diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..bea28168a6 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the `; function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { - readonly approvalPolicy: "on-request" | "never"; - readonly sandbox: "workspace-write" | "danger-full-access"; + readonly approvalPolicy: "untrusted" | "on-request" | "never"; + readonly sandbox: "read-only" | "workspace-write" | "danger-full-access"; } { - if (runtimeMode === "approval-required") { - return { - approvalPolicy: "on-request", - sandbox: "workspace-write", - }; + switch (runtimeMode) { + case "approval-required": + return { + approvalPolicy: "untrusted", + sandbox: "read-only", + }; + case "auto-accept-edits": + return { + approvalPolicy: "on-request", + sandbox: "workspace-write", + }; + case "full-access": + return { + approvalPolicy: "never", + sandbox: "danger-full-access", + }; } - - return { - approvalPolicy: "never", - sandbox: "danger-full-access", - }; } /** diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5a09d8b6ba..fdc6b5d789 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -14,6 +14,7 @@ import { ApprovalRequestId, ProviderItemId, ProviderRuntimeEvent, + type RuntimeMode, ThreadId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; @@ -2496,57 +2497,63 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("restores base permission mode on sendTurn when interactionMode is default", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; + it.effect.each<{ runtimeMode: RuntimeMode; expectedBase: PermissionMode }>([ + { runtimeMode: "full-access", expectedBase: "bypassPermissions" }, + { runtimeMode: "approval-required", expectedBase: "default" }, + { runtimeMode: "auto-accept-edits", expectedBase: "acceptEdits" }, + ])( + "restores $expectedBase permission mode after plan turn ($runtimeMode)", + ({ runtimeMode, expectedBase }) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "full-access", - }); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode, + }); - // First turn in plan mode - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "plan this", - interactionMode: "plan", - attachments: [], - }); + // First turn in plan mode + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); - // Complete the turn so we can send another - const turnCompletedFiber = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); + // Complete the turn so we can send another + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-plan-restore", - uuid: "result-plan", - } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: `sdk-session-${runtimeMode}`, + uuid: `result-${runtimeMode}`, + } as unknown as SDKMessage); - yield* Fiber.join(turnCompletedFiber); + yield* Fiber.join(turnCompletedFiber); - // Second turn back to default - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "now do it", - interactionMode: "default", - attachments: [], - }); + // Second turn back to default + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "now do it", + interactionMode: "default", + attachments: [], + }); - // First call sets "plan", second call restores "bypassPermissions" (the base for full-access) - assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "bypassPermissions"]); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); + assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", expectedBase]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); it.effect("does not call setPermissionMode when interactionMode is absent", () => { const harness = makeHarness(); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9f2eeb014e..94f8ba0c90 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2693,7 +2693,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? modelSelection.options.thinking : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); - const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; + const runtimeModeToPermission: Record = { + "auto-accept-edits": "acceptEdits", + "full-access": "bypassPermissions", + }; + const permissionMode = runtimeModeToPermission[input.runtimeMode]; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -2881,8 +2885,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ - try: () => - context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), + try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 24de35959e..ae2397d316 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -956,6 +956,16 @@ async function waitForButtonContainingText(text: string): Promise { + return waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="select-item"]')).find((item) => + item.textContent?.includes(text), + ) ?? null, + `Unable to find select item containing "${text}".`, + ); +} + async function expectComposerActionsContained(): Promise { const footer = await waitForElement( () => document.querySelector('[data-chat-composer-footer="true"]'), @@ -2326,6 +2336,32 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows runtime mode descriptions in the desktop composer access select", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + }); + + try { + const runtimeModeSelect = await waitForButtonByText("Full access"); + runtimeModeSelect.click(); + + expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain( + "Ask before commands and file changes", + ); + + const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits"); + expect(autoAcceptItem.textContent).toContain("Auto-approve edits"); + expect((await waitForSelectItemContainingText("Full access")).textContent).toContain( + "Allow commands and edits without prompts", + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps removed terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1bb552e817..6c408ea7b0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -115,10 +115,13 @@ import { ListTodoIcon, LockIcon, LockOpenIcon, + type LucideIcon, + PenLineIcon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; @@ -410,6 +413,29 @@ interface TerminalLaunchContext { worktreePath: string | null; } +const runtimeModeConfig: Record< + RuntimeMode, + { label: string; description: string; icon: LucideIcon } +> = { + "approval-required": { + label: "Supervised", + description: "Ask before commands and file changes.", + icon: LockIcon, + }, + "auto-accept-edits": { + label: "Auto-accept edits", + description: "Auto-approve edits, ask before other actions.", + icon: PenLineIcon, + }, + "full-access": { + label: "Full access", + description: "Allow commands and edits without prompts.", + icon: LockOpenIcon, + }, +}; + +const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; + type PersistentTerminalLaunchContext = Pick; function useLocalDispatchState(input: { @@ -960,6 +986,8 @@ export default function ChatView(props: ChatViewProps) { composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + const runtimeModeOption = runtimeModeConfig[runtimeMode]; + const RuntimeModeIcon = runtimeModeOption.icon; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; @@ -2350,11 +2378,6 @@ export default function ChatView(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const toggleRuntimeMode = useCallback(() => { - void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", - ); - }, [handleRuntimeModeChange, runtimeMode]); const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { @@ -4651,7 +4674,7 @@ export default function ChatView(props: ChatViewProps) { traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} - onToggleRuntimeMode={toggleRuntimeMode} + onRuntimeModeChange={handleRuntimeModeChange} /> ) : ( <> @@ -4693,29 +4716,39 @@ export default function ChatView(props: ChatViewProps) { className="mx-0.5 hidden h-4 sm:block" /> -