From c66dfd099f918a0e0a451a7561282202ceea2a58 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:02:58 -0700 Subject: [PATCH 1/4] fix(web): stop the worktree setup card from flashing and shifting Promoting a draft to its server thread swapped route components, which unmounted ChatView and painted an empty timeline for a frame. The setup card also changed shape at every phase: its own header gave way to the working header, the stage list was replaced by a lone script row once the agent started, the output tail grew and wrapped with each line, and a script that outlived the reply trailed the assistant's message. Both thread routes now render one ThreadRouteView from the _chat layout, keyed by the thread id the draft already reserved, so promotion is a prop change on a mounted element. The working header is the only header for the setup's whole life and swaps its text in place. The stage list keeps its footprint until the agent takes over, then leaves; a still-running script is surfaced as a chip in the working header that opens the stages and live tail in a popover. The tail is a fixed four-line box that never wraps, and the server splits script output on bare carriage returns so progress redraws stay short lines. A clean finish leaves no trace; a failed script keeps a collapsed row under the send while the turn runs. Co-Authored-By: Claude Fable 5 --- .../project/ProjectSetupScriptRunner.test.ts | 7 +- .../src/project/ProjectSetupScriptRunner.ts | 5 +- .../web/src/components/ChatView.logic.test.ts | 93 +++------ apps/web/src/components/ChatView.logic.ts | 9 +- apps/web/src/components/ThreadRouteView.tsx | 195 ++++++++++++++++++ .../chat/MessagesTimeline.logic.test.ts | 66 ++++-- .../components/chat/MessagesTimeline.logic.ts | 77 +++---- .../src/components/chat/MessagesTimeline.tsx | 115 ++++++++--- .../src/components/chat/WorktreeSetupCard.tsx | 112 ++++++++-- .../routes/_chat.$environmentId.$threadId.tsx | 106 +--------- apps/web/src/routes/_chat.draft.$draftId.tsx | 95 +-------- apps/web/src/routes/_chat.tsx | 12 +- 12 files changed, 528 insertions(+), 364 deletions(-) create mode 100644 apps/web/src/components/ThreadRouteView.tsx diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 8bfd86bf6ac5..31fd6a195827 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -307,7 +307,9 @@ describe("ProjectSetupScriptRunner", () => { // control sequences are stripped, and the echoed wrapper is hidden. yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`); yield* emit("\u001b[32mResolving"); - yield* emit(" deps\u001b[0m\r\nDone in 2s\r\n"); + yield* emit(" deps\u001b[0m\r\n"); + // Progress redraws separated by bare carriage returns are their own lines. + yield* emit("Progress: 1/3\rProgress: 2/3\rProgress: 3/3\r\nDone in 2s\r\n"); // A spoofed sentinel from the script itself must not settle completion. yield* emit("__T3_SETUP_DONE__:0\r\n"); yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`); @@ -317,6 +319,9 @@ describe("ProjectSetupScriptRunner", () => { expect(completion.exitCode).toBe(3); expect(seen).toEqual([ "Resolving deps", + "Progress: 1/3", + "Progress: 2/3", + "Progress: 3/3", "Done in 2s", "__T3_SETUP_DONE__:0", `__T3_SETUP_DONE___${"0".repeat(32)}:0`, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 2835750f8c71..359466486f5a 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -258,7 +258,10 @@ export const make = Effect.gen(function* () { } if (event.type === "output") { lineBuffer += event.data; - const lines = lineBuffer.split(/\r?\n/); + // A bare carriage return is how installers redraw a progress line in + // place. Treating it as a line break keeps each redraw a short line + // of its own instead of gluing every update into one long one. + const lines = lineBuffer.split(/\r\n|\r|\n/); lineBuffer = lines.pop() ?? ""; // A script that never prints a newline must not grow this forever. // The sentinel is always on its own line, so keeping the tail is safe. diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c226a3a65c78..e2efba8f2f84 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -10,6 +10,7 @@ import { type ServerProvider, ThreadId, TurnId, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { Atom, AsyncResult } from "effect/unstable/reactivity"; @@ -2533,7 +2534,12 @@ describe("worktree setup visibility", () => { expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull(); }); - it("shows a running setup and hides a clean one once the turn started", () => { + it("shows a running setup and drops a clean one once the turn started", () => { + const visible = ( + snapshot: WorktreeSetupSnapshot | null, + turnStarted: boolean, + isWorking = true, + ) => resolveVisibleWorktreeSetup({ live: null, recorded: snapshot, turnStarted, isWorking }); expect( resolveVisibleWorktreeSetup({ live: base, @@ -2542,22 +2548,10 @@ describe("worktree setup visibility", () => { isWorking: true, }), ).toEqual(base); - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: settledDone, - turnStarted: false, - isWorking: true, - }), - ).toEqual(settledDone); - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: settledDone, - turnStarted: true, - isWorking: true, - }), - ).toBeNull(); + expect(visible(settledDone, false)).toEqual(settledDone); + expect(visible(settledDone, true)).toBeNull(); + expect(visible(settledDone, true, false)).toBeNull(); + expect(visible(null, true)).toBeNull(); }); it("keeps a failed script visible for the running turn and a failed setup always", () => { @@ -2565,57 +2559,26 @@ describe("worktree setup visibility", () => { ...settledDone, stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], }; - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: scriptFailed, - turnStarted: true, - isWorking: true, - }), - ).toEqual(scriptFailed); - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: scriptFailed, - turnStarted: true, - isWorking: false, - }), - ).toBeNull(); + const visible = (snapshot: WorktreeSetupSnapshot, isWorking: boolean) => + resolveVisibleWorktreeSetup({ live: null, recorded: snapshot, turnStarted: true, isWorking }); + expect(visible(scriptFailed, true)).toEqual(scriptFailed); + expect(visible(scriptFailed, false)).toBeNull(); const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" }; - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: failed, - turnStarted: true, - isWorking: false, - }), - ).toEqual(failed); + expect(visible(failed, false)).toEqual(failed); + const cancelled = { ...settledDone, phase: "cancelled" as const }; + expect(visible(cancelled, false)).toEqual(cancelled); }); it("prefers whichever snapshot is newer by sequence", () => { - expect( - resolveVisibleWorktreeSetup({ - live: { ...base, sequence: 3 }, - recorded: { ...settledDone, sequence: 7 }, - turnStarted: false, - isWorking: false, - }), - ).toEqual({ ...settledDone, sequence: 7 }); - expect( - resolveVisibleWorktreeSetup({ - live: { ...settledDone, sequence: 9 }, - recorded: { ...base, sequence: 1 }, - turnStarted: false, - isWorking: false, - }), - ).toEqual({ ...settledDone, sequence: 9 }); - expect( - resolveVisibleWorktreeSetup({ - live: base, - recorded: null, - turnStarted: false, - isWorking: false, - }), - ).toEqual(base); + const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) => + resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, isWorking: false }); + expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({ + ...settledDone, + sequence: 7, + }); + expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({ + ...settledDone, + sequence: 9, + }); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1dbe11f6c4c2..90f0533c47ce 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -278,9 +278,11 @@ export function findRecordedWorktreeSetup( /** * Which setup snapshot the timeline shows, if any. The live stream wins while * it has a newer sequence; the recorded activity covers everything else. A - * running setup always shows. Once settled, the card stays only while it - * still says something the turn does not: the turn has not started yet, or a - * stage failed and the turn is still running so the exit code stays reachable. + * running, failed, or cancelled setup always shows. A clean finish leaves no + * trace once the turn is live: the setup is a means to the reply, not part of + * the conversation. Before the turn is live it stays so nothing collapses in + * the handoff gap. A failed script stays while the turn still runs so its + * exit code and terminal remain reachable. */ export function resolveVisibleWorktreeSetup(input: { live: WorktreeSetupSnapshot | null; @@ -293,7 +295,6 @@ export function resolveVisibleWorktreeSetup(input: { ? input.live : input.recorded; if (!snapshot) return null; - if (snapshot.phase === "running") return snapshot; if (snapshot.phase !== "done") return snapshot; if (!input.turnStarted) return snapshot; const stageFailed = snapshot.stages.some((stage) => stage.status === "failed"); diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx new file mode 100644 index 000000000000..6c798e15646b --- /dev/null +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -0,0 +1,195 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import ChatView from "./ChatView"; +import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; +import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; +import { SidebarInset } from "./ui/sidebar"; +import { + finalizePromotedDraftThreadByRef, + markPromotedDraftThreadByRef, + useBackgroundDraftSubmissionPending, + useComposerDraftStore, +} from "../composerDraftStore"; +import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; +import { + useEnvironmentThreadRefs, + useThread, + useThreadDetail, + useThreadRefs, + useThreadShell, + useThreadStatus, +} from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { environmentShell } from "../state/shell"; +import { + buildThreadRouteParams, + resolveThreadRouteRenderState, + type ThreadRouteTarget, +} from "../threadRoutes"; +import { resolveThreadSyncPhase } from "../threadSync"; + +/** + * The single chat surface behind both `/draft/$draftId` and + * `/$environmentId/$threadId`. A draft reserves its thread id up front, so + * one ChatView element keyed by that id carries the draft through its + * promotion to a server thread: the route swap only changes props, nothing + * unmounts, and the timeline never paints an empty frame in between. + * + * Rendered by the `_chat` layout rather than by the two leaf routes, since + * a keyed element only survives when the same parent renders it. + */ +export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { + const navigate = useNavigate(); + const draftId = target.kind === "draft" ? target.draftId : null; + const draftSession = useComposerDraftStore((store) => + draftId === null ? null : store.getDraftSession(draftId), + ); + const threadRefs = useThreadRefs(); + // The server thread this view is about: the route's own ref, or the draft's + // reserved ref once the server knows it. + const inferredThreadRef = draftSession + ? (threadRefs.find( + (ref) => + ref.environmentId === draftSession.environmentId && + ref.threadId === draftSession.threadId, + ) ?? null) + : null; + const serverThreadRef: ScopedThreadRef | null = + target.kind === "server" ? target.threadRef : (draftSession?.promotedTo ?? inferredThreadRef); + const serverThread = useThread(serverThreadRef); + const backgroundSubmissionPending = useBackgroundDraftSubmissionPending( + target.kind === "draft" ? serverThreadRef : null, + ); + const canonicalThreadRef = + target.kind === "draft" + ? resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread, + backgroundSubmissionPending, + }) + : null; + + const shell = useEnvironmentQuery( + serverThreadRef === null ? null : environmentShell.stateAtom(serverThreadRef.environmentId), + ); + const serverThreadShell = useThreadShell(serverThreadRef); + const serverThreadDetail = useThreadDetail(serverThreadRef); + const serverThreadStatus = useThreadStatus(serverThreadRef); + const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); + const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const draftThread = useComposerDraftStore((store) => + serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, + ); + const environmentHasDraftThreads = useComposerDraftStore((store) => + serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false, + ); + const renderState = resolveThreadRouteRenderState({ + bootstrapComplete, + serverThreadShellExists: serverThreadShell !== null, + serverThreadDetailExists: serverThreadDetail !== null, + serverThreadDetailDeleted: serverThreadStatus === "deleted", + draftThreadExists: draftThread !== null, + }); + const threadSyncPhase = resolveThreadSyncPhase({ + detailExists: serverThreadDetail !== null, + shellExists: serverThreadShell !== null, + status: serverThreadStatus, + }); + const serverThreadStarted = threadHasStarted(serverThreadDetail); + const environmentHasAnyThreads = environmentThreadRefs.length > 0 || environmentHasDraftThreads; + + useEffect(() => { + if (!inferredThreadRef || draftSession?.promotedTo) { + return; + } + markPromotedDraftThreadByRef(inferredThreadRef); + }, [draftSession?.promotedTo, inferredThreadRef]); + + useEffect(() => { + if (!canonicalThreadRef) { + return; + } + let cancelled = false; + void waitForDraftHeroTransition().then(() => { + if (cancelled) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(canonicalThreadRef), + replace: true, + }); + }); + return () => { + cancelled = true; + }; + }, [canonicalThreadRef, navigate]); + + useEffect(() => { + if (target.kind !== "draft" || draftSession || canonicalThreadRef) { + return; + } + void navigate({ to: "/", replace: true }); + }, [canonicalThreadRef, draftSession, navigate, target.kind]); + + useEffect(() => { + if (target.kind !== "server" || !bootstrapComplete) { + return; + } + // Navigation already resolved onto this path, so a drop aimed here + // passed its landing check; once the thread reads as missing it can + // never be attached, release it even when there is nowhere to redirect. + if (renderState === "missing") { + const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); + clearPendingFileDropsForThread(target.threadRef); + if (environmentHasAnyThreads) { + void navigate({ to: "/", replace: true }); + } + } + }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); + + useEffect(() => { + if (target.kind !== "server" || !serverThreadStarted || !draftThread) { + return; + } + finalizePromotedDraftThreadByRef(target.threadRef); + }, [draftThread, serverThreadStarted, target]); + + let view: React.ReactNode = null; + if (target.kind === "draft") { + if (draftSession) { + view = ( + + ); + } + } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) { + view = ( + + ); + } + + return ( + + {view} + + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 24078b5f0af0..c751c678e0e8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1126,7 +1126,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("shows the worktree setup card instead of the working placeholder", () => { + it("leads the worktree setup card with the working header", () => { const snapshot: WorktreeSetupSnapshot = { threadId: ThreadId.make("thread-setup"), phase: "running", @@ -1177,6 +1177,7 @@ describe("deriveMessagesTimelineRows", () => { worktreeSetup: snapshot, }); expect(withoutMessages).toEqual([ + { kind: "working", id: "working-indicator-row", createdAt: "2026-01-01T00:00:00Z" }, { kind: "worktree-setup", id: WORKTREE_SETUP_ROW_ID, @@ -1186,6 +1187,22 @@ describe("deriveMessagesTimelineRows", () => { }, ]); + // The main pass already places the working header after the send while a + // bootstrap counts as working; the card slots under that one header. + const withUserMessage = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + }); + expect(withUserMessage.map((row) => row.kind)).toEqual([ + "message", + "working", + "worktree-setup", + ]); + // A failed setup never handed off, so the card stays under the send. const withMessages = deriveMessagesTimelineRows({ timelineEntries: [userEntry, assistantEntry], @@ -1202,8 +1219,8 @@ describe("deriveMessagesTimelineRows", () => { "message", ]); - // Once the agent stage is done the setup script may still be running in - // the background: the turn owns the header and the script row follows it. + // Once the agent stage is done and the turn is live, a still-running + // script leaves the timeline; the working header surfaces it instead. const stage = (id: "agent" | "setup-script", status: "done" | "running") => ({ id, @@ -1233,13 +1250,7 @@ describe("deriveMessagesTimelineRows", () => { supportsConversationRollback: false, worktreeSetup: asyncSnapshot, }); - expect(asyncRows.map((row) => row.kind)).toEqual([ - "message", - "working", - "worktree-setup", - "thinking", - ]); - expect(asyncRows[2]).toMatchObject({ kind: "worktree-setup", embedded: true }); + expect(asyncRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]); // Dispatched but not yet visible as a turn: the full card stays put so // nothing collapses during the handoff. @@ -1251,11 +1262,26 @@ describe("deriveMessagesTimelineRows", () => { supportsConversationRollback: false, worktreeSetup: asyncSnapshot, }); - expect(handoffRows.map((row) => row.kind)).toEqual(["message", "worktree-setup"]); - expect(handoffRows[1]).toMatchObject({ kind: "worktree-setup", embedded: false }); + expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); + expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false }); - // A script that already finished has nothing left to show once the turn is live. - const finishedRows = deriveMessagesTimelineRows({ + // A script that outlives the reply never trails the assistant's message. + const outlivedRows = deriveMessagesTimelineRows({ + timelineEntries: [ + userEntry, + { ...assistantEntry, message: { ...assistantEntry.message, streaming: false } }, + ], + latestTurn: { ...liveTurn, state: "completed", completedAt: "2026-01-01T00:00:40Z" }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(outlivedRows.map((row) => row.kind)).toEqual(["message", "message"]); + + // A failed script after the handoff keeps its row under the send. + const failedRows = deriveMessagesTimelineRows({ timelineEntries: [userEntry], latestTurn: liveTurn, isWorking: true, @@ -1264,10 +1290,18 @@ describe("deriveMessagesTimelineRows", () => { supportsConversationRollback: false, worktreeSetup: { ...asyncSnapshot, - stages: [stage("setup-script", "done"), stage("agent", "done")], + phase: "done", + endedAt: "2026-01-01T00:00:20Z", + stages: [{ ...stage("setup-script", "done"), status: "failed" }, stage("agent", "done")], }, }); - expect(finishedRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]); + expect(failedRows.map((row) => row.kind)).toEqual([ + "message", + "worktree-setup", + "working", + "thinking", + ]); + expect(failedRows[1]).toMatchObject({ kind: "worktree-setup", embedded: true }); }); it("keeps context compaction visible outside folded work", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 983e49dfaa87..f500d55e6514 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -401,7 +401,11 @@ export type MessagesTimelineRow = id: string; createdAt: string | null; snapshot: WorktreeSetupSnapshot; - /** The agent already started; render only the script row under the turn header. */ + /** + * The agent's turn is live and owns the "Working for" header, so the + * card drops its own header and settle-time actions. The stage list + * stays put so nothing jumps when the handoff happens. + */ embedded: boolean; } | { @@ -1273,66 +1277,65 @@ export function deriveMessagesTimelineRows(input: { }); } - // Until the agent's turn is live, the setup card takes the place of the - // working and thinking placeholders. It stays after a failed or cancelled - // setup so the outcome and its actions remain visible until the thread - // state moves on. "Live" means the turn is in the timeline, not just that - // the server dispatched it: the card must not collapse in the gap between. + // Until the agent's turn is live, the setup card sits under the send with + // the working header above it (the header reads "Setting up worktree…" and + // later swaps its text in place, so nothing moves at the handoff). "Live" + // means the turn is in the timeline, not just that the server dispatched + // it: the card must not vanish in the gap between. Once the turn is live + // the stage list leaves the timeline; a script that is still running is + // surfaced by the working header itself. A failed or cancelled setup stays + // under the send so its outcome and actions remain reachable. const setupHandedOff = input.worktreeSetup !== null && input.worktreeSetup !== undefined && worktreeSetupAgentStarted(input.worktreeSetup) && input.latestTurn?.startedAt != null; - if (input.worktreeSetup && !setupHandedOff) { + if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) { const setupRow = { kind: "worktree-setup", id: WORKTREE_SETUP_ROW_ID, createdAt: input.worktreeSetup.startedAt, snapshot: input.worktreeSetup, - embedded: false, + embedded: setupHandedOff, } as const; - // Sit directly under the first user message: a finished snapshot can - // outlive the first assistant reply, and it belongs to the send, not the - // end of the thread. const firstUserRowIndex = nextRows.findIndex( (row) => row.kind === "message" && row.message.role === "user", ); + if (!setupHandedOff && input.worktreeSetup.phase === "running") { + // 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 (isWorking is true while a bootstrap runs); reuse it. + const workingRowIndex = nextRows.findIndex((row) => row.kind === "working"); + if (workingRowIndex >= 0) { + nextRows.splice(workingRowIndex + 1, 0, setupRow); + } else { + const insertAt = firstUserRowIndex >= 0 ? firstUserRowIndex + 1 : nextRows.length; + nextRows.splice( + insertAt, + 0, + { + kind: "working", + id: "working-indicator-row", + createdAt: input.worktreeSetup.startedAt, + }, + setupRow, + ); + } + return attachTrailingToolGroupsToAssistant(nextRows); + } if (firstUserRowIndex >= 0) { nextRows.splice(firstUserRowIndex + 1, 0, setupRow); } else { nextRows.push(setupRow); } - return attachTrailingToolGroupsToAssistant(nextRows); + if (!setupHandedOff) { + return attachTrailingToolGroupsToAssistant(nextRows); + } } if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - // An async setup script outlives the handoff. The turn owns the header, so - // the script's row sits first under it, ahead of the agent's own work. A - // script that already finished (or never ran) has nothing left to show. - const setupScriptStage = input.worktreeSetup?.stages.find((stage) => stage.id === "setup-script"); - if ( - input.worktreeSetup && - setupHandedOff && - (setupScriptStage?.status === "running" || setupScriptStage?.status === "failed") - ) { - const setupRow = { - kind: "worktree-setup", - id: WORKTREE_SETUP_ROW_ID, - createdAt: input.worktreeSetup.startedAt, - snapshot: input.worktreeSetup, - embedded: true, - } as const; - const workingRowIndex = nextRows.findIndex((row) => row.kind === "working"); - if (workingRowIndex >= 0) { - nextRows.splice(workingRowIndex + 1, 0, setupRow); - } else { - // The turn already finished (or has not been dispatched yet): the row - // trails the reply so a still-running script stays visible after it. - nextRows.push(setupRow); - } - } if (input.isWorking && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 307a893342f3..cb1098800630 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -185,6 +185,7 @@ import { toolGroupAction, workEntryDisplayLabel, workEntryIsVisibleInGroup, + worktreeSetupAgentStarted, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -192,6 +193,8 @@ import { type WorkGroupScrollAnchor, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { @@ -295,6 +298,12 @@ interface TimelineRowActivityState { isCompacting: boolean; isRevertingCheckpoint: boolean; latestTurnId: TurnId | null; + /** + * A worktree setup whose script is still running after the agent took + * over. The working header shows it as a chip with a popover; the stage + * list itself has already left the timeline. + */ + backgroundWorktreeSetup: WorktreeSetupSnapshot | null; } const TimelineRowCtx = createContext(null!); @@ -980,6 +989,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRemoveQueuedMessage, ], ); + const backgroundWorktreeSetup = + worktreeSetup !== null && + worktreeSetup.phase === "running" && + worktreeSetupAgentStarted(worktreeSetup) && + latestTurn?.startedAt != null + ? worktreeSetup + : null; const activityState = useMemo( () => ({ isWorking, @@ -987,8 +1003,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isCompacting, isRevertingCheckpoint, latestTurnId: latestTurn?.turnId ?? null, + backgroundWorktreeSetup, }), - [isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], + [ + backgroundWorktreeSetup, + isCompacting, + isRevertingCheckpoint, + isWorking, + isPreparingWorktree, + latestTurn?.turnId, + ], ); // Stable renderItem — no closure deps. Row components read shared state @@ -2210,40 +2234,81 @@ function ProposedPlanTimelineRow({ } function WorkingTimelineRow({ row }: { row: Extract }) { - const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); + const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup } = + use(TimelineRowActivityCtx); + // One span for every label so the setup-to-working handoff swaps text in + // place instead of remounting the row. + const shimmer = isPreparingWorktree || isCompacting; + const label = isPreparingWorktree ? ( + "Setting up worktree…" + ) : isCompacting ? ( + + ) : row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + ); return (
-
+
- {isPreparingWorktree ? ( - <> - Setting up worktree… - Setting up worktree… - - ) : isCompacting ? ( - <> - - - - - - ) : row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} + {label} + {shimmer ? {label} : null} + {backgroundWorktreeSetup ? ( + + ) : null}
); } +/** + * Trailing chip in the working header while a setup script still runs after + * the agent started. Opens the stage list and live output in a popover; the + * chip leaves with the script, so nothing lingers in the timeline. + */ +function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { + const ctx = use(TimelineRowCtx); + const terminalId = snapshot.setupScript?.terminalId ?? null; + const openTerminal = ctx.onOpenWorktreeSetupTerminal; + const onOpenTerminal = useMemo( + () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null), + [openTerminal, terminalId], + ); + const scriptName = snapshot.setupScript?.name ?? "Setup script"; + return ( + + + } + > + + {scriptName} + + + + + + ); +} + function ThinkingTimelineRow() { const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 6fe54269cd10..42810727ec64 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -222,18 +222,34 @@ function StageRow({ ); } +/** The server keeps this many trailing lines; the box is sized for exactly that. */ +const OUTPUT_TAIL_LINES = 4; +const OUTPUT_TAIL_SLOTS = Array.from({ length: OUTPUT_TAIL_LINES }, (_, slot) => slot); + +/** + * Fixed-height window onto the script's last lines. Rows never wrap and the + * box never grows or shrinks, so streaming output cannot push the timeline + * around while the script runs. + */ function OutputTail({ lines, failed }: { lines: ReadonlyArray; failed: boolean }) { - if (lines.length === 0) return null; + const rows = OUTPUT_TAIL_SLOTS.map((slot) => ({ + slot, + line: lines[lines.length - OUTPUT_TAIL_LINES + slot] ?? "", + })); return (
-      {lines.join("\n")}
+      {rows.map(({ slot, line }) => (
+        
+ {line.length === 0 ? "\u00a0" : line} +
+ ))}
); } @@ -269,6 +285,45 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { ); } +/** + * One-line summary of a settled setup under a live turn. A clean finish is + * removed from the timeline altogether, so this only ever renders the + * script-failed outcome while the turn still runs and the terminal matters. + */ +function CollapsedSummaryRow({ + snapshot, + totalElapsed, +}: { + snapshot: WorktreeSetupSnapshot; + totalElapsed: number | null; +}) { + const scriptFailed = snapshot.stages.some( + (stage) => stage.id === "setup-script" && stage.status === "failed", + ); + const status: WorktreeSetupStage["status"] = scriptFailed ? "failed" : "done"; + const label = scriptFailed ? "Worktree ready, setup script failed" : "Worktree ready"; + return ( +
+ + + + {label} + {totalElapsed !== null ? ( + + {formatDuration(totalElapsed)} + + ) : null} +
+ ); +} + export function WorktreeSetupCard({ snapshot, onCancel, @@ -277,8 +332,9 @@ export function WorktreeSetupCard({ embedded = false, }: WorktreeSetupCardProps & { /** - * The agent already started (async setup script), so the turn owns the - * "Working for" header and only the script's row sits among the worklog. + * The agent's turn is live and owns the "Working for" header. The stage + * list stays exactly where it was so the handoff never moves anything; a + * failed script that outlives the handoff collapses to a single row. */ embedded?: boolean; }) { @@ -292,24 +348,40 @@ export function WorktreeSetupCard({ })(); const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending"; - const stages = embedded - ? snapshot.stages.filter((stage) => stage.id === "setup-script") - : snapshot.stages; + 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; + // 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. + const showTail = + setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed"); return (
- {embedded ? null : } -
- {stages.map((stage) => ( -
- - {stage.id === "setup-script" && - (stage.status === "running" || stage.status === "failed") ? ( - - ) : null} -
- ))} -
+ {showHeader ? : null} + {collapsed ? ( + + ) : ( +
+ {snapshot.stages.map((stage) => ( +
+ + {stage.id === "setup-script" && showTail ? ( + + ) : null} +
+ ))} +
+ )} {snapshot.phase === "failed" && snapshot.error ? (

{snapshot.error}

diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 16c0c4dfcc6d..0a1bd024f3d6 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,105 +1,7 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; - -import ChatView from "../components/ChatView"; -import { threadHasStarted } from "../components/ChatView.logic"; -import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; -import { resolveThreadSyncPhase } from "../threadSync"; -import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { SidebarInset } from "~/components/ui/sidebar"; -import { - useEnvironmentThreadRefs, - useThreadDetail, - useThreadShell, - useThreadStatus, -} from "../state/entities"; -import { useEnvironmentQuery } from "../state/query"; -import { environmentShell } from "../state/shell"; - -function ChatThreadRouteView() { - const navigate = useNavigate(); - const threadRef = Route.useParams({ - select: (params) => resolveThreadRouteRef(params), - }); - const shell = useEnvironmentQuery( - threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), - ); - const serverThreadShell = useThreadShell(threadRef); - const serverThreadDetail = useThreadDetail(threadRef); - const serverThreadStatus = useThreadStatus(threadRef); - const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); - const bootstrapComplete = shell.data?.snapshot._tag === "Some"; - const environmentHasServerThreads = environmentThreadRefs.length > 0; - const draftThreadExists = useComposerDraftStore((store) => - threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, - ); - const draftThread = useComposerDraftStore((store) => - threadRef ? store.getDraftThreadByRef(threadRef) : null, - ); - const environmentHasDraftThreads = useComposerDraftStore((store) => { - if (!threadRef) { - return false; - } - return store.hasDraftThreadsInEnvironment(threadRef.environmentId); - }); - const renderState = resolveThreadRouteRenderState({ - bootstrapComplete, - serverThreadShellExists: serverThreadShell !== null, - serverThreadDetailExists: serverThreadDetail !== null, - serverThreadDetailDeleted: serverThreadStatus === "deleted", - draftThreadExists, - }); - const threadSyncPhase = resolveThreadSyncPhase({ - detailExists: serverThreadDetail !== null, - shellExists: serverThreadShell !== null, - status: serverThreadStatus, - }); - const serverThreadStarted = threadHasStarted(serverThreadDetail); - const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; - - useEffect(() => { - if (!threadRef || !bootstrapComplete) { - return; - } - - // Navigation already resolved onto this path, so a drop aimed here - // passed its landing check; once the thread reads as missing it can - // never be attached, release it even when there is nowhere to redirect. - if (renderState === "missing") { - const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); - clearPendingFileDropsForThread(threadRef); - if (environmentHasAnyThreads) { - void navigate({ to: "/", replace: true }); - } - } - }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); - - useEffect(() => { - if (!threadRef || !serverThreadStarted || !draftThread) { - return; - } - finalizePromotedDraftThreadByRef(threadRef); - }, [draftThread, serverThreadStarted, threadRef]); - - if (!threadRef) { - return null; - } - - return ( - - {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? ( - - ) : null} - - ); -} +import { createFileRoute } from "@tanstack/react-router"; +// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's +// promotion onto this route keeps the same ChatView mounted. export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ - component: ChatThreadRouteView, + component: () => null, }); diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 9d393f27e0bd..5a7f58cfbc66 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,94 +1,7 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; -import ChatView from "../components/ChatView"; -import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic"; -import { - DraftId, - markPromotedDraftThreadByRef, - useBackgroundDraftSubmissionPending, - useComposerDraftStore, -} from "../composerDraftStore"; -import { SidebarInset } from "../components/ui/sidebar"; -import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition"; -import { buildThreadRouteParams } from "../threadRoutes"; -import { useThread, useThreadRefs } from "../state/entities"; - -function DraftChatThreadRouteView() { - const navigate = useNavigate(); - const { draftId: rawDraftId } = Route.useParams(); - const draftId = DraftId.make(rawDraftId); - const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); - const threadRefs = useThreadRefs(); - const inferredThreadRef = draftSession - ? (threadRefs.find( - (ref) => - ref.environmentId === draftSession.environmentId && - ref.threadId === draftSession.threadId, - ) ?? null) - : null; - const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; - const serverThread = useThread(serverThreadRef); - const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); - const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread, - backgroundSubmissionPending, - }); - - useEffect(() => { - if (!inferredThreadRef || draftSession?.promotedTo) { - return; - } - markPromotedDraftThreadByRef(inferredThreadRef); - }, [draftSession?.promotedTo, inferredThreadRef]); - - useEffect(() => { - if (!canonicalThreadRef) { - return; - } - - let cancelled = false; - void waitForDraftHeroTransition().then(() => { - if (cancelled) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(canonicalThreadRef), - replace: true, - }); - }); - - return () => { - cancelled = true; - }; - }, [canonicalThreadRef, navigate]); - - useEffect(() => { - if (draftSession || canonicalThreadRef) { - return; - } - void navigate({ to: "/", replace: true }); - }, [canonicalThreadRef, draftSession, navigate]); - - if (!draftSession) { - return null; - } - - return ( - - - - ); -} +import { createFileRoute } from "@tanstack/react-router"; +// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's +// promotion to `/$environmentId/$threadId` keeps the same ChatView mounted. export const Route = createFileRoute("/_chat/draft/$draftId")({ - component: DraftChatThreadRouteView, + component: () => null, }); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..980177004daa 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,8 +1,10 @@ -import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { ThreadRouteView } from "../components/ThreadRouteView"; +import { resolveThreadRouteTarget } from "../threadRoutes"; import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; @@ -175,10 +177,16 @@ function ChatRouteGlobalShortcuts() { } function ChatRouteLayout() { + // Both thread routes render here, not in their own leaf components, so the + // draft-to-thread promotion keeps one ChatView mounted across the swap. + const threadTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); return ( <> - + {threadTarget ? : } ); } From b4a5156c98731e4b21357d47190950470cbe2e7d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:31:09 -0700 Subject: [PATCH 2/4] fix(web): keep ChatView identity stable across thread navigation Review follow-ups. Keying ChatView by thread ref remounted it on every thread-to-thread navigation; key by draft id instead (latched for the promoted thread's on-screen lifetime) so a draft keeps its own instance through promotion while plain threads reuse one. Make a failed setup script's row independent of whether a later turn is running, so it stops popping in and out at turn boundaries. Route every setup state through the same timeline tail so the working, thinking, and queued-message rows never go missing. Derive the collapsed summary from the setup's phase so failed and cancelled setups do not read as ready. Use the Button primitive for the chip. Co-Authored-By: Claude Fable 5 --- .../src/project/ProjectSetupScriptRunner.ts | 7 ++- .../web/src/components/ChatView.logic.test.ts | 34 ++++------- apps/web/src/components/ChatView.logic.ts | 9 ++- apps/web/src/components/ChatView.tsx | 1 - apps/web/src/components/ThreadRouteView.tsx | 41 +++++++++---- .../chat/MessagesTimeline.logic.test.ts | 47 ++++++++++----- .../components/chat/MessagesTimeline.logic.ts | 57 +++++++++---------- .../src/components/chat/MessagesTimeline.tsx | 4 +- .../src/components/chat/WorktreeSetupCard.tsx | 16 +++--- apps/web/src/composerDraftStore.ts | 13 +++++ 10 files changed, 136 insertions(+), 93 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 359466486f5a..16cbfaa59496 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -259,8 +259,11 @@ export const make = Effect.gen(function* () { if (event.type === "output") { lineBuffer += event.data; // A bare carriage return is how installers redraw a progress line in - // place. Treating it as a line break keeps each redraw a short line - // of its own instead of gluing every update into one long one. + // place; each redraw becomes a short line of its own instead of + // being glued into one long one. The wrapper echo is filtered per + // segment too, which is why `echoedWrapperLines` is split on the + // same `\r`: a line editor repainting the typed command yields the + // same segments. const lines = lineBuffer.split(/\r\n|\r|\n/); lineBuffer = lines.pop() ?? ""; // A script that never prints a newline must not grow this forever. diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e2efba8f2f84..e69b10b682ab 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2535,43 +2535,33 @@ describe("worktree setup visibility", () => { }); it("shows a running setup and drops a clean one once the turn started", () => { - const visible = ( - snapshot: WorktreeSetupSnapshot | null, - turnStarted: boolean, - isWorking = true, - ) => resolveVisibleWorktreeSetup({ live: null, recorded: snapshot, turnStarted, isWorking }); - expect( - resolveVisibleWorktreeSetup({ - live: base, - recorded: null, - turnStarted: false, - isWorking: true, - }), - ).toEqual(base); + const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) => + resolveVisibleWorktreeSetup({ live: null, recorded: snapshot, turnStarted }); + expect(resolveVisibleWorktreeSetup({ live: base, recorded: null, turnStarted: false })).toEqual( + base, + ); expect(visible(settledDone, false)).toEqual(settledDone); expect(visible(settledDone, true)).toBeNull(); - expect(visible(settledDone, true, false)).toBeNull(); expect(visible(null, true)).toBeNull(); }); - it("keeps a failed script visible for the running turn and a failed setup always", () => { + it("keeps a failed script, a failed setup, and a cancelled setup visible", () => { const scriptFailed = { ...settledDone, stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], }; - const visible = (snapshot: WorktreeSetupSnapshot, isWorking: boolean) => - resolveVisibleWorktreeSetup({ live: null, recorded: snapshot, turnStarted: true, isWorking }); - expect(visible(scriptFailed, true)).toEqual(scriptFailed); - expect(visible(scriptFailed, false)).toBeNull(); + const visible = (snapshot: WorktreeSetupSnapshot) => + resolveVisibleWorktreeSetup({ live: null, recorded: snapshot, turnStarted: true }); + expect(visible(scriptFailed)).toEqual(scriptFailed); const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" }; - expect(visible(failed, false)).toEqual(failed); + expect(visible(failed)).toEqual(failed); const cancelled = { ...settledDone, phase: "cancelled" as const }; - expect(visible(cancelled, false)).toEqual(cancelled); + expect(visible(cancelled)).toEqual(cancelled); }); it("prefers whichever snapshot is newer by sequence", () => { const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) => - resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, isWorking: false }); + resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false }); expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({ ...settledDone, sequence: 7, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 90f0533c47ce..0feb37cdc805 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -281,14 +281,14 @@ export function findRecordedWorktreeSetup( * running, failed, or cancelled setup always shows. A clean finish leaves no * trace once the turn is live: the setup is a means to the reply, not part of * the conversation. Before the turn is live it stays so nothing collapses in - * the handoff gap. A failed script stays while the turn still runs so its - * exit code and terminal remain reachable. + * the handoff gap. A failed script stays for good, so its exit code and + * terminal remain reachable; visibility never depends on whether some later + * turn happens to be running, which would make the row come and go. */ export function resolveVisibleWorktreeSetup(input: { live: WorktreeSetupSnapshot | null; recorded: WorktreeSetupSnapshot | null; turnStarted: boolean; - isWorking: boolean; }): WorktreeSetupSnapshot | null { const snapshot = input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) @@ -297,8 +297,7 @@ export function resolveVisibleWorktreeSetup(input: { if (!snapshot) return null; if (snapshot.phase !== "done") return snapshot; if (!input.turnStarted) return snapshot; - const stageFailed = snapshot.stages.some((stage) => stage.status === "failed"); - return stageFailed && input.isWorking ? snapshot : null; + return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null; } export function resolveDraftHeroState(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 38f773cf037e..31f0b26d3caa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3529,7 +3529,6 @@ export default function ChatView(props: ChatViewProps) { live: liveWorktreeSetup, recorded: recordedWorktreeSetup, turnStarted: activeThread?.latestTurn?.startedAt != null, - isWorking, }); // Sends wait for the agent handoff, not for the setup script: an async // script keeps the snapshot running while the agent already works, and a diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 6c798e15646b..ed902d06ede1 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,7 +1,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import ChatView from "./ChatView"; import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; @@ -33,13 +33,16 @@ import { resolveThreadSyncPhase } from "../threadSync"; /** * The single chat surface behind both `/draft/$draftId` and - * `/$environmentId/$threadId`. A draft reserves its thread id up front, so - * one ChatView element keyed by that id carries the draft through its - * promotion to a server thread: the route swap only changes props, nothing - * unmounts, and the timeline never paints an empty frame in between. + * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so + * a background send's state stays with the draft it came from), and that + * instance carries the draft through its promotion to a server thread: the + * thread route keeps keying by the draft id while the draft record exists, + * so the route swap only changes props and the timeline never paints an + * empty frame. Plain server threads are unkeyed, so navigating between them + * reuses one instance as ChatView expects. * * Rendered by the `_chat` layout rather than by the two leaf routes, since - * a keyed element only survives when the same parent renders it. + * an element only survives a route swap when the same parent renders it. */ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { const navigate = useNavigate(); @@ -83,6 +86,25 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); + const promotedDraftId = useComposerDraftStore((store) => + target.kind === "server" ? store.getDraftIdByRef(target.threadRef) : null, + ); + // The draft record is removed once the promoted thread has started, which + // is after the route swap. Latch the key so the element that carried the + // draft keeps its identity for as long as this thread stays on screen. + const [chatViewKey, setChatViewKey] = useState<{ threadKey: string; key: string } | null>(null); + const serverThreadKey = target.kind === "server" ? scopedThreadKey(target.threadRef) : null; + const nextChatViewKey = + serverThreadKey === null + ? null + : chatViewKey?.threadKey === serverThreadKey + ? chatViewKey + : promotedDraftId + ? { threadKey: serverThreadKey, key: promotedDraftId } + : null; + if (nextChatViewKey !== chatViewKey) { + setChatViewKey(nextChatViewKey); + } const environmentHasDraftThreads = useComposerDraftStore((store) => serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false, ); @@ -163,10 +185,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { if (draftSession) { view = ( { }); describe("deriveMessagesTimelineRows", () => { + const queuedMessage = (id: string, prompt: string) => ({ + id, + prompt, + images: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + submissionIntent: "foreground" as const, + queuedAfterToolActivityId: null, + createdAt: "2026-01-01T00:00:01Z", + }); + it("appends queued messages after the live rows, marking the oldest as next", () => { - const queuedMessage = (id: string, prompt: string) => ({ - id, - prompt, - images: [], - files: [], - terminalContexts: [], - previewAnnotations: [], - reviewComments: [], - submissionIntent: "foreground" as const, - queuedAfterToolActivityId: null, - createdAt: "2026-01-01T00:00:01Z", - }); const rows = deriveMessagesTimelineRows({ timelineEntries: [], isWorking: true, @@ -1203,7 +1204,9 @@ describe("deriveMessagesTimelineRows", () => { "worktree-setup", ]); - // A failed setup never handed off, so the card stays under the send. + // A failed setup never handed off, so the card stays under the send. The + // rest of the timeline is untouched: a running send still gets its + // working and thinking rows, and queued follow-ups still trail. const withMessages = deriveMessagesTimelineRows({ timelineEntries: [userEntry, assistantEntry], isWorking: true, @@ -1211,12 +1214,30 @@ describe("deriveMessagesTimelineRows", () => { turnDiffSummaries: [], supportsConversationRollback: false, worktreeSetup: { ...snapshot, phase: "failed" }, + queuedMessages: [queuedMessage("q1", "later")], }); expect(withMessages.map((row) => row.kind)).toEqual([ "message", "worktree-setup", "working", "message", + "thinking", + "queued-message", + ]); + const runningWithQueue = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + queuedMessages: [queuedMessage("q1", "later")], + }); + expect(runningWithQueue.map((row) => row.kind)).toEqual([ + "message", + "working", + "worktree-setup", + "queued-message", ]); // Once the agent stage is done and the turn is live, a still-running diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f500d55e6514..98d1de7ad307 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1290,6 +1290,7 @@ export function deriveMessagesTimelineRows(input: { input.worktreeSetup !== undefined && worktreeSetupAgentStarted(input.worktreeSetup) && input.latestTurn?.startedAt != null; + const setupRunning = !setupHandedOff && input.worktreeSetup?.phase === "running"; if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) { const setupRow = { kind: "worktree-setup", @@ -1301,42 +1302,38 @@ export function deriveMessagesTimelineRows(input: { const firstUserRowIndex = nextRows.findIndex( (row) => row.kind === "message" && row.message.role === "user", ); - if (!setupHandedOff && input.worktreeSetup.phase === "running") { - // 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 (isWorking is true while a bootstrap runs); reuse it. - const workingRowIndex = nextRows.findIndex((row) => row.kind === "working"); - if (workingRowIndex >= 0) { - nextRows.splice(workingRowIndex + 1, 0, setupRow); - } else { - const insertAt = firstUserRowIndex >= 0 ? firstUserRowIndex + 1 : nextRows.length; - nextRows.splice( - insertAt, - 0, - { - kind: "working", - id: "working-indicator-row", - createdAt: input.worktreeSetup.startedAt, - }, - setupRow, - ); - } - return attachTrailingToolGroupsToAssistant(nextRows); - } - if (firstUserRowIndex >= 0) { - nextRows.splice(firstUserRowIndex + 1, 0, setupRow); + // 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; + if (workingRowIndex >= 0) { + nextRows.splice(workingRowIndex + 1, 0, setupRow); } else { - nextRows.push(setupRow); - } - if (!setupHandedOff) { - return attachTrailingToolGroupsToAssistant(nextRows); + const insertAt = firstUserRowIndex >= 0 ? firstUserRowIndex + 1 : nextRows.length; + nextRows.splice( + insertAt, + 0, + ...(setupRunning + ? [ + { + kind: "working", + id: "working-indicator-row", + createdAt: input.worktreeSetup.startedAt, + } as const, + setupRow, + ] + : [setupRow]), + ); } } - if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { + // 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. + const hasWorkingRow = nextRows.some((row) => row.kind === "working"); + if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && (!hasActivityRow || latestToolFailed)) { + if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index cb1098800630..09aa61bd5186 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2286,8 +2286,8 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 42810727ec64..6fbe7a2b9b5e 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -287,8 +287,8 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { /** * One-line summary of a settled setup under a live turn. A clean finish is - * removed from the timeline altogether, so this only ever renders the - * script-failed outcome while the turn still runs and the terminal matters. + * removed from the timeline altogether, so this only renders the outcomes + * worth keeping: a failed script, a failed setup, or a cancelled one. */ function CollapsedSummaryRow({ snapshot, @@ -297,11 +297,13 @@ function CollapsedSummaryRow({ snapshot: WorktreeSetupSnapshot; totalElapsed: number | null; }) { - const scriptFailed = snapshot.stages.some( - (stage) => stage.id === "setup-script" && stage.status === "failed", - ); - const status: WorktreeSetupStage["status"] = scriptFailed ? "failed" : "done"; - const label = scriptFailed ? "Worktree ready, setup script failed" : "Worktree ready"; + const status: WorktreeSetupStage["status"] = + snapshot.phase === "failed" || snapshot.phase === "cancelled" + ? "failed" + : snapshot.stages.some((stage) => stage.id === "setup-script" && stage.status === "failed") + ? "failed" + : "done"; + const label = headerLabel(snapshot); return (
DraftSessionState | null; /** Resolves a server-thread ref back to a matching draft session when one exists. */ getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null; + /** The draft id that reserved a server-thread ref, while its draft record still exists. */ + getDraftIdByRef: (threadRef: ScopedThreadRef) => DraftId | null; getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null; getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; listDraftThreadKeys: () => string[]; @@ -2583,6 +2585,17 @@ const composerDraftStore = create()( } return null; }, + getDraftIdByRef: (threadRef) => { + for (const [draftId, draftSession] of Object.entries(get().draftThreadsByThreadKey)) { + if ( + draftSession.environmentId === threadRef.environmentId && + draftSession.threadId === threadRef.threadId + ) { + return DraftId.make(draftId); + } + } + return null; + }, getDraftThread: (threadRef) => { if (typeof threadRef === "string") { return get().getDraftSession(DraftId.make(threadRef)); From 35aced367615f81b577514e2740c05e18711f493 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:01:01 -0700 Subject: [PATCH 3/4] fix(web): retire the worktree setup row after the first turn A failed setup script's row was kept for the thread's whole life, so it rode along on every follow-up turn. The setup belongs to the first turn: once the user sends another message, every settled outcome is history and nothing is shown again. Only a script that is still running survives a follow-up. Co-Authored-By: Claude Fable 5 --- .../web/src/components/ChatView.logic.test.ts | 41 +++++++++++++++---- apps/web/src/components/ChatView.logic.ts | 19 ++++++--- apps/web/src/components/ChatView.tsx | 1 + 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e69b10b682ab..8a7e17adee12 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2536,10 +2536,20 @@ describe("worktree setup visibility", () => { it("shows a running setup and drops a clean one once the turn started", () => { const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) => - resolveVisibleWorktreeSetup({ live: null, recorded: snapshot, turnStarted }); - expect(resolveVisibleWorktreeSetup({ live: base, recorded: null, turnStarted: false })).toEqual( - base, - ); + resolveVisibleWorktreeSetup({ + live: null, + recorded: snapshot, + turnStarted, + followUpSent: false, + }); + expect( + resolveVisibleWorktreeSetup({ + live: base, + recorded: null, + turnStarted: false, + followUpSent: false, + }), + ).toEqual(base); expect(visible(settledDone, false)).toEqual(settledDone); expect(visible(settledDone, true)).toBeNull(); expect(visible(null, true)).toBeNull(); @@ -2550,18 +2560,35 @@ describe("worktree setup visibility", () => { ...settledDone, stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], }; - const visible = (snapshot: WorktreeSetupSnapshot) => - resolveVisibleWorktreeSetup({ live: null, recorded: snapshot, turnStarted: true }); + const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) => + resolveVisibleWorktreeSetup({ + live: null, + recorded: snapshot, + turnStarted: true, + followUpSent, + }); expect(visible(scriptFailed)).toEqual(scriptFailed); const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" }; expect(visible(failed)).toEqual(failed); const cancelled = { ...settledDone, phase: "cancelled" as const }; expect(visible(cancelled)).toEqual(cancelled); + + // The setup belongs to the first turn. A follow-up send retires every + // settled outcome; only a script that is still running stays. + expect(visible(scriptFailed, true)).toBeNull(); + expect(visible(failed, true)).toBeNull(); + expect(visible(cancelled, true)).toBeNull(); + expect(visible(settledDone, true)).toBeNull(); + const stillRunning = { + ...base, + stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")], + }; + expect(visible(stillRunning, true)).toEqual(stillRunning); }); it("prefers whichever snapshot is newer by sequence", () => { const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) => - resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false }); + resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false }); expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({ ...settledDone, sequence: 7, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0feb37cdc805..f5d9e7ad2578 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -278,23 +278,30 @@ export function findRecordedWorktreeSetup( /** * Which setup snapshot the timeline shows, if any. The live stream wins while * it has a newer sequence; the recorded activity covers everything else. A - * running, failed, or cancelled setup always shows. A clean finish leaves no - * trace once the turn is live: the setup is a means to the reply, not part of - * the conversation. Before the turn is live it stays so nothing collapses in - * the handoff gap. A failed script stays for good, so its exit code and - * terminal remain reachable; visibility never depends on whether some later - * turn happens to be running, which would make the row come and go. + * running setup always shows. The setup belongs to the thread's first turn: + * once the user has sent a follow-up it is history and nothing about it is + * shown again, whatever its outcome. Within that first turn, a clean finish + * leaves no trace once the turn is live (the setup is a means to the reply, + * not part of the conversation), while a failed script, a failed setup, or a + * cancelled one stays so the outcome, exit code, and terminal are reachable. + * Before the turn is live everything stays so nothing collapses in the + * handoff gap. Visibility never depends on whether a turn happens to be + * running, which would make the row come and go. */ export function resolveVisibleWorktreeSetup(input: { live: WorktreeSetupSnapshot | null; recorded: WorktreeSetupSnapshot | null; turnStarted: boolean; + /** The user sent a message after the one that created the worktree. */ + followUpSent: boolean; }): WorktreeSetupSnapshot | null { const snapshot = input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) ? input.live : input.recorded; if (!snapshot) return null; + if (snapshot.phase === "running") return snapshot; + if (input.followUpSent) return null; if (snapshot.phase !== "done") return snapshot; if (!input.turnStarted) return snapshot; return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 31f0b26d3caa..02cbaae061bf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3529,6 +3529,7 @@ export default function ChatView(props: ChatViewProps) { live: liveWorktreeSetup, recorded: recordedWorktreeSetup, turnStarted: activeThread?.latestTurn?.startedAt != null, + followUpSent: (serverMessages?.filter((message) => message.role === "user").length ?? 0) > 1, }); // Sends wait for the agent handoff, not for the setup script: an async // script keeps the snapshot running while the agent already works, and a From 2897da1e9144960b8bec76d2ac04ef551f566f82 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:06:52 -0700 Subject: [PATCH 4/4] fix(web): count the optimistic follow-up when retiring the setup row Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 02cbaae061bf..708809712917 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3529,7 +3529,9 @@ export default function ChatView(props: ChatViewProps) { live: liveWorktreeSetup, recorded: recordedWorktreeSetup, turnStarted: activeThread?.latestTurn?.startedAt != null, - followUpSent: (serverMessages?.filter((message) => message.role === "user").length ?? 0) > 1, + // Counts the optimistic send too, so the row retires the moment the + // follow-up is on screen rather than when the server echoes it back. + followUpSent: timelineMessages.filter((message) => message.role === "user").length > 1, }); // Sends wait for the agent handoff, not for the setup script: an async // script keeps the snapshot running while the agent already works, and a