diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747..1625ebd4d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -26,6 +26,7 @@ import { type TerminalContextDraft, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; +import { buildPlanImplementationPrompt } from "../proposedPlan"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -381,6 +382,47 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithActionablePlan(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-plan-actionable-target" as MessageId, + targetText: "plan thread", + }); + const thread = snapshot.threads[0]!; + return { + ...snapshot, + threads: [ + { + ...thread, + interactionMode: "plan", + latestTurn: { + turnId: "turn-plan-actionable" as never, + state: "completed", + requestedAt: isoAt(119), + startedAt: isoAt(120), + completedAt: isoAt(180), + assistantMessageId: null, + }, + proposedPlans: [ + { + id: "plan-browser-test" as never, + turnId: "turn-plan-actionable" as never, + planMarkdown: "# Ship plan mode follow-up\n\n- Step 1\n- Step 2", + implementedAt: null, + implementationThreadId: null, + createdAt: isoAt(170), + updatedAt: isoAt(171), + }, + ], + session: { + ...thread.session!, + status: "ready", + activeTurnId: null, + }, + }, + ], + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -563,6 +605,16 @@ async function waitForSendButton(): Promise { ); } +async function waitForButtonByText(text: string): Promise { + return waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === text, + ) as HTMLButtonElement | null, + `Unable to find ${text} button.`, + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -1597,6 +1649,133 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("defers plan implementation into a draft thread and sends selected adapter settings on Implement", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithActionablePlan(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + ...nextFixture.serverConfig.providers, + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: NOW_ISO, + }, + ], + }; + }, + }); + + try { + const implementationMenuButton = await waitForElement( + () => + document.querySelector('button[aria-label="Implementation actions"]'), + "Unable to find implementation actions button.", + ); + implementationMenuButton.click(); + + const implementInNewThreadButton = await waitForButtonByText("Implement in a new thread"); + implementInNewThreadButton.click(); + + const draftThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should change to a deferred implementation draft thread UUID.", + ); + const draftThreadId = draftThreadPath.slice(1) as ThreadId; + + expect( + wsRequests.some( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + (request.command as { type?: string } | undefined)?.type === "thread.create", + ), + ).toBe(false); + expect( + wsRequests.some( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + (request.command as { type?: string } | undefined)?.type === "thread.turn.start", + ), + ).toBe(false); + + const composerEditor = await waitForComposerEditor(); + await vi.waitFor( + () => { + expect(composerEditor.textContent).toContain( + buildPlanImplementationPrompt("# Ship plan mode follow-up\n\n- Step 1\n- Step 2"), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect.element(page.getByRole("button", { name: "Implement" })).toBeInTheDocument(); + expect(document.querySelector('button[aria-label="Send message"]')).toBeNull(); + expect(document.body.textContent).not.toContain("Implement in a new thread"); + + const providerPickerButton = await waitForElement( + () => + Array.from( + document.querySelectorAll( + '[data-chat-composer-footer="true"] button', + ), + ).find((button) => button.textContent?.includes("GPT-5")) ?? null, + "Unable to find provider/model picker trigger.", + ); + providerPickerButton.click(); + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Codex"); + expect(text).toContain("Claude"); + }); + await page.getByText("Claude").click(); + await page.getByRole("menuitemradio", { name: "Claude Opus 4.6" }).click(); + + await vi.waitFor(() => { + expect(useComposerDraftStore.getState().draftsByThreadId[draftThreadId]).toMatchObject({ + provider: "claudeAgent", + model: "claude-opus-4-6", + deferredPlanImplementation: { + sourceThreadId: THREAD_ID, + sourcePlanId: "plan-browser-test", + }, + }); + }); + + const implementButton = await waitForButtonByText("Implement"); + implementButton.click(); + + await vi.waitFor( + () => { + const dispatchRequests = wsRequests.filter( + (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, + ) as Array<{ command?: Record }>; + expect( + dispatchRequests.some((request) => request.command?.type === "thread.create"), + ).toBe(true); + const turnStartRequest = dispatchRequests.find( + (request) => request.command?.type === "thread.turn.start", + ); + expect(turnStartRequest?.command).toMatchObject({ + threadId: draftThreadId, + provider: "claudeAgent", + model: "claude-opus-4-6", + sourceProposedPlan: { + threadId: THREAD_ID, + planId: "plan-browser-test", + }, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps long proposed plans lightweight until the user expands them", 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 e628f6ea6..1ee9289ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -240,7 +240,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const markThreadVisited = useStore((store) => store.markThreadVisited); - const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); @@ -271,6 +270,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); + const setComposerDraftModelOptions = useComposerDraftStore((store) => store.setModelOptions); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, @@ -298,11 +298,15 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const createProjectDraftThread = useComposerDraftStore((store) => store.createProjectDraftThread); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const setDeferredPlanImplementation = useComposerDraftStore( + (store) => store.setDeferredPlanImplementation, + ); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -563,6 +567,55 @@ export default function ChatView({ threadId }: ChatViewProps) { [openOrReuseProjectDraftThread], ); + const allocateImplementationDraftThread = useCallback((): ThreadId | null => { + if (!activeProject) { + return null; + } + + const currentDraftThread = getDraftThread(threadId); + if (!isServerThread && currentDraftThread?.projectId === activeProject.id) { + return threadId; + } + + const storedDraftThread = getDraftThreadByProjectId(activeProject.id); + if (storedDraftThread) { + if (storedDraftThread.threadId === threadId) { + return storedDraftThread.threadId; + } + const storedDraft = + useComposerDraftStore.getState().draftsByThreadId[storedDraftThread.threadId]; + const storedDraftHasSendableContent = deriveComposerSendState({ + prompt: storedDraft?.prompt ?? "", + imageCount: storedDraft?.images.length ?? 0, + terminalContexts: storedDraft?.terminalContexts ?? [], + }).hasSendableContent; + if (!storedDraftHasSendableContent && storedDraft?.deferredPlanImplementation == null) { + return storedDraftThread.threadId; + } + } + + clearProjectDraftThreadId(activeProject.id); + return createProjectDraftThread(activeProject.id, { + createdAt: new Date().toISOString(), + runtimeMode, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: activeThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? null, + envMode: activeThread?.worktreePath ? "worktree" : "local", + }); + }, [ + activeProject, + activeThread?.branch, + activeThread?.worktreePath, + clearProjectDraftThreadId, + createProjectDraftThread, + getDraftThread, + getDraftThreadByProjectId, + isServerThread, + runtimeMode, + threadId, + ]); + useEffect(() => { if (!activeThread?.id) return; if (!latestTurnSettled) return; @@ -740,18 +793,25 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const activeDeferredPlanImplementation = composerDraft.deferredPlanImplementation ?? null; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && latestTurnSettled && hasActionableProposedPlan(activeProposedPlan); + const showDeferredImplementationPrompt = + pendingUserInputs.length === 0 && + activeDeferredPlanImplementation !== null && + !showPlanFollowUpPrompt; const activePendingApproval = pendingApprovals[0] ?? null; const isComposerApprovalState = activePendingApproval !== null; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || - (showPlanFollowUpPrompt && activeProposedPlan !== null); - const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + (showPlanFollowUpPrompt && activeProposedPlan !== null) || + showDeferredImplementationPrompt; + const composerFooterHasWideActions = + showPlanFollowUpPrompt || showDeferredImplementationPrompt || activePendingProgress !== null; const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -2350,7 +2410,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const promptForSend = promptRef.current; + let promptForSend = promptRef.current; const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, @@ -2377,8 +2437,27 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return; } + if (showDeferredImplementationPrompt && activeDeferredPlanImplementation) { + const seededImplementationPrompt = buildPlanImplementationPrompt( + activeDeferredPlanImplementation.planMarkdown, + ); + const deferredImplementationText = trimmed.length > 0 ? trimmed : seededImplementationPrompt; + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + promptForSend = deferredImplementationText; + } + const hasDeferredImplementationFallback = + showDeferredImplementationPrompt && + activeDeferredPlanImplementation !== null && + trimmed.length === 0; + const effectiveTrimmedPrompt = promptForSend.trim(); const standaloneSlashCommand = - composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 + !showDeferredImplementationPrompt && + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -2390,7 +2469,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger(null); return; } - if (!hasSendableContent) { + if (!hasSendableContent && !hasDeferredImplementationFallback) { if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( expiredTerminalContextCount, @@ -2527,7 +2606,9 @@ export default function ChatView({ threadId }: ChatViewProps) { firstComposerImageName = firstComposerImage.name; } } - let titleSeed = trimmed; + let titleSeed = activeDeferredPlanImplementation?.planMarkdown + ? buildPlanImplementationThreadTitle(activeDeferredPlanImplementation.planMarkdown) + : effectiveTrimmedPrompt; if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; @@ -2625,9 +2706,20 @@ export default function ChatView({ threadId }: ChatViewProps) { assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, + ...(activeDeferredPlanImplementation + ? { + sourceProposedPlan: { + threadId: activeDeferredPlanImplementation.sourceThreadId, + planId: activeDeferredPlanImplementation.sourcePlanId, + }, + } + : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; + if (activeDeferredPlanImplementation) { + setDeferredPlanImplementation(threadIdForSend, null); + } })().catch(async (err: unknown) => { if (createdServerThreadForLocalDraft && !turnStartSucceeded) { await api.orchestration @@ -2963,9 +3055,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onImplementPlanInNewThread = useCallback(async () => { - const api = readNativeApi(); if ( - !api || !activeThread || !activeProject || !activeProposedPlan || @@ -2977,116 +3067,50 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } - const createdAt = new Date().toISOString(); - const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; - const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); - const outgoingImplementationPrompt = formatOutgoingPrompt({ - provider: selectedProvider, - effort: selectedPromptEffort, - text: implementationPrompt, - }); - const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); - const nextThreadModel: ModelSlug = - selectedModel || - (activeThread.model as ModelSlug) || - (activeProject.model as ModelSlug) || - DEFAULT_MODEL_BY_PROVIDER.codex; + const nextThreadId = allocateImplementationDraftThread(); + if (!nextThreadId) { + return; + } - sendInFlightRef.current = true; - beginSendPhase("sending-turn"); - const finish = () => { - sendInFlightRef.current = false; - resetSendPhase(); - }; + setComposerDraftPrompt(nextThreadId, buildPlanImplementationPrompt(planMarkdown)); + setComposerDraftProvider(nextThreadId, selectedProvider); + setComposerDraftModel(nextThreadId, selectedModel); + setComposerDraftModelOptions(nextThreadId, selectedModelOptionsForDispatch); + setComposerDraftRuntimeMode(nextThreadId, runtimeMode); + setComposerDraftInteractionMode(nextThreadId, "default"); + setDeferredPlanImplementation(nextThreadId, { + sourceThreadId: activeThread.id, + sourcePlanId: activeProposedPlan.id, + planMarkdown, + }); - await api.orchestration - .dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: nextThreadId, - projectId: activeProject.id, - title: nextThreadTitle, - model: nextThreadModel, - 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: [], - }, - provider: selectedProvider, - model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch - ? { modelOptions: selectedModelOptionsForDispatch } - : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", - 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 }, - }); - }) - .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); - toastManager.add({ - type: "error", - title: "Could not start implementation thread", - description: - err instanceof Error ? err.message : "An error occurred while creating the new thread.", - }); - }) - .then(finish, finish); + // Signal that the plan sidebar should open on the new thread. + planSidebarOpenOnNextThreadRef.current = true; + await navigate({ + to: "/$threadId", + params: { threadId: nextThreadId }, + }); }, [ activeProject, activeProposedPlan, activeThread, - beginSendPhase, + allocateImplementationDraftThread, isConnecting, isSendBusy, isServerThread, navigate, - resetSendPhase, runtimeMode, - selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, - providerOptionsForDispatch, selectedProvider, - settings.enableAssistantStreaming, - syncServerReadModel, + setComposerDraftInteractionMode, + setComposerDraftModel, + setComposerDraftModelOptions, + setComposerDraftPrompt, + setComposerDraftProvider, + setComposerDraftRuntimeMode, + setDeferredPlanImplementation, ]); const onProviderModelSelect = useCallback( @@ -3612,6 +3636,15 @@ export default function ChatView({ threadId }: ChatViewProps) { planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null} /> + ) : showDeferredImplementationPrompt && activeDeferredPlanImplementation ? ( +
+ +
) : null}
@@ -3993,6 +4028,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
) + ) : showDeferredImplementationPrompt ? ( + ) : (