diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0f48fa1b9f06..9e6b96b21018 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -628,6 +628,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("holds a first send while the thread shell still reports a preparing run", () => { + // The draft had no run. The server thread's shell shows the new run before + // the detail projection behind `phase` loads. + const localDispatch = createLocalDispatchSnapshot(makeThread()); + const preparingRun = { + ...completedTurn, + status: "preparing" as const, + startedAt: null, + completedAt: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "disconnected", + latestRun: preparingRun, + runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + it("waits for the matching running turn before acknowledging", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 255a4f7e8cd3..3323a8f3e0b9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -57,7 +57,7 @@ import { stripInlineContextReferences } from "~/lib/composerContextReferences"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; -import type { TimelineEntry } from "../session-logic"; +import { derivePhase, type TimelineEntry } from "../session-logic"; import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; import type { DesktopPreviewOverlay } from "../previewStateStore"; import type { RightPanelSurface } from "../rightPanelStore"; @@ -1248,7 +1248,10 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } - if (input.phase === "connecting") { + // The thread shell can report a preparing or starting run before the detail + // projection behind `phase` loads, so either source still connecting holds + // the send. + if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b5a96518892c..e2037c2a0645 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6396,6 +6396,9 @@ export default function ChatView(props: ChatViewProps) { optimisticUserMessages, ]); + // Keyed on the thread, not the draft: a draft's promotion to its server + // route keeps this instance and drops `draftId`, and the send it is still + // dispatching must survive that swap. useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -6405,7 +6408,7 @@ export default function ChatView(props: ChatViewProps) { }); resetLocalDispatch(); setExpandedImage(null); - }, [draftId, resetLocalDispatch, threadId]); + }, [resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e7c0afa479d6..7c2fb5bd8893 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4625,6 +4625,22 @@ it("keeps the working header in place across worktree setup handoff", () => { expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false }); + // A clean finish before the turn is live keeps that same layout, so the card + // does not jump above the header in the gap before the run starts. + const settledRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { + ...snapshot, + phase: "done", + stages: [stage("setup-script", "done"), stage("agent", "done")], + }, + }); + expect(settledRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); + // A script that already finished has nothing left to show once the turn is live. const finishedRows = deriveMessagesTimelineRows({ timelineEntries: [userEntry], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b035839c4ae4..88da32d28fc3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1673,7 +1673,11 @@ export function deriveMessagesTimelineRows(input: { input.worktreeSetup !== undefined && worktreeSetupAgentStarted(input.worktreeSetup) && input.latestRun?.startedAt != null; - const setupRunning = !setupHandedOff && input.worktreeSetup?.phase === "running"; + // A finished setup keeps the slot until the turn is live, so the card does + // not jump above the working header in the gap before the run starts. + const setupOwnsWorkingSlot = + !setupHandedOff && + (input.worktreeSetup?.phase === "running" || input.worktreeSetup?.phase === "done"); if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) { const setupRow = { kind: "worktree-setup", @@ -1688,7 +1692,9 @@ export function deriveMessagesTimelineRows(input: { // While the setup runs, the working header leads the card in the same // slot it keeps once the agent's own turn takes over. The main pass may // already have placed that header (a bootstrap counts as working). - const workingRowIndex = setupRunning ? nextRows.findIndex((row) => row.kind === "working") : -1; + const workingRowIndex = setupOwnsWorkingSlot + ? nextRows.findIndex((row) => row.kind === "working") + : -1; if (workingRowIndex >= 0) { nextRows.splice(workingRowIndex + 1, 0, setupRow); } else { @@ -1696,7 +1702,7 @@ export function deriveMessagesTimelineRows(input: { nextRows.splice( insertAt, 0, - ...(setupRunning + ...(setupOwnsWorkingSlot ? [ { kind: "working", @@ -1710,15 +1716,15 @@ export function deriveMessagesTimelineRows(input: { } } - // A running setup owns the working slot above its card and shows no - // activity row of its own; every other state gets the usual tail. + // A setup that owns the working slot sits under it and shows no activity + // row of its own; every other state gets the usual tail. const hasWorkingRow = nextRows.some((row) => row.kind === "working"); if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) { appendWorkingRow(); } if ( input.isWorking && - !setupRunning && + !setupOwnsWorkingSlot && !hasActiveCompaction && (!hasActivityRow || latestToolFailed) ) { diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index ae625c6a0d6f..0d0a0bc1b415 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -356,17 +356,20 @@ export function WorktreeSetupCard({ const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending"; const collapsed = embedded && !running; - // While running, the timeline's working row above the card carries the - // "Setting up worktree…" label (and keeps that slot when the agent takes - // over). The card only brings its own header for a settled outcome that - // has no working row to sit under. - const showHeader = !embedded && !running; + // While running, and after a clean finish until the agent's turn is live, + // the timeline's working row above the card carries the label (and keeps + // that slot when the agent takes over). The card only brings its own + // header for a failed or cancelled setup that has no working row above it. + const showHeader = !embedded && !running && snapshot.phase !== "done"; // The tail box is part of the script row's footprint while the script runs // (and after it failed, so the last lines explain the failure). It mounts - // as soon as the script is running, empty lines and all, so the card takes - // its final height once instead of growing with each output line. + // with the first output line at its full fixed height, so the card grows + // once instead of with each line. A script that ends before printing + // anything never flashes an empty box. const showTail = - setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed"); + setupStage !== undefined && + (setupStage.status === "failed" || + (setupStage.status === "running" && setupStage.tail.length > 0)); return (