diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f9f1362d0e..da1b3d3689 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { + CheckpointRef, EventId, ORCHESTRATION_WS_METHODS, type MessageId, @@ -297,6 +298,70 @@ function createSnapshotForTargetUser(options: { }; } +function withLatestTurnState( + snapshot: OrchestrationReadModel, + options?: { + turnId?: TurnId; + completedAt?: string; + includeCheckpoint?: boolean; + sessionStatus?: OrchestrationSessionStatus; + }, +): OrchestrationReadModel { + const turnId = options?.turnId ?? ("turn-browser-running" as TurnId); + const completedAt = options?.completedAt ?? isoAt(180); + const sessionStatus = options?.sessionStatus ?? "running"; + const threads = [...snapshot.threads]; + const threadIndex = threads.findIndex((thread) => thread.id === THREAD_ID); + if (threadIndex < 0) { + return snapshot; + } + + const thread = threads[threadIndex]; + if (!thread) { + return snapshot; + } + + threads[threadIndex] = { + ...thread, + latestTurn: { + turnId, + state: "completed", + requestedAt: isoAt(120), + startedAt: isoAt(121), + completedAt, + assistantMessageId: null, + }, + session: thread.session + ? { + ...thread.session, + status: sessionStatus, + activeTurnId: sessionStatus === "running" ? turnId : null, + updatedAt: options?.includeCheckpoint ? isoAt(181) : isoAt(121), + } + : null, + checkpoints: options?.includeCheckpoint + ? [ + { + turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe( + "refs/t3/checkpoints/thread-browser-test/turn/1", + ), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: isoAt(181), + }, + ] + : [], + }; + + return { + ...snapshot, + threads, + }; +} + function buildFixture(snapshot: OrchestrationReadModel): TestFixture { return { snapshot, @@ -2104,6 +2169,97 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("restores the composer send button when a running session already has a finalized checkpoint for its latest turn", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withLatestTurnState( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-stale-running-target" as MessageId, + targetText: "stale running target", + }), + { includeCheckpoint: true }, + ), + }); + + try { + const sendButton = await waitForSendButton(); + expect(sendButton).toBeTruthy(); + expect( + document.querySelector('button[aria-label="Stop generation"]'), + ).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps the composer stop button while a running session lacks a finalized checkpoint for its latest turn", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withLatestTurnState( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-active-running-target" as MessageId, + targetText: "active running target", + }), + ), + }); + + try { + const stopButton = await waitForElement( + () => document.querySelector('button[aria-label="Stop generation"]'), + "Unable to find stop generation button.", + ); + expect(stopButton).toBeTruthy(); + expect( + document.querySelector('button[aria-label="Send message"]'), + ).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps the composer in sending state during a delayed turn start", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withLatestTurnState( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-delayed-turn-start" as MessageId, + targetText: "delayed turn start target", + }), + { includeCheckpoint: true, sessionStatus: "ready" }, + ), + resolveRpc: (body) => { + if ( + body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + body.type === "thread.turn.start" + ) { + return new Promise(() => undefined); + } + return undefined; + }, + }); + + try { + await page.getByTestId("composer-editor").fill("follow up while waiting"); + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + expect( + document.querySelector('button[aria-label="Sending"]'), + ).toBeTruthy(); + expect( + document.querySelector('button[aria-label="Stop generation"]'), + ).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + } 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.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8d49bc07f2..7a132a98b4 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -9,6 +9,7 @@ import { deriveComposerSendState, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + shouldShowComposerRunningState, waitForStartedServerThread, } from "./ChatView.logic"; @@ -455,3 +456,74 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); }); + +describe("shouldShowComposerRunningState", () => { + const latestTurn = { + turnId: TurnId.makeUnsafe("turn-running"), + state: "completed" as const, + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: "2026-03-29T00:01:30.000Z", + assistantMessageId: null, + }; + + it("hides the composer stop state once the running turn has a finalized checkpoint", () => { + expect( + shouldShowComposerRunningState({ + phase: "running", + latestTurn, + turnDiffSummaries: [ + { + turnId: latestTurn.turnId, + completedAt: "2026-03-29T00:01:31.000Z", + status: "ready", + files: [], + checkpointTurnCount: 1, + }, + ], + }), + ).toBe(false); + }); + + it("keeps the composer in stop state while the turn is still running without a finalized checkpoint", () => { + expect( + shouldShowComposerRunningState({ + phase: "running", + latestTurn, + turnDiffSummaries: [], + }), + ).toBe(true); + }); + + it("keeps the composer in stop state while the latest turn has not completed yet", () => { + expect( + shouldShowComposerRunningState({ + phase: "running", + latestTurn: { + ...latestTurn, + state: "running", + completedAt: null, + }, + turnDiffSummaries: [ + { + turnId: latestTurn.turnId, + completedAt: "2026-03-29T00:01:31.000Z", + status: "ready", + files: [], + checkpointTurnCount: 1, + }, + ], + }), + ).toBe(true); + }); + + it("returns false when the session itself is not running", () => { + expect( + shouldShowComposerRunningState({ + phase: "ready", + latestTurn, + turnDiffSummaries: [], + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ca2a671c11..06e1d81ab8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -304,3 +304,20 @@ export function hasServerAcknowledgedLocalDispatch(input: { input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); } + +export function shouldShowComposerRunningState(input: { + phase: SessionPhase; + latestTurn: Thread["latestTurn"] | null; + turnDiffSummaries: ReadonlyArray; +}): boolean { + if (input.phase !== "running") { + return false; + } + + const latestTurn = input.latestTurn; + if (!latestTurn?.completedAt) { + return true; + } + + return !input.turnDiffSummaries.some((summary) => summary.turnId === latestTurn.turnId); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1580c8f605..e8b574c6d5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -190,6 +190,7 @@ import { reconcileMountedTerminalThreadIds, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + shouldShowComposerRunningState, threadHasStarted, waitForStartedServerThread, } from "./ChatView.logic"; @@ -869,6 +870,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = useProjectById(activeThread?.projectId); + const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = + useTurnDiffSummaries(activeThread); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1121,6 +1124,11 @@ export default function ChatView({ threadId }: ChatViewProps) { threadError: activeThread?.error, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const composerShowsRunningState = shouldShowComposerRunningState({ + phase, + latestTurn: activeLatestTurn, + turnDiffSummaries, + }); const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -1137,7 +1145,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; } - if (phase === "running") { + if (composerShowsRunningState) { return "running"; } if (showPlanFollowUpPrompt) { @@ -1148,10 +1156,10 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingIsResponding, activePendingProgress, composerSendState.hasSendableContent, + composerShowsRunningState, isConnecting, isPreparingWorktree, isSendBusy, - phase, prompt, showPlanFollowUpPrompt, ]); @@ -1318,8 +1326,6 @@ export default function ChatView({ threadId }: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); - const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = - useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); for (const summary of turnDiffSummaries) { @@ -4382,7 +4388,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } : null } - isRunning={phase === "running"} + isRunning={composerShowsRunningState} showPlanFollowUpPrompt={ pendingUserInputs.length === 0 && showPlanFollowUpPrompt }