From 837a15181948b3c7ffbb078b92557c9a1c2046c4 Mon Sep 17 00:00:00 2001 From: Quentin Date: Mon, 9 Mar 2026 21:23:24 -0400 Subject: [PATCH 1/4] feat: support plan implementation in new worktrees --- apps/server/src/codexAppServerManager.test.ts | 72 +++ apps/server/src/codexAppServerManager.ts | 16 + .../src/provider/Layers/CodexAdapter.test.ts | 47 +- .../src/provider/Layers/CodexAdapter.ts | 17 + apps/web/src/components/ChatView.browser.tsx | 188 ++++++- apps/web/src/components/ChatView.tsx | 496 ++++++++++++++---- 6 files changed, 730 insertions(+), 106 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index f614b92302..51f0fc348c 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -205,6 +206,77 @@ describe("classifyCodexStderrLine", () => { message: line, }); }); + + it("emits process/stderr as a notification event", () => { + const manager = new CodexAppServerManager(); + const events: Array<{ method: string; kind: string; message?: string }> = []; + manager.on("event", (event) => { + events.push({ + method: event.method, + kind: event.kind, + ...(event.message ? { message: event.message } : {}), + }); + }); + + const output = new EventEmitter(); + const stderr = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { stderr: EventEmitter }; + child.stderr = stderr; + + type ProcessListenerHarnessContext = { + session: { + provider: "codex"; + status: "ready"; + threadId: ThreadId; + runtimeMode: "full-access"; + createdAt: string; + updatedAt: string; + }; + child: EventEmitter & { stderr: EventEmitter }; + output: EventEmitter; + pending: Map; + pendingApprovals: Map; + pendingUserInputs: Map; + nextRequestId: number; + stopping: boolean; + }; + + const context: ProcessListenerHarnessContext = { + session: { + provider: "codex" as const, + status: "ready" as const, + threadId: asThreadId("thread-1"), + runtimeMode: "full-access" as const, + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + child, + output, + pending: new Map(), + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + nextRequestId: 1, + stopping: false, + }; + + ( + manager as unknown as { + attachProcessListeners: (context: ProcessListenerHarnessContext) => void; + } + ).attachProcessListeners(context); + + const line = + "2026-03-10T01:03:53.921955Z ERROR codex_core::models_manager::manager: failed to renew cache TTL: EOF while parsing a value at line 1 column 0"; + stderr.emit("data", Buffer.from(`${line}\n`)); + + expect(events).toEqual([ + { + method: "process/stderr", + kind: "notification", + message: line, + }, + ]); + }); }); describe("process stderr events", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 1f0abd6d73..0e28baf304 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -1342,6 +1342,22 @@ export class CodexAppServerManager extends EventEmitter { assert.equal(firstEvent.payload.reason, "Sandbox setup failed"); } - assert.equal(secondEvent?.type, "runtime.warning"); - if (secondEvent?.type === "runtime.warning") { - assert.equal(secondEvent.payload.message, "Sandbox setup failed"); + assert.equal(secondEvent?.type, "runtime.warning"); + if (secondEvent?.type === "runtime.warning") { + assert.equal(secondEvent.payload.message, "Sandbox setup failed"); + } + }), + ); + + it.effect("maps process/stderr notifications into runtime warnings", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 1)).pipe( + Effect.timeoutOption(1_000), + Effect.forkChild, + ); + + const event: ProviderEvent = { + id: asEventId("evt-process-stderr"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "process/stderr", + message: + "2026-03-10T01:03:53.921955Z ERROR codex_core::models_manager::manager: failed to renew cache TTL: EOF while parsing a value at line 1 column 0", + }; + + lifecycleManager.emit("event", event); + const maybeEvents = yield* Fiber.join(eventsFiber); + + assert.equal(Option.isSome(maybeEvents), true); + if (Option.isNone(maybeEvents)) { + return; + } + + const events = Array.from(maybeEvents.value); + assert.equal(events.length, 1); + + const warningEvent = events[0]; + assert.equal(warningEvent?.type, "runtime.warning"); + if (warningEvent?.type === "runtime.warning") { + assert.equal( + warningEvent.payload.message, + "2026-03-10T01:03:53.921955Z ERROR codex_core::models_manager::manager: failed to renew cache TTL: EOF while parsing a value at line 1 column 0", + ); } }), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index b9ac4bfc4a..03210a5276 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -696,6 +696,23 @@ function mapToRuntimeEvents( ]; } + if (event.method === "process/stderr") { + if (!event.message) { + return []; + } + + return [ + { + type: "runtime.warning", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + message: event.message, + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; + } + if (event.method === "thread/started") { const payloadThreadId = asString(asObject(payload?.thread)?.id); const providerThreadId = payloadThreadId ?? asString(payload?.threadId); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..8c5f0e6fdc 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -8,6 +8,7 @@ import { type ProjectId, type ServerConfig, type ThreadId, + type TurnId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, @@ -435,6 +436,62 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithProposedPlan(options?: { + branch?: string | null; + worktreePath?: string | null; + scripts?: OrchestrationReadModel["projects"][number]["scripts"]; +}): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-plan-target" as MessageId, + targetText: "plan thread", + }); + const turnId = "turn-proposed-plan-browser" as TurnId; + return { + ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID ? { ...project, scripts: options?.scripts ?? [] } : project, + ), + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + interactionMode: "plan", + branch: options?.branch ?? "main", + worktreePath: options?.worktreePath ?? null, + latestTurn: { + turnId, + state: "completed", + requestedAt: isoAt(100), + startedAt: isoAt(101), + completedAt: isoAt(102), + assistantMessageId: "msg-assistant-plan" as MessageId, + }, + proposedPlans: [ + { + id: "plan-browser" as OrchestrationReadModel["threads"][number]["proposedPlans"][number]["id"], + turnId, + planMarkdown: "# Build this feature\n\nImplement the approved plan.", + implementedAt: null, + implementationThreadId: null, + createdAt: isoAt(102), + updatedAt: isoAt(102), + }, + ], + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(102), + }, + } + : thread, + ), + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { @@ -520,12 +577,23 @@ const worker = setupWorker( const method = request.body?._tag; if (typeof method !== "string") return; wsRequests.push(request.body); - client.send( - JSON.stringify({ - id: request.id, - result: resolveWsRpc(request.body), - }), - ); + try { + client.send( + JSON.stringify({ + id: request.id, + result: resolveWsRpc(request.body), + }), + ); + } catch (error) { + client.send( + JSON.stringify({ + id: request.id, + error: { + message: error instanceof Error ? error.message : "Mock websocket request failed.", + }, + }), + ); + } }); }), http.get("*/attachments/:attachmentId", () => @@ -1517,6 +1585,114 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows implement-in-new-worktree in the plan implementation actions", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithProposedPlan(), + }); + + try { + await page.getByRole("button", { name: "Implementation actions" }).click(); + await expect.element(page.getByRole("menuitem", { name: "Implement in a new thread" })).toBeVisible(); + await expect.element( + page.getByRole("menuitem", { name: "Implement in new worktree" }), + ).toBeVisible(); + } finally { + await mounted.cleanup(); + } + }); + + it("starts plan implementation in a newly created worktree from the current branch", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithProposedPlan(), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitCreateWorktree) { + return { + worktree: { + path: "/repo/project/.t3/worktrees/t3code-abcd1234", + branch: "t3code/abcd1234", + }, + }; + } + return undefined; + }, + }); + + try { + await page.getByRole("button", { name: "Implementation actions" }).click(); + await page.getByRole("menuitem", { name: "Implement in new worktree" }).click(); + + await vi.waitFor( + () => { + const createWorktreeRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.gitCreateWorktree, + ); + expect(createWorktreeRequest).toMatchObject({ + _tag: WS_METHODS.gitCreateWorktree, + cwd: "/repo/project", + branch: "main", + }); + + const dispatches = wsRequests.filter( + (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, + ); + const createThreadCommand = dispatches + .map( + (request) => + request.command as { type?: string; branch?: string; worktreePath?: string }, + ) + .find((command) => command.type === "thread.create"); + expect(createThreadCommand).toMatchObject({ + type: "thread.create", + branch: "t3code/abcd1234", + worktreePath: "/repo/project/.t3/worktrees/t3code-abcd1234", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows rollback options when worktree creation succeeds but the implementation thread fails", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithProposedPlan(), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitCreateWorktree) { + return { + worktree: { + path: "/repo/project/.t3/worktrees/t3code-abcd1234", + branch: "t3code/abcd1234", + }, + }; + } + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + const command = body.command as { type?: string } | undefined; + if (command?.type === "thread.create") { + throw new Error("thread.create failed"); + } + } + return undefined; + }, + }); + + try { + await page.getByRole("button", { name: "Implementation actions" }).click(); + await page.getByRole("menuitem", { name: "Implement in new worktree" }).click(); + + await expect.element(page.getByText(/keep worktree/i)).toBeVisible(); + await expect.element(page.getByText(/delete worktree/i)).toBeVisible(); + await expect.element( + page.getByText("/repo/project/.t3/worktrees/t3code-abcd1234"), + ).toBeVisible(); + } finally { + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..6abba51e54 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,7 +27,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { + gitBranchesQueryOptions, + gitCreateWorktreeMutationOptions, + invalidateGitQueries, +} from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; @@ -101,6 +105,15 @@ import { Separator } from "./ui/separator"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -245,6 +258,12 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +interface ImplementationWorktreeFailureState { + projectCwd: string; + worktreePath: string; + errorMessage: string; +} + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); @@ -358,6 +377,8 @@ export default function ChatView({ threadId }: ChatViewProps) { useState(null); const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] = useState(null); + const [implementationWorktreeFailure, setImplementationWorktreeFailure] = + useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -1369,51 +1390,72 @@ export default function ChatView({ threadId }: ChatViewProps) { async ( script: ProjectScript, options?: { + targetThreadId?: ThreadId; + targetProject?: { id: ProjectId; cwd: string }; + targetThreadWorktreePath?: string | null; cwd?: string; env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; + allowLocalDraftThread?: boolean; + throwOnError?: boolean; }, ) => { const api = readNativeApi(); - if (!api || !activeThreadId || !activeProject || !activeThread) return; + const targetThreadId = options?.targetThreadId ?? activeThreadId; + const targetProject = options?.targetProject ?? activeProject; + const targetThreadWorktreePath = + options?.targetThreadWorktreePath ?? activeThread?.worktreePath ?? null; + if (!api || !targetThreadId || !targetProject) return; + if (targetThreadId === activeThreadId && !isServerThread && !options?.allowLocalDraftThread) { + return; + } if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; + if (current[targetProject.id] === script.id) return current; + return { ...current, [targetProject.id]: script.id }; }); } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; + const terminalStore = useTerminalStateStore.getState(); + const targetTerminalState = selectThreadTerminalState( + terminalStore.terminalStateByThreadId, + targetThreadId, + ); + const targetCwd = + options?.cwd ?? options?.worktreePath ?? targetThreadWorktreePath ?? targetProject.cwd; const baseTerminalId = - terminalState.activeTerminalId || - terminalState.terminalIds[0] || + targetTerminalState.activeTerminalId || + targetTerminalState.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); + const isBaseTerminalBusy = targetTerminalState.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = wantsNewTerminal; + const shouldCreateNewTerminal = + wantsNewTerminal && targetTerminalState.terminalIds.length < MAX_TERMINALS_PER_GROUP; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; - setTerminalOpen(true); + terminalStore.setTerminalOpen(targetThreadId, true); if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadId, targetTerminalId); + terminalStore.newTerminal(targetThreadId, targetTerminalId); } else { - storeSetActiveTerminal(activeThreadId, targetTerminalId); + terminalStore.setActiveTerminal(targetThreadId, targetTerminalId); + } + if (targetThreadId === activeThreadId) { + setTerminalFocusRequestId((value) => value + 1); } - setTerminalFocusRequestId((value) => value + 1); const runtimeEnv = projectScriptRuntimeEnv({ project: { - cwd: activeProject.cwd, + cwd: targetProject.cwd, }, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + worktreePath: options?.worktreePath ?? targetThreadWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); const openTerminalInput: Parameters[0] = shouldCreateNewTerminal ? { - threadId: activeThreadId, + threadId: targetThreadId, terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, @@ -1421,7 +1463,7 @@ export default function ChatView({ threadId }: ChatViewProps) { rows: SCRIPT_TERMINAL_ROWS, } : { - threadId: activeThreadId, + threadId: targetThreadId, terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, @@ -1430,33 +1472,95 @@ export default function ChatView({ threadId }: ChatViewProps) { try { await api.terminal.open(openTerminalInput); await api.terminal.write({ - threadId: activeThreadId, + threadId: targetThreadId, terminalId: targetTerminalId, data: `${script.command}\r`, }); } catch (error) { - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); + const message = + error instanceof Error ? error.message : `Failed to run script "${script.name}".`; + if (options?.throwOnError) { + throw new Error(message, { cause: error }); + } + setThreadError(targetThreadId, message); } }, [ activeProject, activeThread, activeThreadId, - gitCwd, - setTerminalOpen, + isServerThread, setThreadError, - storeNewTerminal, - storeSetActiveTerminal, setLastInvokedScriptByProjectId, - terminalState.activeTerminalId, - terminalState.runningTerminalIds, - terminalState.terminalIds, ], ); + const runWorktreeSetupScript = useCallback( + async (input: { + targetThreadId: ThreadId; + targetProject: { id: ProjectId; cwd: string; scripts: ProjectScript[] }; + targetThreadWorktreePath: string | null; + allowLocalDraftThread?: boolean; + throwOnError?: boolean; + }) => { + const setupScript = setupProjectScript(input.targetProject.scripts); + if (!setupScript) { + return; + } + await runProjectScript(setupScript, { + targetThreadId: input.targetThreadId, + targetProject: input.targetProject, + targetThreadWorktreePath: input.targetThreadWorktreePath, + worktreePath: input.targetThreadWorktreePath, + ...(input.targetThreadWorktreePath ? { cwd: input.targetThreadWorktreePath } : {}), + rememberAsLastInvoked: false, + ...(input.allowLocalDraftThread !== undefined + ? { allowLocalDraftThread: input.allowLocalDraftThread } + : {}), + ...(input.throwOnError !== undefined ? { throwOnError: input.throwOnError } : {}), + }); + }, + [runProjectScript], + ); + + const createWorktreeFromBaseBranch = useCallback( + async (input: { projectCwd: string; baseBranch: string }) => { + const result = await createWorktreeMutation.mutateAsync({ + cwd: input.projectCwd, + branch: input.baseBranch, + newBranch: buildTemporaryWorktreeBranchName(), + }); + return { + branch: result.worktree.branch, + worktreePath: result.worktree.path, + }; + }, + [createWorktreeMutation], + ); + + const syncLatestSnapshot = useCallback(async () => { + const api = readNativeApi(); + if (!api) return; + const snapshot = await api.orchestration.getSnapshot(); + syncServerReadModel(snapshot); + }, [syncServerReadModel]); + + const cleanupImplementationThreadCreation = useCallback( + async (targetThreadId: ThreadId) => { + const api = readNativeApi(); + if (!api) return; + await api.orchestration + .dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: targetThreadId, + }) + .catch(() => undefined); + await syncLatestSnapshot().catch(() => undefined); + }, + [syncLatestSnapshot], + ); + useEffect(() => { if (!pendingPullRequestSetupRequest || !activeProject || !activeThreadId || !activeThread) { return; @@ -2684,14 +2788,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } } if (shouldRunSetupScript) { - const setupScriptOptions: Parameters[1] = { - worktreePath: nextThreadWorktreePath, - rememberAsLastInvoked: false, - }; - if (nextThreadWorktreePath) { - setupScriptOptions.cwd = nextThreadWorktreePath; - } - await runProjectScript(setupScript, setupScriptOptions); + await runWorktreeSetupScript({ + targetThreadId: threadIdForSend, + targetProject: activeProject, + targetThreadWorktreePath: nextThreadWorktreePath, + allowLocalDraftThread: createdServerThreadForLocalDraft, + }); } } @@ -3064,10 +3166,74 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); + const startImplementationThread = useCallback( + async (input: { + nextThreadId: ThreadId; + createdAt: string; + implementationPromptText: string; + nextThreadTitle: string; + nextThreadModelSelection: ModelSelection; + branch: string | null; + worktreePath: string | null; + runWorktreeSetup?: boolean; + }) => { + const api = readNativeApi(); + if (!api || !activeProject) { + throw new Error("Implementation thread could not be started."); + } + + await api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: input.nextThreadId, + projectId: activeProject.id, + title: input.nextThreadTitle, + modelSelection: input.nextThreadModelSelection, + runtimeMode, + interactionMode: "default", + branch: input.branch, + worktreePath: input.worktreePath, + createdAt: input.createdAt, + }); + + if (input.runWorktreeSetup && input.worktreePath) { + await runWorktreeSetupScript({ + targetThreadId: input.nextThreadId, + targetProject: activeProject, + targetThreadWorktreePath: input.worktreePath, + throwOnError: true, + }); + } + + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: input.nextThreadId, + message: { + messageId: newMessageId(), + role: "user", + text: input.implementationPromptText, + attachments: [], + }, + modelSelection: input.nextThreadModelSelection, + titleSeed: input.nextThreadTitle, + runtimeMode, + interactionMode: "default", + createdAt: input.createdAt, + }); + + await syncLatestSnapshot(); + planSidebarOpenOnNextThreadRef.current = true; + await navigate({ + to: "/$threadId", + params: { threadId: input.nextThreadId }, + }); + }, + [activeProject, navigate, runWorktreeSetupScript, runtimeMode, syncLatestSnapshot], + ); + const onImplementPlanInNewThread = useCallback(async () => { - const api = readNativeApi(); if ( - !api || !activeThread || !activeProject || !activeProposedPlan || @@ -3100,62 +3266,17 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase(); }; - await api.orchestration - .dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: nextThreadId, - projectId: activeProject.id, - title: nextThreadTitle, - modelSelection: nextThreadModelSelection, - runtimeMode, - interactionMode: "default", - branch: activeThread.branch, - worktreePath: activeThread.worktreePath, - createdAt, - }) - .then(() => { - return api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: nextThreadId, - message: { - messageId: newMessageId(), - role: "user", - text: outgoingImplementationPrompt, - attachments: [], - }, - modelSelection: selectedModelSelection, - titleSeed: nextThreadTitle, - runtimeMode, - interactionMode: "default", - createdAt, - }); - }) - .then(() => api.orchestration.getSnapshot()) - .then((snapshot) => { - syncServerReadModel(snapshot); - // Signal that the plan sidebar should open on the new thread. - planSidebarOpenOnNextThreadRef.current = true; - return navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, - }); - }) + await startImplementationThread({ + nextThreadId, + createdAt, + implementationPromptText: outgoingImplementationPrompt, + nextThreadTitle, + nextThreadModelSelection, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + }) .catch(async (err) => { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: nextThreadId, - }) - .catch(() => undefined); - await api.orchestration - .getSnapshot() - .then((snapshot) => { - syncServerReadModel(snapshot); - }) - .catch(() => undefined); + await cleanupImplementationThreadCreation(nextThreadId); toastManager.add({ type: "error", title: "Could not start implementation thread", @@ -3169,20 +3290,162 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan, activeThread, beginSendPhase, + cleanupImplementationThreadCreation, isConnecting, isSendBusy, isServerThread, - navigate, resetSendPhase, - runtimeMode, selectedPromptEffort, selectedModelSelection, selectedProvider, selectedProviderModels, - syncServerReadModel, selectedModel, + startImplementationThread, + ]); + + const onImplementPlanInNewWorktree = useCallback(async () => { + if ( + !activeThread || + !activeProject || + !activeProposedPlan || + !isServerThread || + !isGitRepo || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return; + } + + const baseBranch = + branchesQuery.data?.branches.find((branch) => branch.current)?.name ?? activeThread.branch; + if (!baseBranch) { + toastManager.add({ + type: "error", + title: "Could not create implementation worktree", + description: "Check out or select a branch before starting implementation in a new worktree.", + }); + return; + } + + const createdAt = new Date().toISOString(); + const nextThreadId = newThreadId(); + const planMarkdown = activeProposedPlan.planMarkdown; + const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); + const outgoingImplementationPrompt = formatOutgoingPrompt({ + provider: selectedProvider, + model: selectedModel, + models: selectedProviderModels, + effort: selectedPromptEffort, + text: implementationPrompt, + }); + const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); + const nextThreadModelSelection: ModelSelection = selectedModelSelection; + + sendInFlightRef.current = true; + beginSendPhase("preparing-worktree"); + const finish = () => { + sendInFlightRef.current = false; + resetSendPhase(); + }; + + let createdWorktreePath: string | null = null; + await createWorktreeFromBaseBranch({ + projectCwd: activeProject.cwd, + baseBranch, + }) + .then(async (worktree) => { + createdWorktreePath = worktree.worktreePath; + await startImplementationThread({ + nextThreadId, + createdAt, + implementationPromptText: outgoingImplementationPrompt, + nextThreadTitle, + nextThreadModelSelection, + branch: worktree.branch, + worktreePath: worktree.worktreePath, + runWorktreeSetup: true, + }); + }) + .catch(async (err) => { + await cleanupImplementationThreadCreation(nextThreadId); + const message = + err instanceof Error ? err.message : "An error occurred while creating the new worktree."; + if (createdWorktreePath) { + setImplementationWorktreeFailure({ + projectCwd: activeProject.cwd, + worktreePath: createdWorktreePath, + errorMessage: message, + }); + return; + } + toastManager.add({ + type: "error", + title: "Could not start implementation worktree", + description: message, + }); + }) + .then(finish, finish); + }, [ + activeProject, + activeProposedPlan, + activeThread, + beginSendPhase, + branchesQuery.data?.branches, + cleanupImplementationThreadCreation, + createWorktreeFromBaseBranch, + isConnecting, + isGitRepo, + isSendBusy, + isServerThread, + resetSendPhase, + selectedPromptEffort, + selectedModel, + selectedModelSelection, + selectedProvider, + selectedProviderModels, + startImplementationThread, ]); + const onKeepImplementationWorktree = useCallback(() => { + if (!implementationWorktreeFailure) { + return; + } + toastManager.add({ + type: "warning", + title: "Implementation worktree kept", + description: implementationWorktreeFailure.errorMessage, + }); + setImplementationWorktreeFailure(null); + }, [implementationWorktreeFailure]); + + const onDeleteImplementationWorktree = useCallback(async () => { + const api = readNativeApi(); + if (!api || !implementationWorktreeFailure) { + return; + } + const { projectCwd, worktreePath, errorMessage } = implementationWorktreeFailure; + setImplementationWorktreeFailure(null); + try { + await api.git.removeWorktree({ + cwd: projectCwd, + path: worktreePath, + }); + await invalidateGitQueries(queryClient); + toastManager.add({ + type: "success", + title: "Implementation worktree deleted", + description: worktreePath, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not delete implementation worktree", + description: error instanceof Error ? error.message : errorMessage, + }); + } + }, [implementationWorktreeFailure, queryClient]); + const onProviderModelSelect = useCallback( (provider: ProviderKind, model: string) => { if (!activeThread) return; @@ -4106,6 +4369,12 @@ export default function ChatView({ threadId }: ChatViewProps) { > Implement in a new thread + void onImplementPlanInNewWorktree()} + > + Implement in new worktree + @@ -4252,6 +4521,39 @@ export default function ChatView({ threadId }: ChatViewProps) { ); })()} + { + if (!open) { + setImplementationWorktreeFailure(null); + } + }} + > + + + Implementation worktree created, but startup failed + + {implementationWorktreeFailure?.errorMessage ?? + "The implementation thread could not be started after creating its worktree."} + + {implementationWorktreeFailure ? ( +

+ {implementationWorktreeFailure.worktreePath} +

+ ) : null} +
+ + }>Close + + + +
+
+ {expandedImage && expandedImageItem && (
Date: Tue, 10 Mar 2026 00:36:50 -0400 Subject: [PATCH 2/4] feat(web): implement approved plans in new worktrees --- apps/server/src/codexAppServerManager.test.ts | 72 ------------------- apps/server/src/codexAppServerManager.ts | 18 +---- .../src/provider/Layers/CodexAdapter.test.ts | 47 +----------- .../src/provider/Layers/CodexAdapter.ts | 17 ----- 4 files changed, 4 insertions(+), 150 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 51f0fc348c..f614b92302 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from "node:events"; import { describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -206,77 +205,6 @@ describe("classifyCodexStderrLine", () => { message: line, }); }); - - it("emits process/stderr as a notification event", () => { - const manager = new CodexAppServerManager(); - const events: Array<{ method: string; kind: string; message?: string }> = []; - manager.on("event", (event) => { - events.push({ - method: event.method, - kind: event.kind, - ...(event.message ? { message: event.message } : {}), - }); - }); - - const output = new EventEmitter(); - const stderr = new EventEmitter(); - const child = new EventEmitter() as EventEmitter & { stderr: EventEmitter }; - child.stderr = stderr; - - type ProcessListenerHarnessContext = { - session: { - provider: "codex"; - status: "ready"; - threadId: ThreadId; - runtimeMode: "full-access"; - createdAt: string; - updatedAt: string; - }; - child: EventEmitter & { stderr: EventEmitter }; - output: EventEmitter; - pending: Map; - pendingApprovals: Map; - pendingUserInputs: Map; - nextRequestId: number; - stopping: boolean; - }; - - const context: ProcessListenerHarnessContext = { - session: { - provider: "codex" as const, - status: "ready" as const, - threadId: asThreadId("thread-1"), - runtimeMode: "full-access" as const, - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - child, - output, - pending: new Map(), - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - nextRequestId: 1, - stopping: false, - }; - - ( - manager as unknown as { - attachProcessListeners: (context: ProcessListenerHarnessContext) => void; - } - ).attachProcessListeners(context); - - const line = - "2026-03-10T01:03:53.921955Z ERROR codex_core::models_manager::manager: failed to renew cache TTL: EOF while parsing a value at line 1 column 0"; - stderr.emit("data", Buffer.from(`${line}\n`)); - - expect(events).toEqual([ - { - method: "process/stderr", - kind: "notification", - message: line, - }, - ]); - }); }); describe("process stderr events", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 0e28baf304..aae669cf46 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -1046,7 +1046,7 @@ export class CodexAppServerManager extends EventEmitter { assert.equal(firstEvent.payload.reason, "Sandbox setup failed"); } - assert.equal(secondEvent?.type, "runtime.warning"); - if (secondEvent?.type === "runtime.warning") { - assert.equal(secondEvent.payload.message, "Sandbox setup failed"); - } - }), - ); - - it.effect("maps process/stderr notifications into runtime warnings", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 1)).pipe( - Effect.timeoutOption(1_000), - Effect.forkChild, - ); - - const event: ProviderEvent = { - id: asEventId("evt-process-stderr"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "process/stderr", - message: - "2026-03-10T01:03:53.921955Z ERROR codex_core::models_manager::manager: failed to renew cache TTL: EOF while parsing a value at line 1 column 0", - }; - - lifecycleManager.emit("event", event); - const maybeEvents = yield* Fiber.join(eventsFiber); - - assert.equal(Option.isSome(maybeEvents), true); - if (Option.isNone(maybeEvents)) { - return; - } - - const events = Array.from(maybeEvents.value); - assert.equal(events.length, 1); - - const warningEvent = events[0]; - assert.equal(warningEvent?.type, "runtime.warning"); - if (warningEvent?.type === "runtime.warning") { - assert.equal( - warningEvent.payload.message, - "2026-03-10T01:03:53.921955Z ERROR codex_core::models_manager::manager: failed to renew cache TTL: EOF while parsing a value at line 1 column 0", - ); + assert.equal(secondEvent?.type, "runtime.warning"); + if (secondEvent?.type === "runtime.warning") { + assert.equal(secondEvent.payload.message, "Sandbox setup failed"); } }), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 03210a5276..b9ac4bfc4a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -696,23 +696,6 @@ function mapToRuntimeEvents( ]; } - if (event.method === "process/stderr") { - if (!event.message) { - return []; - } - - return [ - { - type: "runtime.warning", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - message: event.message, - ...(event.payload !== undefined ? { detail: event.payload } : {}), - }, - }, - ]; - } - if (event.method === "thread/started") { const payloadThreadId = asString(asObject(payload?.thread)?.id); const providerThreadId = payloadThreadId ?? asString(payload?.threadId); From 1ac191cb87e6385f2c260bd10e16a73656b894a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 23:31:12 -0700 Subject: [PATCH 3/4] fix(web): preserve script launches after rebase Co-authored-by: codex --- apps/web/src/components/ChatView.browser.tsx | 5 ++--- apps/web/src/components/ChatView.tsx | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 8c5f0e6fdc..15c954ca43 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -453,8 +453,7 @@ function createSnapshotWithProposedPlan(options?: { ), threads: snapshot.threads.map((thread) => thread.id === THREAD_ID - ? { - ...thread, + ? Object.assign({}, thread, { interactionMode: "plan", branch: options?.branch ?? "main", worktreePath: options?.worktreePath ?? null, @@ -486,7 +485,7 @@ function createSnapshotWithProposedPlan(options?: { lastError: null, updatedAt: isoAt(102), }, - } + }) : thread, ), }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6abba51e54..f720baf30f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1408,7 +1408,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const targetThreadWorktreePath = options?.targetThreadWorktreePath ?? activeThread?.worktreePath ?? null; if (!api || !targetThreadId || !targetProject) return; - if (targetThreadId === activeThreadId && !isServerThread && !options?.allowLocalDraftThread) { + if (targetThreadId !== activeThreadId && !isServerThread && !options?.allowLocalDraftThread) { return; } if (options?.rememberAsLastInvoked !== false) { From cfb0ee7c110537e37ce6b758ec5bc3588374e8aa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 23:41:59 -0700 Subject: [PATCH 4/4] fix(server): revert unrelated stderr event change Co-authored-by: codex --- apps/server/src/codexAppServerManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index aae669cf46..1f0abd6d73 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -1046,7 +1046,7 @@ export class CodexAppServerManager extends EventEmitter