Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/renderer/components/providers/claude/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ registerComposerControls("claude", ({ capabilities, config, isDisabled, onConfig
hideLabelOnWrap: true,
value:
config.approvalPolicy ??
capabilities.bypassApprovalPolicy ??
capabilities.bypassPermissions?.approvalPolicy ??
capabilities.approvalPolicies[0]?.id ??
"default",
isDisabled,
Expand Down
113 changes: 58 additions & 55 deletions src/renderer/components/providers/codex/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ export * from "./CodexStatusIcon";

import type { ComposerControl } from "@/renderer/components/thread/ThreadComposer";
import { CodexStatusIcon } from "./CodexStatusIcon";
import { fullAccessToggle, planWorkToggle } from "../composerControlBuilders";
import { planWorkToggle } from "../composerControlBuilders";
import type { AgentCapability, ThreadConfig } from "@/shared/contracts";
import {
registerCommitGenDefaults,
registerComposerControls,
Expand Down Expand Up @@ -96,8 +97,8 @@ const CODEX_PERMISSION_PRESETS = [
{
id: "auto-review",
label: "Auto-review",
hint: "Review failures",
approvalPolicies: ["on-failure"],
hint: "Review on request",
approvalPolicies: ["on-request"],
sandboxModes: ["workspace-write"],
},
{
Expand Down Expand Up @@ -137,8 +138,45 @@ function isCodexPermissionPresetSelected(
);
}

function buildCodexPermissionControl(
capabilities: AgentCapability,
config: ThreadConfig,
isDisabled: boolean,
onConfigChange: (patch: Partial<ThreadConfig>) => void,
): ComposerControl | null {
const approvalIds = new Set(capabilities.approvalPolicies.map((policy) => policy.id));
const sandboxIds = new Set(capabilities.sandboxModes.map((mode) => mode.id));
const permissionPresets = CODEX_PERMISSION_PRESETS.flatMap((preset) => {
const resolved = resolveCodexPermissionPreset(preset, approvalIds, sandboxIds);
return resolved ? [{ ...preset, ...resolved }] : [];
});
if (permissionPresets.length === 0) return null;
const current =
permissionPresets.find((preset) => isCodexPermissionPresetSelected(preset, config)) ??
permissionPresets[0]!;
return {
iconKind: "permission",
options: permissionPresets.map((preset) => ({
id: preset.id,
label: preset.label,
hint: preset.hint,
})),
hideLabelOnWrap: true,
value: current.id,
isDisabled,
onChange: (value: string) => {
const preset = permissionPresets.find((option) => option.id === value);
if (!preset) return;
onConfigChange({
approvalPolicy: preset.approvalPolicy,
sandboxMode: preset.sandboxMode,
});
},
};
}

registerComposerControls("codex", {
// ACP exposes plan mode and the coupled approval/sandbox preset selector.
// ACP exposes plan mode in addition to the preset selector.
gui: ({ capabilities, config, isDisabled, onConfigChange }) => {
const isPlanMode = (config.mode ?? "agent") !== "agent";
const controls: ComposerControl[] = [
Expand All @@ -148,59 +186,24 @@ registerComposerControls("codex", {
onChange: (isSelected) => onConfigChange({ mode: isSelected ? "plan" : "agent" }),
}),
];

const approvalIds = new Set(capabilities.approvalPolicies.map((policy) => policy.id));
const sandboxIds = new Set(capabilities.sandboxModes.map((mode) => mode.id));
const permissionPresets = CODEX_PERMISSION_PRESETS.flatMap((preset) => {
const resolved = resolveCodexPermissionPreset(preset, approvalIds, sandboxIds);
return resolved ? [{ ...preset, ...resolved }] : [];
});
if (permissionPresets.length > 0) {
const currentPermissionPreset =
permissionPresets.find((preset) => isCodexPermissionPresetSelected(preset, config)) ??
permissionPresets[0]!;
controls.push({
iconKind: "permission",
options: permissionPresets.map((preset) => ({
id: preset.id,
label: preset.label,
hint: preset.hint,
})),
hideLabelOnWrap: true,
value: currentPermissionPreset.id,
isDisabled,
onChange: (value) => {
const preset = permissionPresets.find((option) => option.id === value);
if (!preset) return;
onConfigChange({
approvalPolicy: preset.approvalPolicy,
sandboxMode: preset.sandboxMode,
});
},
});
}
const permission = buildCodexPermissionControl(
capabilities,
config,
isDisabled,
onConfigChange,
);
if (permission) controls.push(permission);
return controls;
},
// Terminal CLI ignores `mode: "plan"` and exposes a single Full Access
// toggle instead of the paired approval/sandbox selector.
// Terminal CLI ignores `mode: "plan"` but uses the same preset selector
// as GUI for permissions so both surfaces stay in lockstep.
terminal: ({ capabilities, config, isDisabled, onConfigChange }) => {
const hasPermissions =
capabilities.approvalPolicies.length > 0 || capabilities.sandboxModes.length > 0;
if (!hasPermissions) return [];
const isFullAccess =
config.approvalPolicy === "never" && config.sandboxMode === "danger-full-access";
return [
fullAccessToggle({
isFullAccess,
isDisabled,
onChange: (selected) => {
if (selected) {
onConfigChange({ approvalPolicy: "never", sandboxMode: "danger-full-access" });
} else {
onConfigChange({ approvalPolicy: "", sandboxMode: "" });
}
},
}),
];
const permission = buildCodexPermissionControl(
capabilities,
config,
isDisabled,
onConfigChange,
);
return permission ? [permission] : [];
},
});
34 changes: 22 additions & 12 deletions src/renderer/components/thread/ContinueInProviderDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,18 @@ function resolveModeValue(
: (capabilities.modes[0] ?? undefined);
}

function resolveApprovalPolicyValue(capabilities: AgentCapability, preferred?: string): string {
const policies = capabilities.approvalPolicies;
return preferred && policies.some((p) => p.id === preferred)
? preferred
: (capabilities.bypassApprovalPolicy ?? policies[0]?.id ?? "");
}

function resolveSandboxModeValue(capabilities: AgentCapability, preferred?: string): string {
const modes = capabilities.sandboxModes;
return preferred && modes.some((m) => m.id === preferred) ? preferred : (modes[0]?.id ?? "");
function resolveLabeledOptionValue(
options: ReadonlyArray<{ id: string }>,
preferred: string | undefined,
bypass: string | undefined,
): string {
if (preferred !== undefined) {
return options.some((o) => o.id === preferred) ? preferred : "";
}
if (bypass && options.some((o) => o.id === bypass)) {
return bypass;
}
return options[0]?.id ?? "";
}

function resolveDefaultConfig(
Expand All @@ -124,8 +126,16 @@ function resolveDefaultConfig(
? preferred?.thinking === true
: false;
const mode = resolveModeValue(capabilities, preferred?.mode);
const approvalPolicy = resolveApprovalPolicyValue(capabilities, preferred?.approvalPolicy);
const sandboxMode = resolveSandboxModeValue(capabilities, preferred?.sandboxMode);
const approvalPolicy = resolveLabeledOptionValue(
capabilities.approvalPolicies,
preferred?.approvalPolicy,
capabilities.bypassPermissions?.approvalPolicy,
);
const sandboxMode = resolveLabeledOptionValue(
capabilities.sandboxModes,
preferred?.sandboxMode,
capabilities.bypassPermissions?.sandboxMode,
);

return {
model,
Expand Down
22 changes: 13 additions & 9 deletions src/renderer/components/thread/ThreadDraftView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ const codexStatus: AgentStatus = {
{ id: "read-only", label: "Read Only" },
{ id: "danger-full-access", label: "Full Access" },
],
defaultApprovalPolicy: "on-request",
defaultSandboxMode: "workspace-write",
supportsResume: true,
supportsDirectInput: true,
liveInputMode: "server",
Expand Down Expand Up @@ -127,6 +129,7 @@ const geminiStatus: AgentStatus = {
supportsDirectInput: true,
liveInputMode: "terminal",
presentationMode: "terminal",
defaultApprovalPolicy: "never",
settingDefs: [],
},
};
Expand All @@ -151,7 +154,7 @@ const antigravityStatus: AgentStatus = {
liveInputMode: "terminal",
presentationMode: "terminal",
presentationModes: ["terminal"],
bypassApprovalPolicy: "yolo",
bypassPermissions: { approvalPolicy: "yolo" },
settingDefs: [],
},
};
Expand All @@ -178,7 +181,8 @@ const claudeStatus: AgentStatus = {
supportsDirectInput: true,
liveInputMode: "terminal",
presentationMode: "terminal",
bypassApprovalPolicy: "auto",
defaultApprovalPolicy: "auto",
bypassPermissions: { approvalPolicy: "auto" },
settingDefs: [],
},
};
Expand Down Expand Up @@ -402,7 +406,7 @@ describe("ThreadDraftView", () => {
expect(providerModel?.currentModel).toBe("gpt-5.4");
const effortContext = props.controls.find((c) => c.kind === "effort-context");
expect(effortContext?.effortValue).toBe("high");
expect(props.controls.some((control) => control.value === "default-permissions")).toBe(true);
expect(props.controls.some((control) => control.value === "auto-review")).toBe(true);
});

fireEvent.click(screen.getByText("set-prompt"));
Expand All @@ -414,13 +418,15 @@ describe("ThreadDraftView", () => {
model: "gpt-5.4",
effort: "high",
mode: "agent",
approvalPolicy: "on-request",
sandboxMode: "workspace-write",
},
presentationMode: "gui",
prompt: "hello world",
});
});

it("defaults Home Codex drafts to full access without changing project defaults", async () => {
it("defaults Home Codex drafts to provider defaults, same as any other project", async () => {
const onStart = vi.fn<(input: unknown) => void>();
useSharedSettings.setState({
providerConfigs: {
Expand Down Expand Up @@ -454,7 +460,7 @@ describe("ThreadDraftView", () => {
const providerModel = props.controls.find((c) => c.kind === "provider-model");
expect(providerModel?.currentAgentKind).toBe("codex");
expect(providerModel?.currentModel).toBe("gpt-5.4");
expect(props.controls.some((control) => control.value === "full-access")).toBe(true);
expect(props.controls.some((control) => control.value === "auto-review")).toBe(true);
});

fireEvent.click(screen.getByText("set-prompt"));
Expand All @@ -466,14 +472,12 @@ describe("ThreadDraftView", () => {
model: "gpt-5.4",
effort: "high",
mode: "agent",
approvalPolicy: "never",
sandboxMode: "danger-full-access",
approvalPolicy: "on-request",
sandboxMode: "workspace-write",
},
presentationMode: "gui",
prompt: "hello world",
});
expect(useSharedSettings.getState().providerConfigs.codex?.approvalPolicy).toBe("");
expect(useSharedSettings.getState().providerConfigs.codex?.sandboxMode).toBe("");
});

it("defaults synthetic generic ACP permissions to supervised", async () => {
Expand Down
40 changes: 15 additions & 25 deletions src/renderer/components/thread/ThreadDraftView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,7 @@ export function ThreadDraftView(props: {
lastDraftConfig,
isHomeScope ? {} : providerConfigsRef.current,
);
const resolved = resolveProviderDraftConfig(selectedAgentForConfig, saved, {
preferUnrestrictedPermissions: isHomeScope,
});
const resolved = resolveProviderDraftConfig(selectedAgentForConfig, saved);
const nextModel = resolved.model;
const nextEffort = resolved.effort ?? "";
const nextContext = resolved.contextSize;
Expand Down Expand Up @@ -527,20 +525,16 @@ export function ThreadDraftView(props: {
}
if (!selectedAgentForConfig) return;
hasLocalConfigEditRef.current = true;
const resolved = resolveProviderDraftConfig(
selectedAgentForConfig,
{
model: patch.model ?? model,
effort: patch.effort ?? effort,
...(patch.contextSize !== undefined ? { contextSize: patch.contextSize } : { contextSize }),
...(patch.fast !== undefined ? { fast: patch.fast } : { fast }),
...(patch.thinking !== undefined ? { thinking: patch.thinking } : { thinking }),
mode: patch.mode ?? mode,
approvalPolicy: patch.approvalPolicy ?? approvalPolicy,
sandboxMode: patch.sandboxMode ?? sandboxMode,
},
{ preferUnrestrictedPermissions: isHomeScope },
);
const resolved = resolveProviderDraftConfig(selectedAgentForConfig, {
model: patch.model ?? model,
effort: patch.effort ?? effort,
...(patch.contextSize !== undefined ? { contextSize: patch.contextSize } : { contextSize }),
...(patch.fast !== undefined ? { fast: patch.fast } : { fast }),
...(patch.thinking !== undefined ? { thinking: patch.thinking } : { thinking }),
mode: patch.mode ?? mode,
approvalPolicy: patch.approvalPolicy ?? approvalPolicy,
sandboxMode: patch.sandboxMode ?? sandboxMode,
});

setModel(resolved.model);
setEffort(resolved.effort ?? "");
Expand Down Expand Up @@ -604,14 +598,10 @@ export function ThreadDraftView(props: {
persistProviderConfig(effectiveAgentKind, snapshot);
}
const targetSaved = isHomeScope ? undefined : providerConfigsRef.current[nextKind];
const resolved = resolveProviderDraftConfig(
targetAgentForConfig,
{
...(targetSaved ?? {}),
model: nextModel,
},
{ preferUnrestrictedPermissions: isHomeScope },
);
const resolved = resolveProviderDraftConfig(targetAgentForConfig, {
...(targetSaved ?? {}),
model: nextModel,
});
persistProviderConfig(nextKind, resolved);
setModel(resolved.model);
setEffort(resolved.effort ?? "");
Expand Down
13 changes: 10 additions & 3 deletions src/renderer/components/thread/ThreadHeaderStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function ThreadHeaderStatusTooltipBody(props: { thread: Thread }) {
const runtime = threadRuntimeStatusLabel(thread);
const source = thread.threadStatusSource;
const isServer = source === "server";
const errorMessage = thread.status === "error" ? thread.errorMessage?.trim() : undefined;

return (
<div className="w-[min(22rem,calc(100vw-2rem))] space-y-3 py-3 pl-2 pr-5 [overflow-wrap:break-word] [word-break:normal] hyphens-none">
Expand All @@ -79,9 +80,15 @@ export function ThreadHeaderStatusTooltipBody(props: { thread: Thread }) {
</p>
)}
</div>
<p className="border-t border-border/60 pt-2.5 text-xs leading-snug text-muted [overflow-wrap:break-word] [word-break:normal] hyphens-none">
{threadStatusSupportDetail(source)}
</p>
{errorMessage ? (
<p className="max-h-32 overflow-y-auto whitespace-pre-wrap break-words border-t border-border/60 pt-2.5 text-xs leading-snug text-danger">
{errorMessage}
</p>
) : (
<p className="border-t border-border/60 pt-2.5 text-xs leading-snug text-muted [overflow-wrap:break-word] [word-break:normal] hyphens-none">
{threadStatusSupportDetail(source)}
</p>
)}
</div>
);
}
Expand Down
Loading