From 9a9be39751eb2daef9d18ba83992d94d59325bdc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 09:41:39 -0700 Subject: [PATCH 01/11] Reset stuck send phase after turns settle - Keep the send timer alive while a send is active - Fallback-reset sendPhase when a turn settles after a missed transition --- apps/web/src/components/ChatView.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..94a7300683 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2152,14 +2152,14 @@ export default function ChatView({ threadId }: ChatViewProps) { : "local"; useEffect(() => { - if (phase !== "running") return; + if (phase !== "running" && sendPhase === "idle") return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; - }, [phase]); + }, [phase, sendPhase]); const beginSendPhase = useCallback((nextPhase: Exclude) => { setSendStartedAt((current) => current ?? new Date().toISOString()); @@ -2192,6 +2192,19 @@ export default function ChatView({ threadId }: ChatViewProps) { sendPhase, ]); + // Fallback: if the "running" phase transition was missed (e.g. due to React + // batching two snapshots, a WebSocket reconnect, or a very fast turn), the + // primary auto-reset above never fires and sendPhase stays stuck. Detect + // this by checking whether the turn has settled while sendPhase is still + // non-idle — the turn completing is a definitive signal that "sending" is + // over. + useEffect(() => { + if (sendPhase === "idle") return; + if (phase !== "running" && phase !== "connecting" && latestTurnSettled) { + resetSendPhase(); + } + }, [latestTurnSettled, phase, resetSendPhase, sendPhase]); + useEffect(() => { if (!activeThreadId) return; const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; From c91e8b23e8f73a9d0cacf46fc965bbd65b02cfcf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 29 Mar 2026 17:07:04 +0000 Subject: [PATCH 02/11] fix: prevent fallback effect from immediately resetting sendPhase on follow-up messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback effect that catches missed 'running' phase transitions was incorrectly firing on follow-up messages. When beginSendPhase was called, latestTurnSettled was still true from the previous completed turn, causing an immediate resetSendPhase before the server could create the new turn. Fix: record the turnId at the time beginSendPhase is called (via a ref), and only allow the fallback reset when the latest turn differs from the one recorded — meaning a new turn was actually created and has settled. Applied via @cursor push command --- apps/web/src/components/ChatView.tsx | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 94a7300683..244c5151ce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2161,14 +2161,21 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [phase, sendPhase]); - const beginSendPhase = useCallback((nextPhase: Exclude) => { - setSendStartedAt((current) => current ?? new Date().toISOString()); - setSendPhase(nextPhase); - }, []); + const turnIdAtSendRef = useRef(null); + + const beginSendPhase = useCallback( + (nextPhase: Exclude) => { + setSendStartedAt((current) => current ?? new Date().toISOString()); + turnIdAtSendRef.current = activeLatestTurn?.turnId ?? null; + setSendPhase(nextPhase); + }, + [activeLatestTurn?.turnId], + ); const resetSendPhase = useCallback(() => { setSendPhase("idle"); setSendStartedAt(null); + turnIdAtSendRef.current = null; }, []); useEffect(() => { @@ -2198,12 +2205,18 @@ export default function ChatView({ threadId }: ChatViewProps) { // this by checking whether the turn has settled while sendPhase is still // non-idle — the turn completing is a definitive signal that "sending" is // over. + // + // Guard: only reset when the latest turn differs from the one recorded at + // send time. Without this, a follow-up message on a thread whose previous + // turn already settled would be immediately reset before the server creates + // the new turn. useEffect(() => { if (sendPhase === "idle") return; - if (phase !== "running" && phase !== "connecting" && latestTurnSettled) { + const turnChanged = activeLatestTurn?.turnId !== turnIdAtSendRef.current; + if (phase !== "running" && phase !== "connecting" && latestTurnSettled && turnChanged) { resetSendPhase(); } - }, [latestTurnSettled, phase, resetSendPhase, sendPhase]); + }, [activeLatestTurn?.turnId, latestTurnSettled, phase, resetSendPhase, sendPhase]); useEffect(() => { if (!activeThreadId) return; From 8332eb21d59f5fb79254711edf7729d99cf6ec73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 10:30:25 -0700 Subject: [PATCH 03/11] Reset send state with optimistic transition tracking - Replace local send phase state with React transition/optimistic state - Clear send timing reliably after send failures and thread creation --- apps/web/src/components/ChatView.logic.ts | 2 - apps/web/src/components/ChatView.tsx | 627 ++++++++++------------ 2 files changed, 282 insertions(+), 347 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0a27fb203e..0e24d8cc16 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -75,8 +75,6 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ return previewUrls; } -export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; - export interface PullRequestDialogState { initialReference: string | null; key: number; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 244c5151ce..fd8caa451e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -23,7 +23,16 @@ import { } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { truncate } from "@t3tools/shared/String"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useOptimistic, + useRef, + useState, + useTransition, +} from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -175,7 +184,6 @@ import { readFileAsDataUrl, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -330,9 +338,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); - const [sendPhase, setSendPhase] = useState("idle"); - const [sendStartedAt, setSendStartedAt] = useState(null); - const [isConnecting, _setIsConnecting] = useState(false); + const [isSendPending, startSendTransition] = useTransition(); + const sendStartedAtRef = useRef(null); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< @@ -664,14 +671,15 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); - const isSendBusy = sendPhase !== "idle"; - const isPreparingWorktree = sendPhase === "preparing-worktree"; - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const [optimisticPhase, setOptimisticPhase] = useOptimistic(phase); + const isSendBusy = isSendPending; + const isPreparingWorktree = createWorktreeMutation.isPending; + const isWorking = optimisticPhase === "running" || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, - sendStartedAt, + sendStartedAtRef.current, ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -2020,8 +2028,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return []; }); - setSendPhase("idle"); - setSendStartedAt(null); + sendStartedAtRef.current = null; setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); @@ -2152,71 +2159,14 @@ export default function ChatView({ threadId }: ChatViewProps) { : "local"; useEffect(() => { - if (phase !== "running" && sendPhase === "idle") return; + if (optimisticPhase !== "running") return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; - }, [phase, sendPhase]); - - const turnIdAtSendRef = useRef(null); - - const beginSendPhase = useCallback( - (nextPhase: Exclude) => { - setSendStartedAt((current) => current ?? new Date().toISOString()); - turnIdAtSendRef.current = activeLatestTurn?.turnId ?? null; - setSendPhase(nextPhase); - }, - [activeLatestTurn?.turnId], - ); - - const resetSendPhase = useCallback(() => { - setSendPhase("idle"); - setSendStartedAt(null); - turnIdAtSendRef.current = null; - }, []); - - useEffect(() => { - if (sendPhase === "idle") { - return; - } - if ( - phase === "running" || - activePendingApproval !== null || - activePendingUserInput !== null || - activeThread?.error - ) { - resetSendPhase(); - } - }, [ - activePendingApproval, - activePendingUserInput, - activeThread?.error, - phase, - resetSendPhase, - sendPhase, - ]); - - // Fallback: if the "running" phase transition was missed (e.g. due to React - // batching two snapshots, a WebSocket reconnect, or a very fast turn), the - // primary auto-reset above never fires and sendPhase stays stuck. Detect - // this by checking whether the turn has settled while sendPhase is still - // non-idle — the turn completing is a definitive signal that "sending" is - // over. - // - // Guard: only reset when the latest turn differs from the one recorded at - // send time. Without this, a follow-up message on a thread whose previous - // turn already settled would be immediately reset before the server creates - // the new turn. - useEffect(() => { - if (sendPhase === "idle") return; - const turnChanged = activeLatestTurn?.turnId !== turnIdAtSendRef.current; - if (phase !== "running" && phase !== "connecting" && latestTurnSettled && turnChanged) { - resetSendPhase(); - } - }, [activeLatestTurn?.turnId, latestTurnSettled, phase, resetSendPhase, sendPhase]); + }, [optimisticPhase]); useEffect(() => { if (!activeThreadId) return; @@ -2436,7 +2386,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const api = readNativeApi(); if (!api || !activeThread || isRevertingCheckpoint) return; - if (phase === "running" || isSendBusy || isConnecting) { + if (optimisticPhase === "running" || isSendBusy) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } @@ -2469,13 +2419,13 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], + [activeThread, isRevertingCheckpoint, isSendBusy, optimisticPhase, setThreadError], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if (!api || !activeThread || isSendBusy || sendInFlightRef.current) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -2555,7 +2505,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); + sendStartedAtRef.current ??= new Date().toISOString(); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; @@ -2622,184 +2572,185 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(0); setComposerTrigger(null); - let createdServerThreadForLocalDraft = false; - let turnStartSucceeded = false; - let nextThreadBranch = activeThread.branch; - let nextThreadWorktreePath = activeThread.worktreePath; - await (async () => { - // On first message: lock in branch + create worktree if needed. - if (baseBranchForWorktree) { - beginSendPhase("preparing-worktree"); - const newBranch = buildTemporaryWorktreeBranchName(); - const result = await createWorktreeMutation.mutateAsync({ - cwd: activeProject.cwd, - branch: baseBranchForWorktree, - newBranch, - }); - nextThreadBranch = result.worktree.branch; - nextThreadWorktreePath = result.worktree.path; - if (isServerThread) { + startSendTransition(async () => { + setOptimisticPhase("running"); + let createdServerThreadForLocalDraft = false; + let turnStartSucceeded = false; + let nextThreadBranch = activeThread.branch; + let nextThreadWorktreePath = activeThread.worktreePath; + try { + // On first message: lock in branch + create worktree if needed. + if (baseBranchForWorktree) { + const newBranch = buildTemporaryWorktreeBranchName(); + const result = await createWorktreeMutation.mutateAsync({ + cwd: activeProject.cwd, + branch: baseBranchForWorktree, + newBranch, + }); + nextThreadBranch = result.worktree.branch; + nextThreadWorktreePath = result.worktree.path; + if (isServerThread) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadIdForSend, + branch: result.worktree.branch, + worktreePath: result.worktree.path, + }); + // Keep local thread state in sync immediately so terminal drawer opens + // with the worktree cwd/env instead of briefly using the project root. + setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + } + } + + let firstComposerImageName: string | null = null; + if (composerImagesSnapshot.length > 0) { + const firstComposerImage = composerImagesSnapshot[0]; + if (firstComposerImage) { + firstComposerImageName = firstComposerImage.name; + } + } + let titleSeed = trimmed; + if (!titleSeed) { + if (firstComposerImageName) { + titleSeed = `Image: ${firstComposerImageName}`; + } else if (composerTerminalContextsSnapshot.length > 0) { + titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); + } else { + titleSeed = "New thread"; + } + } + const title = truncate(titleSeed); + const threadCreateModelSelection: ModelSelection = { + provider: selectedProvider, + model: + selectedModel || + activeProject.defaultModelSelection?.model || + DEFAULT_MODEL_BY_PROVIDER.codex, + ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), + }; + + if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ - type: "thread.meta.update", + type: "thread.create", commandId: newCommandId(), threadId: threadIdForSend, - branch: result.worktree.branch, - worktreePath: result.worktree.path, + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: nextThreadBranch, + worktreePath: nextThreadWorktreePath, + createdAt: activeThread.createdAt, }); - // Keep local thread state in sync immediately so terminal drawer opens - // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + createdServerThreadForLocalDraft = true; } - } - let firstComposerImageName: string | null = null; - if (composerImagesSnapshot.length > 0) { - const firstComposerImage = composerImagesSnapshot[0]; - if (firstComposerImage) { - firstComposerImageName = firstComposerImage.name; + let setupScript: ProjectScript | null = null; + if (baseBranchForWorktree) { + setupScript = setupProjectScript(activeProject.scripts); } - } - let titleSeed = trimmed; - if (!titleSeed) { - if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; - } else if (composerTerminalContextsSnapshot.length > 0) { - titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); - } else { - titleSeed = "New thread"; + if (setupScript) { + let shouldRunSetupScript = false; + if (isServerThread) { + shouldRunSetupScript = true; + } else { + if (createdServerThreadForLocalDraft) { + shouldRunSetupScript = true; + } + } + if (shouldRunSetupScript) { + const setupScriptOptions: Parameters[1] = { + worktreePath: nextThreadWorktreePath, + rememberAsLastInvoked: false, + }; + if (nextThreadWorktreePath) { + setupScriptOptions.cwd = nextThreadWorktreePath; + } + await runProjectScript(setupScript, setupScriptOptions); + } } - } - const title = truncate(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: selectedProvider, - model: - selectedModel || - activeProject.defaultModelSelection?.model || - DEFAULT_MODEL_BY_PROVIDER.codex, - ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), - }; - if (isLocalDraftThread) { - await api.orchestration.dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: threadIdForSend, - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: nextThreadBranch, - worktreePath: nextThreadWorktreePath, - createdAt: activeThread.createdAt, - }); - createdServerThreadForLocalDraft = true; - } + // Auto-title from first message + if (isFirstMessage && isServerThread) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadIdForSend, + title, + }); + } - let setupScript: ProjectScript | null = null; - if (baseBranchForWorktree) { - setupScript = setupProjectScript(activeProject.scripts); - } - if (setupScript) { - let shouldRunSetupScript = false; if (isServerThread) { - shouldRunSetupScript = true; - } else { - if (createdServerThreadForLocalDraft) { - shouldRunSetupScript = true; - } - } - if (shouldRunSetupScript) { - const setupScriptOptions: Parameters[1] = { - worktreePath: nextThreadWorktreePath, - rememberAsLastInvoked: false, - }; - if (nextThreadWorktreePath) { - setupScriptOptions.cwd = nextThreadWorktreePath; - } - await runProjectScript(setupScript, setupScriptOptions); + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), + runtimeMode, + interactionMode, + }); } - } - // Auto-title from first message - if (isFirstMessage && isServerThread) { + const turnAttachments = await turnAttachmentsPromise; await api.orchestration.dispatchCommand({ - type: "thread.meta.update", + type: "thread.turn.start", commandId: newCommandId(), threadId: threadIdForSend, - title, - }); - } - - if (isServerThread) { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: turnAttachments, + }, + modelSelection: selectedModelSelection, + titleSeed: title, runtimeMode, interactionMode, + createdAt: messageCreatedAt, }); + turnStartSucceeded = true; + } catch (err: unknown) { + if (createdServerThreadForLocalDraft && !turnStartSucceeded) { + await api.orchestration + .dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: threadIdForSend, + }) + .catch(() => undefined); + } + if ( + !turnStartSucceeded && + promptRef.current.length === 0 && + composerImagesRef.current.length === 0 && + composerTerminalContextsRef.current.length === 0 + ) { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + const next = existing.filter((message) => message.id !== messageIdForSend); + return next.length === existing.length ? existing : next; + }); + promptRef.current = promptForSend; + setPrompt(promptForSend); + setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); + addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); + addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); + setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); + } + setThreadError( + threadIdForSend, + err instanceof Error ? err.message : "Failed to send message.", + ); } - - beginSendPhase("sending-turn"); - const turnAttachments = await turnAttachmentsPromise; - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: turnAttachments, - }, - modelSelection: selectedModelSelection, - titleSeed: title, - runtimeMode, - interactionMode, - createdAt: messageCreatedAt, - }); - turnStartSucceeded = true; - })().catch(async (err: unknown) => { - if (createdServerThreadForLocalDraft && !turnStartSucceeded) { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadIdForSend, - }) - .catch(() => undefined); - } - if ( - !turnStartSucceeded && - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 - ) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); - promptRef.current = promptForSend; - setPrompt(promptForSend); - setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); - addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); - addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); - setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); + sendInFlightRef.current = false; + if (!turnStartSucceeded) { + sendStartedAtRef.current = null; } - setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send message.", - ); }); - sendInFlightRef.current = false; - if (!turnStartSucceeded) { - resetSendPhase(); - } }; const onInterrupt = async () => { @@ -2969,14 +2920,7 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode: "default" | "plan"; }) => { const api = readNativeApi(); - if ( - !api || - !activeThread || - !isServerThread || - isSendBusy || - isConnecting || - sendInFlightRef.current - ) { + if (!api || !activeThread || !isServerThread || isSendBusy || sendInFlightRef.current) { return; } @@ -2997,7 +2941,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = true; - beginSendPhase("sending-turn"); + sendStartedAtRef.current ??= new Date().toISOString(); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, @@ -3012,73 +2956,72 @@ export default function ChatView({ threadId }: ChatViewProps) { shouldAutoScrollRef.current = true; forceStickToBottom(); - try { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - modelSelection: selectedModelSelection, - runtimeMode, - interactionMode: nextInteractionMode, - }); + startSendTransition(async () => { + setOptimisticPhase("running"); + try { + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + modelSelection: selectedModelSelection, + runtimeMode, + interactionMode: nextInteractionMode, + }); - // Keep the mode toggle and plan-follow-up banner in sync immediately - // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + // Keep the mode toggle and plan-follow-up banner in sync immediately + // while the same-thread implementation turn is starting. + setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: [], - }, - modelSelection: selectedModelSelection, - titleSeed: activeThread.title, - runtimeMode, - interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan - ? { - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - } - : {}), - createdAt: messageCreatedAt, - }); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default") { - planSidebarDismissedForTurnRef.current = null; - setPlanSidebarOpen(true); + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: [], + }, + modelSelection: selectedModelSelection, + titleSeed: activeThread.title, + runtimeMode, + interactionMode: nextInteractionMode, + ...(nextInteractionMode === "default" && activeProposedPlan + ? { + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + } + : {}), + createdAt: messageCreatedAt, + }); + // Optimistically open the plan sidebar when implementing (not refining). + // "default" mode here means the agent is executing the plan, which produces + // step-tracking activities that the sidebar will display. + if (nextInteractionMode === "default") { + planSidebarDismissedForTurnRef.current = null; + setPlanSidebarOpen(true); + } + } catch (err) { + setOptimisticUserMessages((existing) => + existing.filter((message) => message.id !== messageIdForSend), + ); + setThreadError( + threadIdForSend, + err instanceof Error ? err.message : "Failed to send plan follow-up.", + ); + sendStartedAtRef.current = null; } sendInFlightRef.current = false; - } catch (err) { - setOptimisticUserMessages((existing) => - existing.filter((message) => message.id !== messageIdForSend), - ); - setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send plan follow-up.", - ); - sendInFlightRef.current = false; - resetSendPhase(); - } + }); }, [ activeThread, activeProposedPlan, - beginSendPhase, forceStickToBottom, - isConnecting, isSendBusy, isServerThread, persistThreadSettingsForNextTurn, - resetSendPhase, runtimeMode, selectedPromptEffort, selectedModelSelection, @@ -3086,6 +3029,8 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProviderModels, setComposerDraftInteractionMode, setThreadError, + startSendTransition, + setOptimisticPhase, selectedModel, ], ); @@ -3099,7 +3044,6 @@ export default function ChatView({ threadId }: ChatViewProps) { !activeProposedPlan || !isServerThread || isSendBusy || - isConnecting || sendInFlightRef.current ) { return; @@ -3120,28 +3064,26 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; - beginSendPhase("sending-turn"); - const finish = () => { - sendInFlightRef.current = false; - resetSendPhase(); - }; + sendStartedAtRef.current ??= new Date().toISOString(); - 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({ + startSendTransition(async () => { + setOptimisticPhase("running"); + try { + 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, + }); + + await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: nextThreadId, @@ -3157,18 +3099,16 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode: "default", createdAt, }); - }) - .then(() => api.orchestration.getSnapshot()) - .then((snapshot) => { + + const snapshot = await api.orchestration.getSnapshot(); syncServerReadModel(snapshot); // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; - return navigate({ + await navigate({ to: "/$threadId", params: { threadId: nextThreadId }, }); - }) - .catch(async (err) => { + } catch (err) { await api.orchestration .dispatchCommand({ type: "thread.delete", @@ -3188,23 +3128,24 @@ export default function ChatView({ threadId }: ChatViewProps) { description: err instanceof Error ? err.message : "An error occurred while creating the new thread.", }); - }) - .then(finish, finish); + sendStartedAtRef.current = null; + } + sendInFlightRef.current = false; + }); }, [ activeProject, activeProposedPlan, activeThread, - beginSendPhase, - isConnecting, isSendBusy, isServerThread, navigate, - resetSendPhase, runtimeMode, selectedPromptEffort, selectedModelSelection, selectedProvider, selectedProviderModels, + setOptimisticPhase, + startSendTransition, syncServerReadModel, selectedModel, ]); @@ -3872,7 +3813,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, or use / to show available commands" } - disabled={isConnecting || isComposerApprovalState} + disabled={isComposerApprovalState} /> @@ -4097,9 +4038,9 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="h-9 rounded-full px-4 sm:h-8" - disabled={isSendBusy || isConnecting} + disabled={isSendBusy} > - {isConnecting || isSendBusy ? "Sending..." : "Refine"} + {isSendBusy ? "Sending..." : "Refine"} ) : (
@@ -4107,9 +4048,9 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" - disabled={isSendBusy || isConnecting} + disabled={isSendBusy} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + {isSendBusy ? "Sending..." : "Implement"} } > @@ -4127,7 +4068,7 @@ export default function ChatView({ threadId }: ChatViewProps) { void onImplementPlanInNewThread()} > Implement in a new thread @@ -4140,20 +4081,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
@@ -637,6 +638,9 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { } const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); + if (elapsedSeconds < 1) { + return null; + } if (elapsedSeconds < 60) { return `${elapsedSeconds}s`; } From 4790ea0129c3f7d5a57de74b354a646c90dfc48d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 19:25:32 -0700 Subject: [PATCH 07/11] Reset optimistic send phase per thread - Track send phase state per active thread - Clear stale optimistic sending state after failures and settles - Keep work indicators aligned with the authoritative session phase --- apps/web/src/components/ChatView.tsx | 157 +++++++++++++++++++++++---- 1 file changed, 138 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5e5b3fb708..1faa19c575 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -85,6 +85,7 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + type SessionPhase, type TurnDiffSummary, } from "../types"; import { basenameOfPath } from "../vscode-icons"; @@ -253,6 +254,104 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +interface OptimisticSendPhaseBridge { + threadId: ThreadId; + startedAt: string; + requestSettled: boolean; +} + +function hasLatestTurnCaughtUpToOptimisticSend( + latestTurnRequestedAt: string | null, + optimisticSendStartedAt: string, +): boolean { + if (latestTurnRequestedAt === null) { + return false; + } + const latestTurnRequestedAtMs = Date.parse(latestTurnRequestedAt); + const optimisticSendStartedAtMs = Date.parse(optimisticSendStartedAt); + if (Number.isNaN(latestTurnRequestedAtMs) || Number.isNaN(optimisticSendStartedAtMs)) { + return false; + } + return latestTurnRequestedAtMs >= optimisticSendStartedAtMs; +} + +function useOptimisticSendPhase(input: { + activeThreadId: ThreadId | null; + phase: SessionPhase; + latestTurnRequestedAt: string | null; +}): { + activeSendStartedAt: string | null; + clearOptimisticSendPhase: (threadId?: ThreadId | null) => void; + effectivePhase: SessionPhase; + isSendBusy: boolean; + markOptimisticSendRequestSettled: (threadId: ThreadId) => void; + setOptimisticPhase: (phase: SessionPhase) => void; + startOptimisticSendPhase: (threadId: ThreadId, startedAt: string) => void; +} { + const [optimisticPhase, setOptimisticPhase] = useOptimistic(input.phase); + const [sendPhaseBridge, setSendPhaseBridge] = useState(null); + + const isSendForActiveThread = + sendPhaseBridge !== null && sendPhaseBridge.threadId === input.activeThreadId; + + useEffect(() => { + if (!sendPhaseBridge || !isSendForActiveThread || !sendPhaseBridge.requestSettled) { + return; + } + // Keep the optimistic phase visible until the async send has settled and + // the authoritative thread state has caught up to that send. + if ( + input.phase === "connecting" || + input.phase === "running" || + hasLatestTurnCaughtUpToOptimisticSend(input.latestTurnRequestedAt, sendPhaseBridge.startedAt) + ) { + setSendPhaseBridge(null); + } + }, [input.latestTurnRequestedAt, input.phase, isSendForActiveThread, sendPhaseBridge]); + + const startOptimisticSendPhase = useCallback((threadId: ThreadId, startedAt: string) => { + setSendPhaseBridge({ + threadId, + startedAt, + requestSettled: false, + }); + }, []); + + const markOptimisticSendRequestSettled = useCallback((threadId: ThreadId) => { + setSendPhaseBridge((current) => { + if (!current || current.threadId !== threadId || current.requestSettled) { + return current; + } + return { + ...current, + requestSettled: true, + }; + }); + }, []); + + const clearOptimisticSendPhase = useCallback((threadId?: ThreadId | null) => { + setSendPhaseBridge((current) => { + if (!current) { + return current; + } + if (threadId === undefined || threadId === null || current.threadId === threadId) { + return null; + } + return current; + }); + }, []); + + return { + activeSendStartedAt: isSendForActiveThread ? sendPhaseBridge.startedAt : null, + clearOptimisticSendPhase, + effectivePhase: isSendForActiveThread ? optimisticPhase : input.phase, + isSendBusy: isSendForActiveThread, + markOptimisticSendRequestSettled, + setOptimisticPhase, + startOptimisticSendPhase, + }; +} + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); @@ -338,8 +437,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); - const [isSendPending, startSendTransition] = useTransition(); - const sendStartedAtRef = useRef(null); + const [, startSendTransition] = useTransition(); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< @@ -671,16 +769,28 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); - const [optimisticPhase, setOptimisticPhase] = useOptimistic(phase); - const isSendBusy = isSendPending; - const isPreparingWorktree = createWorktreeMutation.isPending; + const { + activeSendStartedAt, + clearOptimisticSendPhase, + effectivePhase, + isSendBusy, + markOptimisticSendRequestSettled, + setOptimisticPhase, + startOptimisticSendPhase, + } = useOptimisticSendPhase({ + activeThreadId, + phase, + latestTurnRequestedAt: activeLatestTurn?.requestedAt ?? null, + }); + const isSendForActiveThread = isSendBusy; + const isPreparingWorktree = createWorktreeMutation.isPending && isSendForActiveThread; const isWorking = - optimisticPhase === "running" || optimisticPhase === "connecting" || isRevertingCheckpoint; + effectivePhase === "running" || effectivePhase === "connecting" || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, - sendStartedAtRef.current, + activeSendStartedAt, ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -2029,14 +2139,14 @@ export default function ChatView({ threadId }: ChatViewProps) { } return []; }); - sendStartedAtRef.current = null; + clearOptimisticSendPhase(); setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [threadId]); + }, [clearOptimisticSendPhase, threadId]); useEffect(() => { let cancelled = false; @@ -2160,14 +2270,14 @@ export default function ChatView({ threadId }: ChatViewProps) { : "local"; useEffect(() => { - if (optimisticPhase !== "running" && optimisticPhase !== "connecting") return; + if (effectivePhase !== "running" && effectivePhase !== "connecting") return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; - }, [optimisticPhase]); + }, [effectivePhase]); useEffect(() => { if (!activeThreadId) return; @@ -2387,7 +2497,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const api = readNativeApi(); if (!api || !activeThread || isRevertingCheckpoint) return; - if (optimisticPhase === "running" || isSendBusy) { + if (effectivePhase === "running" || isSendBusy) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } @@ -2420,7 +2530,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, isRevertingCheckpoint, isSendBusy, optimisticPhase, setThreadError], + [activeThread, effectivePhase, isRevertingCheckpoint, isSendBusy, setThreadError], ); const onSend = async (e?: { preventDefault: () => void }) => { @@ -2506,7 +2616,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - sendStartedAtRef.current ??= new Date().toISOString(); + startOptimisticSendPhase(activeThread.id, new Date().toISOString()); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; @@ -2755,9 +2865,10 @@ export default function ChatView({ threadId }: ChatViewProps) { threadIdForSend, err instanceof Error ? err.message : "Failed to send message.", ); + clearOptimisticSendPhase(threadIdForSend); } sendInFlightRef.current = false; - sendStartedAtRef.current = null; + markOptimisticSendRequestSettled(threadIdForSend); }); }; @@ -2949,7 +3060,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = true; - sendStartedAtRef.current ??= new Date().toISOString(); + startOptimisticSendPhase(activeThread.id, new Date().toISOString()); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, @@ -3021,13 +3132,14 @@ export default function ChatView({ threadId }: ChatViewProps) { setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), ); + clearOptimisticSendPhase(threadIdForSend); setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send plan follow-up.", ); } sendInFlightRef.current = false; - sendStartedAtRef.current = null; + markOptimisticSendRequestSettled(threadIdForSend); }); }, [ @@ -3042,10 +3154,13 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, + clearOptimisticSendPhase, + markOptimisticSendRequestSettled, setComposerDraftInteractionMode, setThreadError, startSendTransition, setOptimisticPhase, + startOptimisticSendPhase, syncServerReadModel, selectedModel, ], @@ -3080,7 +3195,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; - sendStartedAtRef.current ??= new Date().toISOString(); + startOptimisticSendPhase(activeThread.id, new Date().toISOString()); startSendTransition(async () => { setOptimisticPhase("running"); @@ -3125,6 +3240,7 @@ export default function ChatView({ threadId }: ChatViewProps) { params: { threadId: nextThreadId }, }); } catch (err) { + clearOptimisticSendPhase(activeThread.id); await api.orchestration .dispatchCommand({ type: "thread.delete", @@ -3146,7 +3262,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } sendInFlightRef.current = false; - sendStartedAtRef.current = null; + markOptimisticSendRequestSettled(activeThread.id); }); }, [ activeProject, @@ -3160,7 +3276,10 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, + clearOptimisticSendPhase, + markOptimisticSendRequestSettled, setOptimisticPhase, + startOptimisticSendPhase, startSendTransition, syncServerReadModel, selectedModel, From 4c72c5f671e672cbd0aa80908c0cf994a1993fd0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 19:28:59 -0700 Subject: [PATCH 08/11] Stop resetting send phase from server snapshot - Keep the optimistic send phase stable during turn start - Remove redundant snapshot syncs that caused phase flicker --- apps/web/src/components/ChatView.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1faa19c575..0a471c8bfd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2821,15 +2821,6 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt: messageCreatedAt, }); turnStartSucceeded = true; - - // Sync snapshot before the transition ends so that `phase` reflects - // the new session state ("connecting"/"running"). Without this the - // optimistic overlay reverts to the stale `phase` (often - // "disconnected" for first-turn threads), causing a brief flicker. - await api.orchestration - .getSnapshot() - .then((snapshot) => syncServerReadModel(snapshot)) - .catch(() => undefined); } catch (err: unknown) { if (createdServerThreadForLocalDraft && !turnStartSucceeded) { await api.orchestration @@ -3121,13 +3112,6 @@ export default function ChatView({ threadId }: ChatViewProps) { planSidebarDismissedForTurnRef.current = null; setPlanSidebarOpen(true); } - - // Sync snapshot before the transition ends so that `phase` reflects - // the session state, preventing a brief flicker. - await api.orchestration - .getSnapshot() - .then((snapshot) => syncServerReadModel(snapshot)) - .catch(() => undefined); } catch (err) { setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), @@ -3161,7 +3145,6 @@ export default function ChatView({ threadId }: ChatViewProps) { startSendTransition, setOptimisticPhase, startOptimisticSendPhase, - syncServerReadModel, selectedModel, ], ); From 761e7359f93115a8b3e9178e45ebb96450562aa3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 19:32:19 -0700 Subject: [PATCH 09/11] Preserve send phase while resetting optimistic state - Track the send-phase bridge phase alongside thread state - Keep active thread phase stable during optimistic send resets --- apps/web/src/components/ChatView.tsx | 35 ++++++++++++++++++---------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a471c8bfd..ea8b23228a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -256,6 +256,7 @@ interface PendingPullRequestSetupRequest { interface OptimisticSendPhaseBridge { threadId: ThreadId; + phase: SessionPhase; startedAt: string; requestSettled: boolean; } @@ -286,13 +287,17 @@ function useOptimisticSendPhase(input: { isSendBusy: boolean; markOptimisticSendRequestSettled: (threadId: ThreadId) => void; setOptimisticPhase: (phase: SessionPhase) => void; - startOptimisticSendPhase: (threadId: ThreadId, startedAt: string) => void; + startOptimisticSendPhase: (threadId: ThreadId, startedAt: string, phase: SessionPhase) => void; } { const [optimisticPhase, setOptimisticPhase] = useOptimistic(input.phase); const [sendPhaseBridge, setSendPhaseBridge] = useState(null); const isSendForActiveThread = sendPhaseBridge !== null && sendPhaseBridge.threadId === input.activeThreadId; + const authoritativeWorkingPhase = + input.phase === "connecting" || input.phase === "running" ? input.phase : null; + const bridgedOptimisticPhase = + optimisticPhase === input.phase ? (sendPhaseBridge?.phase ?? input.phase) : optimisticPhase; useEffect(() => { if (!sendPhaseBridge || !isSendForActiveThread || !sendPhaseBridge.requestSettled) { @@ -309,13 +314,17 @@ function useOptimisticSendPhase(input: { } }, [input.latestTurnRequestedAt, input.phase, isSendForActiveThread, sendPhaseBridge]); - const startOptimisticSendPhase = useCallback((threadId: ThreadId, startedAt: string) => { - setSendPhaseBridge({ - threadId, - startedAt, - requestSettled: false, - }); - }, []); + const startOptimisticSendPhase = useCallback( + (threadId: ThreadId, startedAt: string, phase: SessionPhase) => { + setSendPhaseBridge({ + threadId, + phase, + startedAt, + requestSettled: false, + }); + }, + [], + ); const markOptimisticSendRequestSettled = useCallback((threadId: ThreadId) => { setSendPhaseBridge((current) => { @@ -344,7 +353,9 @@ function useOptimisticSendPhase(input: { return { activeSendStartedAt: isSendForActiveThread ? sendPhaseBridge.startedAt : null, clearOptimisticSendPhase, - effectivePhase: isSendForActiveThread ? optimisticPhase : input.phase, + effectivePhase: isSendForActiveThread + ? (authoritativeWorkingPhase ?? bridgedOptimisticPhase) + : input.phase, isSendBusy: isSendForActiveThread, markOptimisticSendRequestSettled, setOptimisticPhase, @@ -2616,7 +2627,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - startOptimisticSendPhase(activeThread.id, new Date().toISOString()); + startOptimisticSendPhase(activeThread.id, new Date().toISOString(), "running"); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; @@ -3051,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = true; - startOptimisticSendPhase(activeThread.id, new Date().toISOString()); + startOptimisticSendPhase(activeThread.id, new Date().toISOString(), "running"); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, @@ -3178,7 +3189,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; - startOptimisticSendPhase(activeThread.id, new Date().toISOString()); + startOptimisticSendPhase(activeThread.id, new Date().toISOString(), "running"); startSendTransition(async () => { setOptimisticPhase("running"); From a2c117255106660282ef1df1a81de4e4fbaaf77a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 19:59:47 -0700 Subject: [PATCH 10/11] Restore send phase handling in the store - Move optimistic send state into shared store - Keep connecting composer disabled without showing Stop generation - Add coverage for the restored composer state --- apps/web/src/components/ChatView.browser.tsx | 30 + apps/web/src/components/ChatView.tsx | 788 ++++++++----------- apps/web/src/store.test.ts | 91 ++- apps/web/src/store.ts | 157 +++- apps/web/src/types.ts | 6 + 5 files changed, 619 insertions(+), 453 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0d612534e5..4704cdb824 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1773,6 +1773,36 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("restores the connecting composer state without showing the stop button", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-connecting-state" as MessageId, + targetText: "connecting state target", + sessionStatus: "starting", + }), + }); + + try { + const connectingButton = await waitForElement( + () => document.querySelector('button[aria-label="Connecting"]'), + "Unable to find connecting send button.", + ); + expect(connectingButton.disabled).toBe(true); + expect( + document.querySelector('button[aria-label="Stop generation"]'), + ).toBeNull(); + + const composerEditor = await waitForElement( + () => document.querySelector('[contenteditable="false"]'), + "Unable to find disabled composer editor.", + ); + expect(composerEditor).toBeTruthy(); + } finally { + await mounted.cleanup(); + } + }); + it("hides the archive action when the pointer leaves a thread row", 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 ea8b23228a..6160c795de 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -23,16 +23,7 @@ import { } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { truncate } from "@t3tools/shared/String"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useOptimistic, - useRef, - useState, - useTransition, -} from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -85,7 +76,6 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, - type SessionPhase, type TurnDiffSummary, } from "../types"; import { basenameOfPath } from "../vscode-icons"; @@ -254,119 +244,15 @@ interface PendingPullRequestSetupRequest { scriptId: string; } -interface OptimisticSendPhaseBridge { - threadId: ThreadId; - phase: SessionPhase; - startedAt: string; - requestSettled: boolean; -} - -function hasLatestTurnCaughtUpToOptimisticSend( - latestTurnRequestedAt: string | null, - optimisticSendStartedAt: string, -): boolean { - if (latestTurnRequestedAt === null) { - return false; - } - const latestTurnRequestedAtMs = Date.parse(latestTurnRequestedAt); - const optimisticSendStartedAtMs = Date.parse(optimisticSendStartedAt); - if (Number.isNaN(latestTurnRequestedAtMs) || Number.isNaN(optimisticSendStartedAtMs)) { - return false; - } - return latestTurnRequestedAtMs >= optimisticSendStartedAtMs; -} - -function useOptimisticSendPhase(input: { - activeThreadId: ThreadId | null; - phase: SessionPhase; - latestTurnRequestedAt: string | null; -}): { - activeSendStartedAt: string | null; - clearOptimisticSendPhase: (threadId?: ThreadId | null) => void; - effectivePhase: SessionPhase; - isSendBusy: boolean; - markOptimisticSendRequestSettled: (threadId: ThreadId) => void; - setOptimisticPhase: (phase: SessionPhase) => void; - startOptimisticSendPhase: (threadId: ThreadId, startedAt: string, phase: SessionPhase) => void; -} { - const [optimisticPhase, setOptimisticPhase] = useOptimistic(input.phase); - const [sendPhaseBridge, setSendPhaseBridge] = useState(null); - - const isSendForActiveThread = - sendPhaseBridge !== null && sendPhaseBridge.threadId === input.activeThreadId; - const authoritativeWorkingPhase = - input.phase === "connecting" || input.phase === "running" ? input.phase : null; - const bridgedOptimisticPhase = - optimisticPhase === input.phase ? (sendPhaseBridge?.phase ?? input.phase) : optimisticPhase; - - useEffect(() => { - if (!sendPhaseBridge || !isSendForActiveThread || !sendPhaseBridge.requestSettled) { - return; - } - // Keep the optimistic phase visible until the async send has settled and - // the authoritative thread state has caught up to that send. - if ( - input.phase === "connecting" || - input.phase === "running" || - hasLatestTurnCaughtUpToOptimisticSend(input.latestTurnRequestedAt, sendPhaseBridge.startedAt) - ) { - setSendPhaseBridge(null); - } - }, [input.latestTurnRequestedAt, input.phase, isSendForActiveThread, sendPhaseBridge]); - - const startOptimisticSendPhase = useCallback( - (threadId: ThreadId, startedAt: string, phase: SessionPhase) => { - setSendPhaseBridge({ - threadId, - phase, - startedAt, - requestSettled: false, - }); - }, - [], - ); - - const markOptimisticSendRequestSettled = useCallback((threadId: ThreadId) => { - setSendPhaseBridge((current) => { - if (!current || current.threadId !== threadId || current.requestSettled) { - return current; - } - return { - ...current, - requestSettled: true, - }; - }); - }, []); - - const clearOptimisticSendPhase = useCallback((threadId?: ThreadId | null) => { - setSendPhaseBridge((current) => { - if (!current) { - return current; - } - if (threadId === undefined || threadId === null || current.threadId === threadId) { - return null; - } - return current; - }); - }, []); - - return { - activeSendStartedAt: isSendForActiveThread ? sendPhaseBridge.startedAt : null, - clearOptimisticSendPhase, - effectivePhase: isSendForActiveThread - ? (authoritativeWorkingPhase ?? bridgedOptimisticPhase) - : input.phase, - isSendBusy: isSendForActiveThread, - markOptimisticSendRequestSettled, - setOptimisticPhase, - startOptimisticSendPhase, - }; -} - export default function ChatView({ threadId }: ChatViewProps) { + const beginThreadSend = useStore((store) => store.beginThreadSend); + const clearThreadSend = useStore((store) => store.clearThreadSend); + const moveThreadSend = useStore((store) => store.moveThreadSend); const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const markThreadVisited = useStore((store) => store.markThreadVisited); + const pendingThreadSend = useStore((store) => store.pendingThreadSendById[threadId] ?? null); + const setThreadSendPhase = useStore((store) => store.setThreadSendPhase); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); @@ -443,12 +329,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); - optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef(composerTerminalContexts); const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); - const [, startSendTransition] = useTransition(); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< @@ -780,23 +664,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); - const { - activeSendStartedAt, - clearOptimisticSendPhase, - effectivePhase, - isSendBusy, - markOptimisticSendRequestSettled, - setOptimisticPhase, - startOptimisticSendPhase, - } = useOptimisticSendPhase({ - activeThreadId, - phase, - latestTurnRequestedAt: activeLatestTurn?.requestedAt ?? null, - }); - const isSendForActiveThread = isSendBusy; - const isPreparingWorktree = createWorktreeMutation.isPending && isSendForActiveThread; - const isWorking = - effectivePhase === "running" || effectivePhase === "connecting" || isRevertingCheckpoint; + const activeSendStartedAt = pendingThreadSend?.startedAt ?? null; + const isSendBusy = pendingThreadSend !== null; + const isPreparingWorktree = + pendingThreadSend?.phase === "preparing-worktree" && createWorktreeMutation.isPending; + const isConnecting = phase === "connecting"; + const showConnectingState = isConnecting && !isSendBusy; + const isWorking = phase === "running" || isSendBusy || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -1268,9 +1142,6 @@ export default function ChatView({ threadId }: ChatViewProps) { null, [composerHighlightedItemId, composerMenuItems], ); - composerMenuOpenRef.current = composerMenuOpen; - composerMenuItemsRef.current = composerMenuItems; - activeComposerMenuItemRef.current = activeComposerMenuItem; const nonPersistedComposerImageIdSet = useMemo( () => new Set(nonPersistedComposerImageIds), [nonPersistedComposerImageIds], @@ -2110,6 +1981,16 @@ export default function ChatView({ threadId }: ChatViewProps) { composerTerminalContextsRef.current = composerTerminalContexts; }, [composerTerminalContexts]); + useEffect(() => { + optimisticUserMessagesRef.current = optimisticUserMessages; + }, [optimisticUserMessages]); + + useEffect(() => { + composerMenuOpenRef.current = composerMenuOpen; + composerMenuItemsRef.current = composerMenuItems; + activeComposerMenuItemRef.current = activeComposerMenuItem; + }, [activeComposerMenuItem, composerMenuItems, composerMenuOpen]); + useEffect(() => { if (!activeThread?.id) return; if (activeThread.messages.length === 0) { @@ -2150,14 +2031,14 @@ export default function ChatView({ threadId }: ChatViewProps) { } return []; }); - clearOptimisticSendPhase(); + sendInFlightRef.current = false; setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [clearOptimisticSendPhase, threadId]); + }, [threadId]); useEffect(() => { let cancelled = false; @@ -2281,14 +2162,14 @@ export default function ChatView({ threadId }: ChatViewProps) { : "local"; useEffect(() => { - if (effectivePhase !== "running" && effectivePhase !== "connecting") return; + if (phase !== "running" && !isSendBusy) return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; - }, [effectivePhase]); + }, [isSendBusy, phase]); useEffect(() => { if (!activeThreadId) return; @@ -2508,7 +2389,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const api = readNativeApi(); if (!api || !activeThread || isRevertingCheckpoint) return; - if (effectivePhase === "running" || isSendBusy) { + if (phase === "running" || isSendBusy) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } @@ -2541,7 +2422,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, effectivePhase, isRevertingCheckpoint, isSendBusy, setThreadError], + [activeThread, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); const onSend = async (e?: { preventDefault: () => void }) => { @@ -2626,8 +2507,13 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + const sendStartedAt = new Date().toISOString(); sendInFlightRef.current = true; - startOptimisticSendPhase(activeThread.id, new Date().toISOString(), "running"); + beginThreadSend( + threadIdForSend, + baseBranchForWorktree ? "preparing-worktree" : "sending-turn", + sendStartedAt, + ); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; @@ -2694,184 +2580,181 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(0); setComposerTrigger(null); - startSendTransition(async () => { - setOptimisticPhase("running"); - let createdServerThreadForLocalDraft = false; - let turnStartSucceeded = false; - let nextThreadBranch = activeThread.branch; - let nextThreadWorktreePath = activeThread.worktreePath; - try { - // On first message: lock in branch + create worktree if needed. - if (baseBranchForWorktree) { - const newBranch = buildTemporaryWorktreeBranchName(); - const result = await createWorktreeMutation.mutateAsync({ - cwd: activeProject.cwd, - branch: baseBranchForWorktree, - newBranch, - }); - nextThreadBranch = result.worktree.branch; - nextThreadWorktreePath = result.worktree.path; - if (isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - branch: result.worktree.branch, - worktreePath: result.worktree.path, - }); - // Keep local thread state in sync immediately so terminal drawer opens - // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); - } - } - - let firstComposerImageName: string | null = null; - if (composerImagesSnapshot.length > 0) { - const firstComposerImage = composerImagesSnapshot[0]; - if (firstComposerImage) { - firstComposerImageName = firstComposerImage.name; - } - } - let titleSeed = trimmed; - if (!titleSeed) { - if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; - } else if (composerTerminalContextsSnapshot.length > 0) { - titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); - } else { - titleSeed = "New thread"; - } - } - const title = truncate(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: selectedProvider, - model: - selectedModel || - activeProject.defaultModelSelection?.model || - DEFAULT_MODEL_BY_PROVIDER.codex, - ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), - }; - - if (isLocalDraftThread) { + let createdServerThreadForLocalDraft = false; + let turnStartSucceeded = false; + let nextThreadBranch = activeThread.branch; + let nextThreadWorktreePath = activeThread.worktreePath; + try { + // On first message: lock in branch + create worktree if needed. + if (baseBranchForWorktree) { + const newBranch = buildTemporaryWorktreeBranchName(); + const result = await createWorktreeMutation.mutateAsync({ + cwd: activeProject.cwd, + branch: baseBranchForWorktree, + newBranch, + }); + nextThreadBranch = result.worktree.branch; + nextThreadWorktreePath = result.worktree.path; + if (isServerThread) { await api.orchestration.dispatchCommand({ - type: "thread.create", + type: "thread.meta.update", commandId: newCommandId(), threadId: threadIdForSend, - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: nextThreadBranch, - worktreePath: nextThreadWorktreePath, - createdAt: activeThread.createdAt, + branch: result.worktree.branch, + worktreePath: result.worktree.path, }); - createdServerThreadForLocalDraft = true; + // Keep local thread state in sync immediately so terminal drawer opens + // with the worktree cwd/env instead of briefly using the project root. + setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); } + } - let setupScript: ProjectScript | null = null; - if (baseBranchForWorktree) { - setupScript = setupProjectScript(activeProject.scripts); + let firstComposerImageName: string | null = null; + if (composerImagesSnapshot.length > 0) { + const firstComposerImage = composerImagesSnapshot[0]; + if (firstComposerImage) { + firstComposerImageName = firstComposerImage.name; } - if (setupScript) { - let shouldRunSetupScript = false; - if (isServerThread) { - shouldRunSetupScript = true; - } else { - if (createdServerThreadForLocalDraft) { - shouldRunSetupScript = true; - } - } - if (shouldRunSetupScript) { - const setupScriptOptions: Parameters[1] = { - worktreePath: nextThreadWorktreePath, - rememberAsLastInvoked: false, - }; - if (nextThreadWorktreePath) { - setupScriptOptions.cwd = nextThreadWorktreePath; - } - await runProjectScript(setupScript, setupScriptOptions); - } + } + let titleSeed = trimmed; + if (!titleSeed) { + if (firstComposerImageName) { + titleSeed = `Image: ${firstComposerImageName}`; + } else if (composerTerminalContextsSnapshot.length > 0) { + titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); + } else { + titleSeed = "New thread"; } + } + const title = truncate(titleSeed); + const threadCreateModelSelection: ModelSelection = { + provider: selectedProvider, + model: + selectedModel || + activeProject.defaultModelSelection?.model || + DEFAULT_MODEL_BY_PROVIDER.codex, + ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), + }; - // Auto-title from first message - if (isFirstMessage && isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - title, - }); - } + if (isLocalDraftThread) { + await api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: threadIdForSend, + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: nextThreadBranch, + worktreePath: nextThreadWorktreePath, + createdAt: activeThread.createdAt, + }); + createdServerThreadForLocalDraft = true; + } + let setupScript: ProjectScript | null = null; + if (baseBranchForWorktree) { + setupScript = setupProjectScript(activeProject.scripts); + } + if (setupScript) { + let shouldRunSetupScript = false; if (isServerThread) { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), - runtimeMode, - interactionMode, - }); + shouldRunSetupScript = true; + } else if (createdServerThreadForLocalDraft) { + shouldRunSetupScript = true; + } + if (shouldRunSetupScript) { + const setupScriptOptions: Parameters[1] = { + worktreePath: nextThreadWorktreePath, + rememberAsLastInvoked: false, + }; + if (nextThreadWorktreePath) { + setupScriptOptions.cwd = nextThreadWorktreePath; + } + await runProjectScript(setupScript, setupScriptOptions); } + } - const turnAttachments = await turnAttachmentsPromise; + // Auto-title from first message + if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ - type: "thread.turn.start", + type: "thread.meta.update", commandId: newCommandId(), threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: turnAttachments, - }, - modelSelection: selectedModelSelection, - titleSeed: title, + title, + }); + } + + if (isServerThread) { + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), runtimeMode, interactionMode, - createdAt: messageCreatedAt, }); - turnStartSucceeded = true; - } catch (err: unknown) { - if (createdServerThreadForLocalDraft && !turnStartSucceeded) { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadIdForSend, - }) - .catch(() => undefined); - } - if ( - !turnStartSucceeded && - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 - ) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); - promptRef.current = promptForSend; - setPrompt(promptForSend); - setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); - addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); - addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); - setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); - } - setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send message.", - ); - clearOptimisticSendPhase(threadIdForSend); } - sendInFlightRef.current = false; - markOptimisticSendRequestSettled(threadIdForSend); - }); + + if (baseBranchForWorktree) { + setThreadSendPhase(threadIdForSend, "sending-turn"); + } + const turnAttachments = await turnAttachmentsPromise; + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: turnAttachments, + }, + modelSelection: selectedModelSelection, + titleSeed: title, + runtimeMode, + interactionMode, + createdAt: messageCreatedAt, + }); + turnStartSucceeded = true; + } catch (err: unknown) { + if (createdServerThreadForLocalDraft && !turnStartSucceeded) { + await api.orchestration + .dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: threadIdForSend, + }) + .catch(() => undefined); + } + if ( + !turnStartSucceeded && + promptRef.current.length === 0 && + composerImagesRef.current.length === 0 && + composerTerminalContextsRef.current.length === 0 + ) { + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + const next = existing.filter((message) => message.id !== messageIdForSend); + return next.length === existing.length ? existing : next; + }); + promptRef.current = promptForSend; + setPrompt(promptForSend); + setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); + addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); + addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); + setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); + } + setThreadError( + threadIdForSend, + err instanceof Error ? err.message : "Failed to send message.", + ); + clearThreadSend(threadIdForSend); + } + sendInFlightRef.current = false; }; const onInterrupt = async () => { @@ -3062,7 +2945,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); sendInFlightRef.current = true; - startOptimisticSendPhase(activeThread.id, new Date().toISOString(), "running"); + beginThreadSend(threadIdForSend, "sending-turn", new Date().toISOString()); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, @@ -3077,65 +2960,61 @@ export default function ChatView({ threadId }: ChatViewProps) { shouldAutoScrollRef.current = true; forceStickToBottom(); - startSendTransition(async () => { - setOptimisticPhase("running"); - try { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - modelSelection: selectedModelSelection, - runtimeMode, - interactionMode: nextInteractionMode, - }); + try { + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + modelSelection: selectedModelSelection, + runtimeMode, + interactionMode: nextInteractionMode, + }); - // Keep the mode toggle and plan-follow-up banner in sync immediately - // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + // Keep the mode toggle and plan-follow-up banner in sync immediately + // while the same-thread implementation turn is starting. + setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: [], - }, - modelSelection: selectedModelSelection, - titleSeed: activeThread.title, - runtimeMode, - interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan - ? { - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - } - : {}), - createdAt: messageCreatedAt, - }); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default") { - planSidebarDismissedForTurnRef.current = null; - setPlanSidebarOpen(true); - } - } catch (err) { - setOptimisticUserMessages((existing) => - existing.filter((message) => message.id !== messageIdForSend), - ); - clearOptimisticSendPhase(threadIdForSend); - setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send plan follow-up.", - ); + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: [], + }, + modelSelection: selectedModelSelection, + titleSeed: activeThread.title, + runtimeMode, + interactionMode: nextInteractionMode, + ...(nextInteractionMode === "default" && activeProposedPlan + ? { + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + } + : {}), + createdAt: messageCreatedAt, + }); + // Optimistically open the plan sidebar when implementing (not refining). + // "default" mode here means the agent is executing the plan, which produces + // step-tracking activities that the sidebar will display. + if (nextInteractionMode === "default") { + planSidebarDismissedForTurnRef.current = null; + setPlanSidebarOpen(true); } - sendInFlightRef.current = false; - markOptimisticSendRequestSettled(threadIdForSend); - }); + } catch (err) { + setOptimisticUserMessages((existing) => + existing.filter((message) => message.id !== messageIdForSend), + ); + clearThreadSend(threadIdForSend); + setThreadError( + threadIdForSend, + err instanceof Error ? err.message : "Failed to send plan follow-up.", + ); + } + sendInFlightRef.current = false; }, [ activeThread, @@ -3149,13 +3028,10 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, - clearOptimisticSendPhase, - markOptimisticSendRequestSettled, + beginThreadSend, + clearThreadSend, setComposerDraftInteractionMode, setThreadError, - startSendTransition, - setOptimisticPhase, - startOptimisticSendPhase, selectedModel, ], ); @@ -3189,75 +3065,73 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; - startOptimisticSendPhase(activeThread.id, new Date().toISOString(), "running"); + beginThreadSend(activeThread.id, "sending-turn", createdAt); - startSendTransition(async () => { - setOptimisticPhase("running"); - try { - 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, - }); + try { + 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, + }); - await api.orchestration.dispatchCommand({ - type: "thread.turn.start", + await 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, + }); + + moveThreadSend(activeThread.id, nextThreadId); + const snapshot = await api.orchestration.getSnapshot(); + syncServerReadModel(snapshot); + // Signal that the plan sidebar should open on the new thread. + planSidebarOpenOnNextThreadRef.current = true; + await navigate({ + to: "/$threadId", + params: { threadId: nextThreadId }, + }); + } catch (err) { + clearThreadSend(activeThread.id); + clearThreadSend(nextThreadId); + await api.orchestration + .dispatchCommand({ + type: "thread.delete", commandId: newCommandId(), threadId: nextThreadId, - message: { - messageId: newMessageId(), - role: "user", - text: outgoingImplementationPrompt, - attachments: [], - }, - modelSelection: selectedModelSelection, - titleSeed: nextThreadTitle, - runtimeMode, - interactionMode: "default", - createdAt, - }); - - const snapshot = await api.orchestration.getSnapshot(); - syncServerReadModel(snapshot); - // Signal that the plan sidebar should open on the new thread. - planSidebarOpenOnNextThreadRef.current = true; - await navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, - }); - } catch (err) { - clearOptimisticSendPhase(activeThread.id); - 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.", - }); - } - sendInFlightRef.current = false; - markOptimisticSendRequestSettled(activeThread.id); - }); + }) + .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.", + }); + } + sendInFlightRef.current = false; }, [ activeProject, activeProposedPlan, @@ -3270,11 +3144,9 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, - clearOptimisticSendPhase, - markOptimisticSendRequestSettled, - setOptimisticPhase, - startOptimisticSendPhase, - startSendTransition, + beginThreadSend, + clearThreadSend, + moveThreadSend, syncServerReadModel, selectedModel, ]); @@ -3942,7 +3814,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, or use / to show available commands" } - disabled={isComposerApprovalState} + disabled={showConnectingState || isComposerApprovalState} /> @@ -4167,9 +4039,13 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="h-9 rounded-full px-4 sm:h-8" - disabled={isSendBusy} + disabled={isSendBusy || showConnectingState} > - {isSendBusy ? "Sending..." : "Refine"} + {showConnectingState + ? "Connecting..." + : isSendBusy + ? "Sending..." + : "Refine"} ) : (
@@ -4177,9 +4053,13 @@ export default function ChatView({ threadId }: ChatViewProps) { type="submit" size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" - disabled={isSendBusy} + disabled={isSendBusy || showConnectingState} > - {isSendBusy ? "Sending..." : "Implement"} + {showConnectingState + ? "Connecting..." + : isSendBusy + ? "Sending..." + : "Implement"} } > @@ -4197,7 +4077,7 @@ export default function ChatView({ threadId }: ChatViewProps) { void onImplementPlanInNewThread()} > Implement in a new thread @@ -4210,16 +4090,22 @@ export default function ChatView({ threadId }: ChatViewProps) {