diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 64c3922b57..93330aaccc 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,61 @@ 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 + ? Object.assign({}, 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 +576,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", () => @@ -1530,6 +1597,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..f720baf30f 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 && (