authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 22:19:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 22:19:27-07:00
log0f5a1513e09fb41e0d0f86157aff64cc3657975d
tree52a6b9e52e3bc6e054b9fb1cd19f4873a7d3fcfe
parentfe896e6a90dd2a91e9499c098bb621568a3f341f
signature Signed by PGP key B5690EEEBB952194

fix(web): stop the worktree setup card from flashing and shifting (#12015)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

14 files changed, 628 insertions(+), 384 deletions(-)

apps/server/src/project/ProjectSetupScriptRunner.test.ts+6-1
...@@ -307,7 +307,9 @@ describe("ProjectSetupScriptRunner", () => {...@@ -307,7 +307,9 @@ describe("ProjectSetupScriptRunner", () => {
307 // control sequences are stripped, and the echoed wrapper is hidden.307 // control sequences are stripped, and the echoed wrapper is hidden.
308 yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`);308 yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`);
309 yield* emit("\u001b[32mResolving");309 yield* emit("\u001b[32mResolving");
310 yield* emit(" deps\u001b[0m\r\nDone in 2s\r\n");310 yield* emit(" deps\u001b[0m\r\n");
311 // Progress redraws separated by bare carriage returns are their own lines.
312 yield* emit("Progress: 1/3\rProgress: 2/3\rProgress: 3/3\r\nDone in 2s\r\n");
311 // A spoofed sentinel from the script itself must not settle completion.313 // A spoofed sentinel from the script itself must not settle completion.
312 yield* emit("__T3_SETUP_DONE__:0\r\n");314 yield* emit("__T3_SETUP_DONE__:0\r\n");
313 yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`);315 yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`);
...@@ -317,6 +319,9 @@ describe("ProjectSetupScriptRunner", () => {...@@ -317,6 +319,9 @@ describe("ProjectSetupScriptRunner", () => {
317 expect(completion.exitCode).toBe(3);319 expect(completion.exitCode).toBe(3);
318 expect(seen).toEqual([320 expect(seen).toEqual([
319 "Resolving deps",321 "Resolving deps",
322 "Progress: 1/3",
323 "Progress: 2/3",
324 "Progress: 3/3",
320 "Done in 2s",325 "Done in 2s",
321 "__T3_SETUP_DONE__:0",326 "__T3_SETUP_DONE__:0",
322 `__T3_SETUP_DONE___${"0".repeat(32)}:0`,327 `__T3_SETUP_DONE___${"0".repeat(32)}:0`,
apps/server/src/project/ProjectSetupScriptRunner.ts+7-1
...@@ -258,7 +258,13 @@ export const make = Effect.gen(function* () {...@@ -258,7 +258,13 @@ export const make = Effect.gen(function* () {
258 }258 }
259 if (event.type === "output") {259 if (event.type === "output") {
260 lineBuffer += event.data;260 lineBuffer += event.data;
261 const lines = lineBuffer.split(/\r?\n/);261 // A bare carriage return is how installers redraw a progress line in
262 // place; each redraw becomes a short line of its own instead of
263 // being glued into one long one. The wrapper echo is filtered per
264 // segment too, which is why `echoedWrapperLines` is split on the
265 // same `\r`: a line editor repainting the typed command yields the
266 // same segments.
267 const lines = lineBuffer.split(/\r\n|\r|\n/);
262 lineBuffer = lines.pop() ?? "";268 lineBuffer = lines.pop() ?? "";
263 // A script that never prints a newline must not grow this forever.269 // A script that never prints a newline must not grow this forever.
264 // The sentinel is always on its own line, so keeping the tail is safe.270 // The sentinel is always on its own line, so keeping the tail is safe.
apps/web/src/components/ChatView.logic.test.ts+44-64
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 type ServerProvider,10 type ServerProvider,
11 ThreadId,11 ThreadId,
12 TurnId,12 TurnId,
13 type WorktreeSetupSnapshot,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";15import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
15import { Atom, AsyncResult } from "effect/unstable/reactivity";16import { Atom, AsyncResult } from "effect/unstable/reactivity";
...@@ -2533,89 +2534,68 @@ describe("worktree setup visibility", () => {...@@ -2533,89 +2534,68 @@ describe("worktree setup visibility", () => {
2533 expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull();2534 expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull();
2534 });2535 });
25352536
2536 it("shows a running setup and hides a clean one once the turn started", () => {2537 it("shows a running setup and drops a clean one once the turn started", () => {
2538 const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) =>
2539 resolveVisibleWorktreeSetup({
2540 live: null,
2541 recorded: snapshot,
2542 turnStarted,
2543 followUpSent: false,
2544 });
2537 expect(2545 expect(
2538 resolveVisibleWorktreeSetup({2546 resolveVisibleWorktreeSetup({
2539 live: base,2547 live: base,
2540 recorded: null,2548 recorded: null,
2541 turnStarted: false,2549 turnStarted: false,
2542 isWorking: true,2550 followUpSent: false,
2543 }),2551 }),
2544 ).toEqual(base);2552 ).toEqual(base);
2545 expect(2553 expect(visible(settledDone, false)).toEqual(settledDone);
2546 resolveVisibleWorktreeSetup({2554 expect(visible(settledDone, true)).toBeNull();
2547 live: null,2555 expect(visible(null, true)).toBeNull();
2548 recorded: settledDone,
2549 turnStarted: false,
2550 isWorking: true,
2551 }),
2552 ).toEqual(settledDone);
2553 expect(
2554 resolveVisibleWorktreeSetup({
2555 live: null,
2556 recorded: settledDone,
2557 turnStarted: true,
2558 isWorking: true,
2559 }),
2560 ).toBeNull();
2561 });2556 });
25622557
2563 it("keeps a failed script visible for the running turn and a failed setup always", () => {2558 it("keeps a failed script, a failed setup, and a cancelled setup visible", () => {
2564 const scriptFailed = {2559 const scriptFailed = {
2565 ...settledDone,2560 ...settledDone,
2566 stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")],2561 stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")],
2567 };2562 };
2568 expect(2563 const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) =>
2569 resolveVisibleWorktreeSetup({
2570 live: null,
2571 recorded: scriptFailed,
2572 turnStarted: true,
2573 isWorking: true,
2574 }),
2575 ).toEqual(scriptFailed);
2576 expect(
2577 resolveVisibleWorktreeSetup({2564 resolveVisibleWorktreeSetup({
2578 live: null,2565 live: null,
2579 recorded: scriptFailed,2566 recorded: snapshot,
2580 turnStarted: true,2567 turnStarted: true,
2581 isWorking: false,2568 followUpSent,
2582 }),2569 });
2583 ).toBeNull();2570 expect(visible(scriptFailed)).toEqual(scriptFailed);
2584 const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" };2571 const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" };
2585 expect(2572 expect(visible(failed)).toEqual(failed);
2586 resolveVisibleWorktreeSetup({2573 const cancelled = { ...settledDone, phase: "cancelled" as const };
2587 live: null,2574 expect(visible(cancelled)).toEqual(cancelled);
2588 recorded: failed,2575
2589 turnStarted: true,2576 // The setup belongs to the first turn. A follow-up send retires every
2590 isWorking: false,2577 // settled outcome; only a script that is still running stays.
2591 }),2578 expect(visible(scriptFailed, true)).toBeNull();
2592 ).toEqual(failed);2579 expect(visible(failed, true)).toBeNull();
2580 expect(visible(cancelled, true)).toBeNull();
2581 expect(visible(settledDone, true)).toBeNull();
2582 const stillRunning = {
2583 ...base,
2584 stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")],
2585 };
2586 expect(visible(stillRunning, true)).toEqual(stillRunning);
2593 });2587 });
25942588
2595 it("prefers whichever snapshot is newer by sequence", () => {2589 it("prefers whichever snapshot is newer by sequence", () => {
2596 expect(2590 const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) =>
2597 resolveVisibleWorktreeSetup({2591 resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false });
2598 live: { ...base, sequence: 3 },2592 expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({
2599 recorded: { ...settledDone, sequence: 7 },2593 ...settledDone,
2600 turnStarted: false,2594 sequence: 7,
2601 isWorking: false,2595 });
2602 }),2596 expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({
2603 ).toEqual({ ...settledDone, sequence: 7 });2597 ...settledDone,
2604 expect(2598 sequence: 9,
2605 resolveVisibleWorktreeSetup({2599 });
2606 live: { ...settledDone, sequence: 9 },
2607 recorded: { ...base, sequence: 1 },
2608 turnStarted: false,
2609 isWorking: false,
2610 }),
2611 ).toEqual({ ...settledDone, sequence: 9 });
2612 expect(
2613 resolveVisibleWorktreeSetup({
2614 live: base,
2615 recorded: null,
2616 turnStarted: false,
2617 isWorking: false,
2618 }),
2619 ).toEqual(base);
2620 });2600 });
2621});2601});
apps/web/src/components/ChatView.logic.ts+13-6
...@@ -278,15 +278,22 @@ export function findRecordedWorktreeSetup(...@@ -278,15 +278,22 @@ export function findRecordedWorktreeSetup(
278/**278/**
279 * Which setup snapshot the timeline shows, if any. The live stream wins while279 * Which setup snapshot the timeline shows, if any. The live stream wins while
280 * it has a newer sequence; the recorded activity covers everything else. A280 * it has a newer sequence; the recorded activity covers everything else. A
281 * running setup always shows. Once settled, the card stays only while it281 * running setup always shows. The setup belongs to the thread's first turn:
282 * still says something the turn does not: the turn has not started yet, or a282 * once the user has sent a follow-up it is history and nothing about it is
283 * stage failed and the turn is still running so the exit code stays reachable.283 * shown again, whatever its outcome. Within that first turn, a clean finish
284 * leaves no trace once the turn is live (the setup is a means to the reply,
285 * not part of the conversation), while a failed script, a failed setup, or a
286 * cancelled one stays so the outcome, exit code, and terminal are reachable.
287 * Before the turn is live everything stays so nothing collapses in the
288 * handoff gap. Visibility never depends on whether a turn happens to be
289 * running, which would make the row come and go.
284 */290 */
285export function resolveVisibleWorktreeSetup(input: {291export function resolveVisibleWorktreeSetup(input: {
286 live: WorktreeSetupSnapshot | null;292 live: WorktreeSetupSnapshot | null;
287 recorded: WorktreeSetupSnapshot | null;293 recorded: WorktreeSetupSnapshot | null;
288 turnStarted: boolean;294 turnStarted: boolean;
289 isWorking: boolean;295 /** The user sent a message after the one that created the worktree. */
296 followUpSent: boolean;
290}): WorktreeSetupSnapshot | null {297}): WorktreeSetupSnapshot | null {
291 const snapshot =298 const snapshot =
292 input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence)299 input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence)
...@@ -294,10 +301,10 @@ export function resolveVisibleWorktreeSetup(input: {...@@ -294,10 +301,10 @@ export function resolveVisibleWorktreeSetup(input: {
294 : input.recorded;301 : input.recorded;
295 if (!snapshot) return null;302 if (!snapshot) return null;
296 if (snapshot.phase === "running") return snapshot;303 if (snapshot.phase === "running") return snapshot;
304 if (input.followUpSent) return null;
297 if (snapshot.phase !== "done") return snapshot;305 if (snapshot.phase !== "done") return snapshot;
298 if (!input.turnStarted) return snapshot;306 if (!input.turnStarted) return snapshot;
299 const stageFailed = snapshot.stages.some((stage) => stage.status === "failed");307 return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null;
300 return stageFailed && input.isWorking ? snapshot : null;
301}308}
302309
303export function resolveDraftHeroState(input: {310export function resolveDraftHeroState(input: {
apps/web/src/components/ChatView.tsx+3-1
...@@ -3529,7 +3529,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -3529,7 +3529,9 @@ export default function ChatView(props: ChatViewProps) {
3529 live: liveWorktreeSetup,3529 live: liveWorktreeSetup,
3530 recorded: recordedWorktreeSetup,3530 recorded: recordedWorktreeSetup,
3531 turnStarted: activeThread?.latestTurn?.startedAt != null,3531 turnStarted: activeThread?.latestTurn?.startedAt != null,
3532 isWorking,3532 // Counts the optimistic send too, so the row retires the moment the
3533 // follow-up is on screen rather than when the server echoes it back.
3534 followUpSent: timelineMessages.filter((message) => message.role === "user").length > 1,
3533 });3535 });
3534 // Sends wait for the agent handoff, not for the setup script: an async3536 // Sends wait for the agent handoff, not for the setup script: an async
3535 // script keeps the snapshot running while the agent already works, and a3537 // script keeps the snapshot running while the agent already works, and a
apps/web/src/components/ThreadRouteView.tsx created+214
...@@ -0,0 +1,214 @@
1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef } from "@t3tools/contracts";
3import { useNavigate } from "@tanstack/react-router";
4import { useEffect, useState } from "react";
5
6import ChatView from "./ChatView";
7import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic";
8import { waitForDraftHeroTransition } from "./chat/draftHeroTransition";
9import { SidebarInset } from "./ui/sidebar";
10import {
11 finalizePromotedDraftThreadByRef,
12 markPromotedDraftThreadByRef,
13 useBackgroundDraftSubmissionPending,
14 useComposerDraftStore,
15} from "../composerDraftStore";
16import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
17import {
18 useEnvironmentThreadRefs,
19 useThread,
20 useThreadDetail,
21 useThreadRefs,
22 useThreadShell,
23 useThreadStatus,
24} from "../state/entities";
25import { useEnvironmentQuery } from "../state/query";
26import { environmentShell } from "../state/shell";
27import {
28 buildThreadRouteParams,
29 resolveThreadRouteRenderState,
30 type ThreadRouteTarget,
31} from "../threadRoutes";
32import { resolveThreadSyncPhase } from "../threadSync";
33
34/**
35 * The single chat surface behind both `/draft/$draftId` and
36 * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so
37 * a background send's state stays with the draft it came from), and that
38 * instance carries the draft through its promotion to a server thread: the
39 * thread route keeps keying by the draft id while the draft record exists,
40 * so the route swap only changes props and the timeline never paints an
41 * empty frame. Plain server threads are unkeyed, so navigating between them
42 * reuses one instance as ChatView expects.
43 *
44 * Rendered by the `_chat` layout rather than by the two leaf routes, since
45 * an element only survives a route swap when the same parent renders it.
46 */
47export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {
48 const navigate = useNavigate();
49 const draftId = target.kind === "draft" ? target.draftId : null;
50 const draftSession = useComposerDraftStore((store) =>
51 draftId === null ? null : store.getDraftSession(draftId),
52 );
53 const threadRefs = useThreadRefs();
54 // The server thread this view is about: the route's own ref, or the draft's
55 // reserved ref once the server knows it.
56 const inferredThreadRef = draftSession
57 ? (threadRefs.find(
58 (ref) =>
59 ref.environmentId === draftSession.environmentId &&
60 ref.threadId === draftSession.threadId,
61 ) ?? null)
62 : null;
63 const serverThreadRef: ScopedThreadRef | null =
64 target.kind === "server" ? target.threadRef : (draftSession?.promotedTo ?? inferredThreadRef);
65 const serverThread = useThread(serverThreadRef);
66 const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(
67 target.kind === "draft" ? serverThreadRef : null,
68 );
69 const canonicalThreadRef =
70 target.kind === "draft"
71 ? resolveDraftPromotionNavigationTarget({
72 serverThreadRef,
73 serverThread,
74 backgroundSubmissionPending,
75 })
76 : null;
77
78 const shell = useEnvironmentQuery(
79 serverThreadRef === null ? null : environmentShell.stateAtom(serverThreadRef.environmentId),
80 );
81 const serverThreadShell = useThreadShell(serverThreadRef);
82 const serverThreadDetail = useThreadDetail(serverThreadRef);
83 const serverThreadStatus = useThreadStatus(serverThreadRef);
84 const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null);
85 const bootstrapComplete = shell.data?.snapshot._tag === "Some";
86 const draftThread = useComposerDraftStore((store) =>
87 serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null,
88 );
89 const promotedDraftId = useComposerDraftStore((store) =>
90 target.kind === "server" ? store.getDraftIdByRef(target.threadRef) : null,
91 );
92 // The draft record is removed once the promoted thread has started, which
93 // is after the route swap. Latch the key so the element that carried the
94 // draft keeps its identity for as long as this thread stays on screen.
95 const [chatViewKey, setChatViewKey] = useState<{ threadKey: string; key: string } | null>(null);
96 const serverThreadKey = target.kind === "server" ? scopedThreadKey(target.threadRef) : null;
97 const nextChatViewKey =
98 serverThreadKey === null
99 ? null
100 : chatViewKey?.threadKey === serverThreadKey
101 ? chatViewKey
102 : promotedDraftId
103 ? { threadKey: serverThreadKey, key: promotedDraftId }
104 : null;
105 if (nextChatViewKey !== chatViewKey) {
106 setChatViewKey(nextChatViewKey);
107 }
108 const environmentHasDraftThreads = useComposerDraftStore((store) =>
109 serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false,
110 );
111 const renderState = resolveThreadRouteRenderState({
112 bootstrapComplete,
113 serverThreadShellExists: serverThreadShell !== null,
114 serverThreadDetailExists: serverThreadDetail !== null,
115 serverThreadDetailDeleted: serverThreadStatus === "deleted",
116 draftThreadExists: draftThread !== null,
117 });
118 const threadSyncPhase = resolveThreadSyncPhase({
119 detailExists: serverThreadDetail !== null,
120 shellExists: serverThreadShell !== null,
121 status: serverThreadStatus,
122 });
123 const serverThreadStarted = threadHasStarted(serverThreadDetail);
124 const environmentHasAnyThreads = environmentThreadRefs.length > 0 || environmentHasDraftThreads;
125
126 useEffect(() => {
127 if (!inferredThreadRef || draftSession?.promotedTo) {
128 return;
129 }
130 markPromotedDraftThreadByRef(inferredThreadRef);
131 }, [draftSession?.promotedTo, inferredThreadRef]);
132
133 useEffect(() => {
134 if (!canonicalThreadRef) {
135 return;
136 }
137 let cancelled = false;
138 void waitForDraftHeroTransition().then(() => {
139 if (cancelled) {
140 return;
141 }
142 void navigate({
143 to: "/$environmentId/$threadId",
144 params: buildThreadRouteParams(canonicalThreadRef),
145 replace: true,
146 });
147 });
148 return () => {
149 cancelled = true;
150 };
151 }, [canonicalThreadRef, navigate]);
152
153 useEffect(() => {
154 if (target.kind !== "draft" || draftSession || canonicalThreadRef) {
155 return;
156 }
157 void navigate({ to: "/", replace: true });
158 }, [canonicalThreadRef, draftSession, navigate, target.kind]);
159
160 useEffect(() => {
161 if (target.kind !== "server" || !bootstrapComplete) {
162 return;
163 }
164 // Navigation already resolved onto this path, so a drop aimed here
165 // passed its landing check; once the thread reads as missing it can
166 // never be attached, release it even when there is nowhere to redirect.
167 if (renderState === "missing") {
168 const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState();
169 clearPendingFileDropsForThread(target.threadRef);
170 if (environmentHasAnyThreads) {
171 void navigate({ to: "/", replace: true });
172 }
173 }
174 }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]);
175
176 useEffect(() => {
177 if (target.kind !== "server" || !serverThreadStarted || !draftThread) {
178 return;
179 }
180 finalizePromotedDraftThreadByRef(target.threadRef);
181 }, [draftThread, serverThreadStarted, target]);
182
183 let view: React.ReactNode = null;
184 if (target.kind === "draft") {
185 if (draftSession) {
186 view = (
187 <ChatView
188 key={target.draftId}
189 draftId={target.draftId}
190 environmentId={draftSession.environmentId}
191 threadId={draftSession.threadId}
192 routeKind="draft"
193 forceExpandedMobileComposer
194 />
195 );
196 }
197 } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) {
198 view = (
199 <ChatView
200 {...(nextChatViewKey ? { key: nextChatViewKey.key } : {})}
201 environmentId={target.threadRef.environmentId}
202 threadId={target.threadRef.threadId}
203 routeKind="server"
204 threadSyncPhase={threadSyncPhase}
205 />
206 );
207 }
208
209 return (
210 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
211 {view}
212 </SidebarInset>
213 );
214}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+84-29
...@@ -1092,19 +1092,20 @@ describe("resolveAssistantMessageCopyState", () => {...@@ -1092,19 +1092,20 @@ describe("resolveAssistantMessageCopyState", () => {
1092});1092});
10931093
1094describe("deriveMessagesTimelineRows", () => {1094describe("deriveMessagesTimelineRows", () => {
1095 const queuedMessage = (id: string, prompt: string) => ({
1096 id,
1097 prompt,
1098 images: [],
1099 files: [],
1100 terminalContexts: [],
1101 previewAnnotations: [],
1102 reviewComments: [],
1103 submissionIntent: "foreground" as const,
1104 queuedAfterToolActivityId: null,
1105 createdAt: "2026-01-01T00:00:01Z",
1106 });
1107
1095 it("appends queued messages after the live rows, marking the oldest as next", () => {1108 it("appends queued messages after the live rows, marking the oldest as next", () => {
1096 const queuedMessage = (id: string, prompt: string) => ({
1097 id,
1098 prompt,
1099 images: [],
1100 files: [],
1101 terminalContexts: [],
1102 previewAnnotations: [],
1103 reviewComments: [],
1104 submissionIntent: "foreground" as const,
1105 queuedAfterToolActivityId: null,
1106 createdAt: "2026-01-01T00:00:01Z",
1107 });
1108 const rows = deriveMessagesTimelineRows({1109 const rows = deriveMessagesTimelineRows({
1109 timelineEntries: [],1110 timelineEntries: [],
1110 isWorking: true,1111 isWorking: true,
...@@ -1126,7 +1127,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1126,7 +1127,7 @@ describe("deriveMessagesTimelineRows", () => {
1126 ]);1127 ]);
1127 });1128 });
11281129
1129 it("shows the worktree setup card instead of the working placeholder", () => {1130 it("leads the worktree setup card with the working header", () => {
1130 const snapshot: WorktreeSetupSnapshot = {1131 const snapshot: WorktreeSetupSnapshot = {
1131 threadId: ThreadId.make("thread-setup"),1132 threadId: ThreadId.make("thread-setup"),
1132 phase: "running",1133 phase: "running",
...@@ -1177,6 +1178,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1177,6 +1178,7 @@ describe("deriveMessagesTimelineRows", () => {
1177 worktreeSetup: snapshot,1178 worktreeSetup: snapshot,
1178 });1179 });
1179 expect(withoutMessages).toEqual([1180 expect(withoutMessages).toEqual([
1181 { kind: "working", id: "working-indicator-row", createdAt: "2026-01-01T00:00:00Z" },
1180 {1182 {
1181 kind: "worktree-setup",1183 kind: "worktree-setup",
1182 id: WORKTREE_SETUP_ROW_ID,1184 id: WORKTREE_SETUP_ROW_ID,
...@@ -1186,7 +1188,25 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1186,7 +1188,25 @@ describe("deriveMessagesTimelineRows", () => {
1186 },1188 },
1187 ]);1189 ]);
11881190
1189 // A failed setup never handed off, so the card stays under the send.1191 // The main pass already places the working header after the send while a
1192 // bootstrap counts as working; the card slots under that one header.
1193 const withUserMessage = deriveMessagesTimelineRows({
1194 timelineEntries: [userEntry],
1195 isWorking: true,
1196 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1197 turnDiffSummaries: [],
1198 supportsConversationRollback: false,
1199 worktreeSetup: snapshot,
1200 });
1201 expect(withUserMessage.map((row) => row.kind)).toEqual([
1202 "message",
1203 "working",
1204 "worktree-setup",
1205 ]);
1206
1207 // A failed setup never handed off, so the card stays under the send. The
1208 // rest of the timeline is untouched: a running send still gets its
1209 // working and thinking rows, and queued follow-ups still trail.
1190 const withMessages = deriveMessagesTimelineRows({1210 const withMessages = deriveMessagesTimelineRows({
1191 timelineEntries: [userEntry, assistantEntry],1211 timelineEntries: [userEntry, assistantEntry],
1192 isWorking: true,1212 isWorking: true,
...@@ -1194,16 +1214,34 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1194,16 +1214,34 @@ describe("deriveMessagesTimelineRows", () => {
1194 turnDiffSummaries: [],1214 turnDiffSummaries: [],
1195 supportsConversationRollback: false,1215 supportsConversationRollback: false,
1196 worktreeSetup: { ...snapshot, phase: "failed" },1216 worktreeSetup: { ...snapshot, phase: "failed" },
1217 queuedMessages: [queuedMessage("q1", "later")],
1197 });1218 });
1198 expect(withMessages.map((row) => row.kind)).toEqual([1219 expect(withMessages.map((row) => row.kind)).toEqual([
1199 "message",1220 "message",
1200 "worktree-setup",1221 "worktree-setup",
1201 "working",1222 "working",
1202 "message",1223 "message",
1224 "thinking",
1225 "queued-message",
1226 ]);
1227 const runningWithQueue = deriveMessagesTimelineRows({
1228 timelineEntries: [userEntry],
1229 isWorking: true,
1230 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1231 turnDiffSummaries: [],
1232 supportsConversationRollback: false,
1233 worktreeSetup: snapshot,
1234 queuedMessages: [queuedMessage("q1", "later")],
1235 });
1236 expect(runningWithQueue.map((row) => row.kind)).toEqual([
1237 "message",
1238 "working",
1239 "worktree-setup",
1240 "queued-message",
1203 ]);1241 ]);
12041242
1205 // Once the agent stage is done the setup script may still be running in1243 // Once the agent stage is done and the turn is live, a still-running
1206 // the background: the turn owns the header and the script row follows it.1244 // script leaves the timeline; the working header surfaces it instead.
1207 const stage = (id: "agent" | "setup-script", status: "done" | "running") =>1245 const stage = (id: "agent" | "setup-script", status: "done" | "running") =>
1208 ({1246 ({
1209 id,1247 id,
...@@ -1233,13 +1271,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1233,13 +1271,7 @@ describe("deriveMessagesTimelineRows", () => {
1233 supportsConversationRollback: false,1271 supportsConversationRollback: false,
1234 worktreeSetup: asyncSnapshot,1272 worktreeSetup: asyncSnapshot,
1235 });1273 });
1236 expect(asyncRows.map((row) => row.kind)).toEqual([1274 expect(asyncRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]);
1237 "message",
1238 "working",
1239 "worktree-setup",
1240 "thinking",
1241 ]);
1242 expect(asyncRows[2]).toMatchObject({ kind: "worktree-setup", embedded: true });
12431275
1244 // Dispatched but not yet visible as a turn: the full card stays put so1276 // Dispatched but not yet visible as a turn: the full card stays put so
1245 // nothing collapses during the handoff.1277 // nothing collapses during the handoff.
...@@ -1251,11 +1283,26 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1251,11 +1283,26 @@ describe("deriveMessagesTimelineRows", () => {
1251 supportsConversationRollback: false,1283 supportsConversationRollback: false,
1252 worktreeSetup: asyncSnapshot,1284 worktreeSetup: asyncSnapshot,
1253 });1285 });
1254 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "worktree-setup"]);1286 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);
1255 expect(handoffRows[1]).toMatchObject({ kind: "worktree-setup", embedded: false });1287 expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false });
12561288
1257 // A script that already finished has nothing left to show once the turn is live.1289 // A script that outlives the reply never trails the assistant's message.
1258 const finishedRows = deriveMessagesTimelineRows({1290 const outlivedRows = deriveMessagesTimelineRows({
1291 timelineEntries: [
1292 userEntry,
1293 { ...assistantEntry, message: { ...assistantEntry.message, streaming: false } },
1294 ],
1295 latestTurn: { ...liveTurn, state: "completed", completedAt: "2026-01-01T00:00:40Z" },
1296 isWorking: false,
1297 activeTurnStartedAt: null,
1298 turnDiffSummaries: [],
1299 supportsConversationRollback: false,
1300 worktreeSetup: asyncSnapshot,
1301 });
1302 expect(outlivedRows.map((row) => row.kind)).toEqual(["message", "message"]);
1303
1304 // A failed script after the handoff keeps its row under the send.
1305 const failedRows = deriveMessagesTimelineRows({
1259 timelineEntries: [userEntry],1306 timelineEntries: [userEntry],
1260 latestTurn: liveTurn,1307 latestTurn: liveTurn,
1261 isWorking: true,1308 isWorking: true,
...@@ -1264,10 +1311,18 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1264,10 +1311,18 @@ describe("deriveMessagesTimelineRows", () => {
1264 supportsConversationRollback: false,1311 supportsConversationRollback: false,
1265 worktreeSetup: {1312 worktreeSetup: {
1266 ...asyncSnapshot,1313 ...asyncSnapshot,
1267 stages: [stage("setup-script", "done"), stage("agent", "done")],1314 phase: "done",
1315 endedAt: "2026-01-01T00:00:20Z",
1316 stages: [{ ...stage("setup-script", "done"), status: "failed" }, stage("agent", "done")],
1268 },1317 },
1269 });1318 });
1270 expect(finishedRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]);1319 expect(failedRows.map((row) => row.kind)).toEqual([
1320 "message",
1321 "worktree-setup",
1322 "working",
1323 "thinking",
1324 ]);
1325 expect(failedRows[1]).toMatchObject({ kind: "worktree-setup", embedded: true });
1271 });1326 });
12721327
1273 it("keeps context compaction visible outside folded work", () => {1328 it("keeps context compaction visible outside folded work", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+42-42
...@@ -401,7 +401,11 @@ export type MessagesTimelineRow =...@@ -401,7 +401,11 @@ export type MessagesTimelineRow =
401 id: string;401 id: string;
402 createdAt: string | null;402 createdAt: string | null;
403 snapshot: WorktreeSetupSnapshot;403 snapshot: WorktreeSetupSnapshot;
404 /** The agent already started; render only the script row under the turn header. */404 /**
405 * The agent's turn is live and owns the "Working for" header, so the
406 * card drops its own header and settle-time actions. The stage list
407 * stays put so nothing jumps when the handoff happens.
408 */
405 embedded: boolean;409 embedded: boolean;
406 }410 }
407 | {411 | {
...@@ -1273,67 +1277,63 @@ export function deriveMessagesTimelineRows(input: {...@@ -1273,67 +1277,63 @@ export function deriveMessagesTimelineRows(input: {
1273 });1277 });
1274 }1278 }
12751279
1276 // Until the agent's turn is live, the setup card takes the place of the1280 // Until the agent's turn is live, the setup card sits under the send with
1277 // working and thinking placeholders. It stays after a failed or cancelled1281 // the working header above it (the header reads "Setting up worktree…" and
1278 // setup so the outcome and its actions remain visible until the thread1282 // later swaps its text in place, so nothing moves at the handoff). "Live"
1279 // state moves on. "Live" means the turn is in the timeline, not just that1283 // means the turn is in the timeline, not just that the server dispatched
1280 // the server dispatched it: the card must not collapse in the gap between.1284 // it: the card must not vanish in the gap between. Once the turn is live
1285 // the stage list leaves the timeline; a script that is still running is
1286 // surfaced by the working header itself. A failed or cancelled setup stays
1287 // under the send so its outcome and actions remain reachable.
1281 const setupHandedOff =1288 const setupHandedOff =
1282 input.worktreeSetup !== null &&1289 input.worktreeSetup !== null &&
1283 input.worktreeSetup !== undefined &&1290 input.worktreeSetup !== undefined &&
1284 worktreeSetupAgentStarted(input.worktreeSetup) &&1291 worktreeSetupAgentStarted(input.worktreeSetup) &&
1285 input.latestTurn?.startedAt != null;1292 input.latestTurn?.startedAt != null;
1286 if (input.worktreeSetup && !setupHandedOff) {1293 const setupRunning = !setupHandedOff && input.worktreeSetup?.phase === "running";
1294 if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) {
1287 const setupRow = {1295 const setupRow = {
1288 kind: "worktree-setup",1296 kind: "worktree-setup",
1289 id: WORKTREE_SETUP_ROW_ID,1297 id: WORKTREE_SETUP_ROW_ID,
1290 createdAt: input.worktreeSetup.startedAt,1298 createdAt: input.worktreeSetup.startedAt,
1291 snapshot: input.worktreeSetup,1299 snapshot: input.worktreeSetup,
1292 embedded: false,1300 embedded: setupHandedOff,
1293 } as const;1301 } as const;
1294 // Sit directly under the first user message: a finished snapshot can
1295 // outlive the first assistant reply, and it belongs to the send, not the
1296 // end of the thread.
1297 const firstUserRowIndex = nextRows.findIndex(1302 const firstUserRowIndex = nextRows.findIndex(
1298 (row) => row.kind === "message" && row.message.role === "user",1303 (row) => row.kind === "message" && row.message.role === "user",
1299 );1304 );
1300 if (firstUserRowIndex >= 0) {1305 // While the setup runs, the working header leads the card in the same
1301 nextRows.splice(firstUserRowIndex + 1, 0, setupRow);1306 // slot it keeps once the agent's own turn takes over. The main pass may
1307 // already have placed that header (a bootstrap counts as working).
1308 const workingRowIndex = setupRunning ? nextRows.findIndex((row) => row.kind === "working") : -1;
1309 if (workingRowIndex >= 0) {
1310 nextRows.splice(workingRowIndex + 1, 0, setupRow);
1302 } else {1311 } else {
1303 nextRows.push(setupRow);1312 const insertAt = firstUserRowIndex >= 0 ? firstUserRowIndex + 1 : nextRows.length;
1313 nextRows.splice(
1314 insertAt,
1315 0,
1316 ...(setupRunning
1317 ? [
1318 {
1319 kind: "working",
1320 id: "working-indicator-row",
1321 createdAt: input.worktreeSetup.startedAt,
1322 } as const,
1323 setupRow,
1324 ]
1325 : [setupRow]),
1326 );
1304 }1327 }
1305 return attachTrailingToolGroupsToAssistant(nextRows);
1306 }1328 }
13071329
1308 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {1330 // A running setup owns the working slot above its card and shows no
1331 // activity row of its own; every other state gets the usual tail.
1332 const hasWorkingRow = nextRows.some((row) => row.kind === "working");
1333 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) {
1309 appendWorkingRow();1334 appendWorkingRow();
1310 }1335 }
1311 // An async setup script outlives the handoff. The turn owns the header, so1336 if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) {
1312 // the script's row sits first under it, ahead of the agent's own work. A
1313 // script that already finished (or never ran) has nothing left to show.
1314 const setupScriptStage = input.worktreeSetup?.stages.find((stage) => stage.id === "setup-script");
1315 if (
1316 input.worktreeSetup &&
1317 setupHandedOff &&
1318 (setupScriptStage?.status === "running" || setupScriptStage?.status === "failed")
1319 ) {
1320 const setupRow = {
1321 kind: "worktree-setup",
1322 id: WORKTREE_SETUP_ROW_ID,
1323 createdAt: input.worktreeSetup.startedAt,
1324 snapshot: input.worktreeSetup,
1325 embedded: true,
1326 } as const;
1327 const workingRowIndex = nextRows.findIndex((row) => row.kind === "working");
1328 if (workingRowIndex >= 0) {
1329 nextRows.splice(workingRowIndex + 1, 0, setupRow);
1330 } else {
1331 // The turn already finished (or has not been dispatched yet): the row
1332 // trails the reply so a still-running script stays visible after it.
1333 nextRows.push(setupRow);
1334 }
1335 }
1336 if (input.isWorking && (!hasActivityRow || latestToolFailed)) {
1337 nextRows.push({1337 nextRows.push({
1338 kind: "thinking",1338 kind: "thinking",
1339 id: LIVE_ACTIVITY_ROW_ID,1339 id: LIVE_ACTIVITY_ROW_ID,
apps/web/src/components/chat/MessagesTimeline.tsx+90-25
...@@ -185,6 +185,7 @@ import {...@@ -185,6 +185,7 @@ import {
185 toolGroupAction,185 toolGroupAction,
186 workEntryDisplayLabel,186 workEntryDisplayLabel,
187 workEntryIsVisibleInGroup,187 workEntryIsVisibleInGroup,
188 worktreeSetupAgentStarted,
188 type StableMessagesTimelineRowsState,189 type StableMessagesTimelineRowsState,
189 type MessagesTimelineRow,190 type MessagesTimelineRow,
190 TIMELINE_MINIMAP_MIN_ITEMS,191 TIMELINE_MINIMAP_MIN_ITEMS,
...@@ -192,6 +193,8 @@ import {...@@ -192,6 +193,8 @@ import {
192 type WorkGroupScrollAnchor,193 type WorkGroupScrollAnchor,
193} from "./MessagesTimeline.logic";194} from "./MessagesTimeline.logic";
194import { TerminalContextInlineChip } from "./TerminalContextInlineChip";195import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
196import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
197import { Spinner } from "../ui/spinner";
195import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";198import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
196import { WorktreeSetupCard } from "./WorktreeSetupCard";199import { WorktreeSetupCard } from "./WorktreeSetupCard";
197import {200import {
...@@ -295,6 +298,12 @@ interface TimelineRowActivityState {...@@ -295,6 +298,12 @@ interface TimelineRowActivityState {
295 isCompacting: boolean;298 isCompacting: boolean;
296 isRevertingCheckpoint: boolean;299 isRevertingCheckpoint: boolean;
297 latestTurnId: TurnId | null;300 latestTurnId: TurnId | null;
301 /**
302 * A worktree setup whose script is still running after the agent took
303 * over. The working header shows it as a chip with a popover; the stage
304 * list itself has already left the timeline.
305 */
306 backgroundWorktreeSetup: WorktreeSetupSnapshot | null;
298}307}
299308
300const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);309const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
...@@ -980,6 +989,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -980,6 +989,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({
980 onRemoveQueuedMessage,989 onRemoveQueuedMessage,
981 ],990 ],
982 );991 );
992 const backgroundWorktreeSetup =
993 worktreeSetup !== null &&
994 worktreeSetup.phase === "running" &&
995 worktreeSetupAgentStarted(worktreeSetup) &&
996 latestTurn?.startedAt != null
997 ? worktreeSetup
998 : null;
983 const activityState = useMemo<TimelineRowActivityState>(999 const activityState = useMemo<TimelineRowActivityState>(
984 () => ({1000 () => ({
985 isWorking,1001 isWorking,
...@@ -987,8 +1003,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -987,8 +1003,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
987 isCompacting,1003 isCompacting,
988 isRevertingCheckpoint,1004 isRevertingCheckpoint,
989 latestTurnId: latestTurn?.turnId ?? null,1005 latestTurnId: latestTurn?.turnId ?? null,
1006 backgroundWorktreeSetup,
990 }),1007 }),
991 [isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],1008 [
1009 backgroundWorktreeSetup,
1010 isCompacting,
1011 isRevertingCheckpoint,
1012 isWorking,
1013 isPreparingWorktree,
1014 latestTurn?.turnId,
1015 ],
992 );1016 );
9931017
994 // Stable renderItem — no closure deps. Row components read shared state1018 // Stable renderItem — no closure deps. Row components read shared state
...@@ -2210,40 +2234,81 @@ function ProposedPlanTimelineRow({...@@ -2210,40 +2234,81 @@ function ProposedPlanTimelineRow({
2210}2234}
22112235
2212function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {2236function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
2213 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);2237 const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup } =
2238 use(TimelineRowActivityCtx);
2239 // One span for every label so the setup-to-working handoff swaps text in
2240 // place instead of remounting the row.
2241 const shimmer = isPreparingWorktree || isCompacting;
2242 const label = isPreparingWorktree ? (
2243 "Setting up worktree…"
2244 ) : isCompacting ? (
2245 <CompactingLabel />
2246 ) : row.createdAt ? (
2247 <>
2248 Working for <WorkingTimer createdAt={row.createdAt} />
2249 </>
2250 ) : (
2251 "Working..."
2252 );
2214 return (2253 return (
2215 <div className="border-b border-border/60 pb-2 pt-1">2254 <div className="border-b border-border/60 pb-2 pt-1">
2216 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">2255 <div className="flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
2217 <span2256 <span
2218 key={isPreparingWorktree ? "setup" : isCompacting ? "compacting" : "working"}2257 ref={shimmer ? observeVisibleAnimation : undefined}
2219 ref={isPreparingWorktree || isCompacting ? observeVisibleAnimation : undefined}2258 className="relative shrink-0 overflow-hidden whitespace-nowrap"
2220 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
2221 >2259 >
2222 {isPreparingWorktree ? (2260 {label}
2223 <>2261 {shimmer ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
2224 Setting up worktree…
2225 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
2226 </>
2227 ) : isCompacting ? (
2228 <>
2229 <CompactingLabel />
2230 <ActivityShimmerOverlay>
2231 <CompactingLabel />
2232 </ActivityShimmerOverlay>
2233 </>
2234 ) : row.createdAt ? (
2235 <>
2236 Working for <WorkingTimer createdAt={row.createdAt} />
2237 </>
2238 ) : (
2239 "Working..."
2240 )}
2241 </span>2262 </span>
2263 {backgroundWorktreeSetup ? (
2264 <BackgroundWorktreeSetupChip snapshot={backgroundWorktreeSetup} />
2265 ) : null}
2242 </div>2266 </div>
2243 </div>2267 </div>
2244 );2268 );
2245}2269}
22462270
2271/**
2272 * Trailing chip in the working header while a setup script still runs after
2273 * the agent started. Opens the stage list and live output in a popover; the
2274 * chip leaves with the script, so nothing lingers in the timeline.
2275 */
2276function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
2277 const ctx = use(TimelineRowCtx);
2278 const terminalId = snapshot.setupScript?.terminalId ?? null;
2279 const openTerminal = ctx.onOpenWorktreeSetupTerminal;
2280 const onOpenTerminal = useMemo(
2281 () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null),
2282 [openTerminal, terminalId],
2283 );
2284 const scriptName = snapshot.setupScript?.name ?? "Setup script";
2285 return (
2286 <Popover>
2287 <PopoverTrigger
2288 render={
2289 <Button
2290 variant="chip"
2291 className="ml-auto inline-flex h-5 min-w-0 shrink-0 items-center gap-1 rounded-full border border-border/70 px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
2292 aria-label={`${scriptName} is still running. Show setup progress.`}
2293 />
2294 }
2295 >
2296 <Spinner className="size-3 shrink-0" />
2297 <span className="truncate">{scriptName}</span>
2298 </PopoverTrigger>
2299 <PopoverPopup side="bottom" align="end" className="w-[32rem] max-w-[calc(100vw-2rem)] p-3">
2300 <WorktreeSetupCard
2301 snapshot={snapshot}
2302 embedded
2303 onCancel={null}
2304 onWorkLocally={null}
2305 onOpenTerminal={onOpenTerminal}
2306 />
2307 </PopoverPopup>
2308 </Popover>
2309 );
2310}
2311
2247function ThinkingTimelineRow() {2312function ThinkingTimelineRow() {
2248 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);2313 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
2249 // Reserve the activity row during setup so the handoff keeps the same height.2314 // Reserve the activity row during setup so the handoff keeps the same height.
apps/web/src/components/chat/WorktreeSetupCard.tsx+94-20
...@@ -222,18 +222,34 @@ function StageRow({...@@ -222,18 +222,34 @@ function StageRow({
222 );222 );
223}223}
224224
225/** The server keeps this many trailing lines; the box is sized for exactly that. */
226const OUTPUT_TAIL_LINES = 4;
227const OUTPUT_TAIL_SLOTS = Array.from({ length: OUTPUT_TAIL_LINES }, (_, slot) => slot);
228
229/**
230 * Fixed-height window onto the script's last lines. Rows never wrap and the
231 * box never grows or shrinks, so streaming output cannot push the timeline
232 * around while the script runs.
233 */
225function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: boolean }) {234function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: boolean }) {
226 if (lines.length === 0) return null;235 const rows = OUTPUT_TAIL_SLOTS.map((slot) => ({
236 slot,
237 line: lines[lines.length - OUTPUT_TAIL_LINES + slot] ?? "",
238 }));
227 return (239 return (
228 <pre240 <pre
229 className={cn(241 className={cn(
230 "mb-1 ml-8 rounded-md border px-2.5 py-1.5 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all select-text",242 "mb-1 ml-8 overflow-hidden rounded-md border px-2.5 py-1.5 font-mono text-[11px] leading-relaxed select-text",
231 failed243 failed
232 ? "border-destructive/20 bg-error-surface text-destructive-foreground"244 ? "border-destructive/20 bg-error-surface text-destructive-foreground"
233 : "border-border bg-code text-muted-foreground",245 : "border-border bg-code text-muted-foreground",
234 )}246 )}
235 >247 >
236 {lines.join("\n")}248 {rows.map(({ slot, line }) => (
249 <div key={slot} className="truncate whitespace-pre">
250 {line.length === 0 ? "\u00a0" : line}
251 </div>
252 ))}
237 </pre>253 </pre>
238 );254 );
239}255}
...@@ -269,6 +285,47 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {...@@ -269,6 +285,47 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
269 );285 );
270}286}
271287
288/**
289 * One-line summary of a settled setup under a live turn. A clean finish is
290 * removed from the timeline altogether, so this only renders the outcomes
291 * worth keeping: a failed script, a failed setup, or a cancelled one.
292 */
293function CollapsedSummaryRow({
294 snapshot,
295 totalElapsed,
296}: {
297 snapshot: WorktreeSetupSnapshot;
298 totalElapsed: number | null;
299}) {
300 const status: WorktreeSetupStage["status"] =
301 snapshot.phase === "failed" || snapshot.phase === "cancelled"
302 ? "failed"
303 : snapshot.stages.some((stage) => stage.id === "setup-script" && stage.status === "failed")
304 ? "failed"
305 : "done";
306 const label = headerLabel(snapshot);
307 return (
308 <div
309 className={cn(
310 "flex min-h-6 min-w-0 items-center gap-1.5 rounded-md px-0.5 py-0.5 text-sm leading-relaxed",
311 stageRowClassName(status),
312 )}
313 data-worktree-setup-stage="summary"
314 data-worktree-setup-status={status}
315 >
316 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
317 <StageIcon status={status} />
318 </span>
319 <span className="min-w-0 flex-1 truncate">{label}</span>
320 {totalElapsed !== null ? (
321 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
322 {formatDuration(totalElapsed)}
323 </span>
324 ) : null}
325 </div>
326 );
327}
328
272export function WorktreeSetupCard({329export function WorktreeSetupCard({
273 snapshot,330 snapshot,
274 onCancel,331 onCancel,
...@@ -277,8 +334,9 @@ export function WorktreeSetupCard({...@@ -277,8 +334,9 @@ export function WorktreeSetupCard({
277 embedded = false,334 embedded = false,
278}: WorktreeSetupCardProps & {335}: WorktreeSetupCardProps & {
279 /**336 /**
280 * The agent already started (async setup script), so the turn owns the337 * The agent's turn is live and owns the "Working for" header. The stage
281 * "Working for" header and only the script's row sits among the worklog.338 * list stays exactly where it was so the handoff never moves anything; a
339 * failed script that outlives the handoff collapses to a single row.
282 */340 */
283 embedded?: boolean;341 embedded?: boolean;
284}) {342}) {
...@@ -292,24 +350,40 @@ export function WorktreeSetupCard({...@@ -292,24 +350,40 @@ export function WorktreeSetupCard({
292 })();350 })();
293 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");351 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
294 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";352 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
295 const stages = embedded353 const collapsed = embedded && !running;
296 ? snapshot.stages.filter((stage) => stage.id === "setup-script")354 // While running, the timeline's working row above the card carries the
297 : snapshot.stages;355 // "Setting up worktree…" label (and keeps that slot when the agent takes
356 // over). The card only brings its own header for a settled outcome that
357 // has no working row to sit under.
358 const showHeader = !embedded && !running;
359 // The tail box is part of the script row's footprint while the script runs
360 // (and after it failed, so the last lines explain the failure). It mounts
361 // as soon as the script is running, empty lines and all, so the card takes
362 // its final height once instead of growing with each output line.
363 const showTail =
364 setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed");
298365
299 return (366 return (
300 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>367 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>
301 {embedded ? null : <SetupHeaderRow snapshot={snapshot} totalElapsed={totalElapsed} />}368 {showHeader ? <SetupHeaderRow snapshot={snapshot} totalElapsed={totalElapsed} /> : null}
302 <div className={embedded ? undefined : "pt-1.5"}>369 {collapsed ? (
303 {stages.map((stage) => (370 <CollapsedSummaryRow snapshot={snapshot} totalElapsed={totalElapsed} />
304 <div key={stage.id}>371 ) : (
305 <StageRow stage={stage} nowMs={nowMs} scriptName={snapshot.setupScript?.name ?? null} />372 <div className={showHeader ? "pt-1.5" : undefined}>
306 {stage.id === "setup-script" &&373 {snapshot.stages.map((stage) => (
307 (stage.status === "running" || stage.status === "failed") ? (374 <div key={stage.id}>
308 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />375 <StageRow
309 ) : null}376 stage={stage}
310 </div>377 nowMs={nowMs}
311 ))}378 scriptName={snapshot.setupScript?.name ?? null}
312 </div>379 />
380 {stage.id === "setup-script" && showTail ? (
381 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
382 ) : null}
383 </div>
384 ))}
385 </div>
386 )}
313387
314 {snapshot.phase === "failed" && snapshot.error ? (388 {snapshot.phase === "failed" && snapshot.error ? (
315 <p className="mt-1 ml-8 text-xs text-muted-foreground">{snapshot.error}</p>389 <p className="mt-1 ml-8 text-xs text-muted-foreground">{snapshot.error}</p>
apps/web/src/composerDraftStore.ts+13
...@@ -498,6 +498,8 @@ interface ComposerDraftStoreState {...@@ -498,6 +498,8 @@ interface ComposerDraftStoreState {
498 getDraftSession: (draftId: DraftId) => DraftSessionState | null;498 getDraftSession: (draftId: DraftId) => DraftSessionState | null;
499 /** Resolves a server-thread ref back to a matching draft session when one exists. */499 /** Resolves a server-thread ref back to a matching draft session when one exists. */
500 getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null;500 getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null;
501 /** The draft id that reserved a server-thread ref, while its draft record still exists. */
502 getDraftIdByRef: (threadRef: ScopedThreadRef) => DraftId | null;
501 getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null;503 getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null;
502 getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null;504 getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null;
503 listDraftThreadKeys: () => string[];505 listDraftThreadKeys: () => string[];
...@@ -2583,6 +2585,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2583,6 +2585,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2583 }2585 }
2584 return null;2586 return null;
2585 },2587 },
2588 getDraftIdByRef: (threadRef) => {
2589 for (const [draftId, draftSession] of Object.entries(get().draftThreadsByThreadKey)) {
2590 if (
2591 draftSession.environmentId === threadRef.environmentId &&
2592 draftSession.threadId === threadRef.threadId
2593 ) {
2594 return DraftId.make(draftId);
2595 }
2596 }
2597 return null;
2598 },
2586 getDraftThread: (threadRef) => {2599 getDraftThread: (threadRef) => {
2587 if (typeof threadRef === "string") {2600 if (typeof threadRef === "string") {
2588 return get().getDraftSession(DraftId.make(threadRef));2601 return get().getDraftSession(DraftId.make(threadRef));
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+4-102
...@@ -1,105 +1,7 @@...@@ -1,105 +1,7 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useEffect } from "react";
3
4import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";
6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
7import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
8import { resolveThreadSyncPhase } from "../threadSync";
9import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
10import { SidebarInset } from "~/components/ui/sidebar";
11import {
12 useEnvironmentThreadRefs,
13 useThreadDetail,
14 useThreadShell,
15 useThreadStatus,
16} from "../state/entities";
17import { useEnvironmentQuery } from "../state/query";
18import { environmentShell } from "../state/shell";
19
20function ChatThreadRouteView() {
21 const navigate = useNavigate();
22 const threadRef = Route.useParams({
23 select: (params) => resolveThreadRouteRef(params),
24 });
25 const shell = useEnvironmentQuery(
26 threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId),
27 );
28 const serverThreadShell = useThreadShell(threadRef);
29 const serverThreadDetail = useThreadDetail(threadRef);
30 const serverThreadStatus = useThreadStatus(threadRef);
31 const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null);
32 const bootstrapComplete = shell.data?.snapshot._tag === "Some";
33 const environmentHasServerThreads = environmentThreadRefs.length > 0;
34 const draftThreadExists = useComposerDraftStore((store) =>
35 threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,
36 );
37 const draftThread = useComposerDraftStore((store) =>
38 threadRef ? store.getDraftThreadByRef(threadRef) : null,
39 );
40 const environmentHasDraftThreads = useComposerDraftStore((store) => {
41 if (!threadRef) {
42 return false;
43 }
44 return store.hasDraftThreadsInEnvironment(threadRef.environmentId);
45 });
46 const renderState = resolveThreadRouteRenderState({
47 bootstrapComplete,
48 serverThreadShellExists: serverThreadShell !== null,
49 serverThreadDetailExists: serverThreadDetail !== null,
50 serverThreadDetailDeleted: serverThreadStatus === "deleted",
51 draftThreadExists,
52 });
53 const threadSyncPhase = resolveThreadSyncPhase({
54 detailExists: serverThreadDetail !== null,
55 shellExists: serverThreadShell !== null,
56 status: serverThreadStatus,
57 });
58 const serverThreadStarted = threadHasStarted(serverThreadDetail);
59 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
60
61 useEffect(() => {
62 if (!threadRef || !bootstrapComplete) {
63 return;
64 }
65
66 // Navigation already resolved onto this path, so a drop aimed here
67 // passed its landing check; once the thread reads as missing it can
68 // never be attached, release it even when there is nowhere to redirect.
69 if (renderState === "missing") {
70 const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState();
71 clearPendingFileDropsForThread(threadRef);
72 if (environmentHasAnyThreads) {
73 void navigate({ to: "/", replace: true });
74 }
75 }
76 }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]);
77
78 useEffect(() => {
79 if (!threadRef || !serverThreadStarted || !draftThread) {
80 return;
81 }
82 finalizePromotedDraftThreadByRef(threadRef);
83 }, [draftThread, serverThreadStarted, threadRef]);
84
85 if (!threadRef) {
86 return null;
87 }
88
89 return (
90 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
91 {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? (
92 <ChatView
93 environmentId={threadRef.environmentId}
94 threadId={threadRef.threadId}
95 routeKind="server"
96 threadSyncPhase={threadSyncPhase}
97 />
98 ) : null}
99 </SidebarInset>
100 );
101}
1022
3// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's
4// promotion onto this route keeps the same ChatView mounted.
103export const Route = createFileRoute("/_chat/$environmentId/$threadId")({5export const Route = createFileRoute("/_chat/$environmentId/$threadId")({
104 component: ChatThreadRouteView,6 component: () => null,
105});7});
apps/web/src/routes/_chat.draft.$draftId.tsx+4-91
...@@ -1,94 +1,7 @@...@@ -1,94 +1,7 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useEffect } from "react";
3import ChatView from "../components/ChatView";
4import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic";
5import {
6 DraftId,
7 markPromotedDraftThreadByRef,
8 useBackgroundDraftSubmissionPending,
9 useComposerDraftStore,
10} from "../composerDraftStore";
11import { SidebarInset } from "../components/ui/sidebar";
12import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition";
13import { buildThreadRouteParams } from "../threadRoutes";
14import { useThread, useThreadRefs } from "../state/entities";
15
16function DraftChatThreadRouteView() {
17 const navigate = useNavigate();
18 const { draftId: rawDraftId } = Route.useParams();
19 const draftId = DraftId.make(rawDraftId);
20 const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId));
21 const threadRefs = useThreadRefs();
22 const inferredThreadRef = draftSession
23 ? (threadRefs.find(
24 (ref) =>
25 ref.environmentId === draftSession.environmentId &&
26 ref.threadId === draftSession.threadId,
27 ) ?? null)
28 : null;
29 const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef;
30 const serverThread = useThread(serverThreadRef);
31 const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef);
32 const canonicalThreadRef = resolveDraftPromotionNavigationTarget({
33 serverThreadRef,
34 serverThread,
35 backgroundSubmissionPending,
36 });
37
38 useEffect(() => {
39 if (!inferredThreadRef || draftSession?.promotedTo) {
40 return;
41 }
42 markPromotedDraftThreadByRef(inferredThreadRef);
43 }, [draftSession?.promotedTo, inferredThreadRef]);
44
45 useEffect(() => {
46 if (!canonicalThreadRef) {
47 return;
48 }
49
50 let cancelled = false;
51 void waitForDraftHeroTransition().then(() => {
52 if (cancelled) {
53 return;
54 }
55 void navigate({
56 to: "/$environmentId/$threadId",
57 params: buildThreadRouteParams(canonicalThreadRef),
58 replace: true,
59 });
60 });
61
62 return () => {
63 cancelled = true;
64 };
65 }, [canonicalThreadRef, navigate]);
66
67 useEffect(() => {
68 if (draftSession || canonicalThreadRef) {
69 return;
70 }
71 void navigate({ to: "/", replace: true });
72 }, [canonicalThreadRef, draftSession, navigate]);
73
74 if (!draftSession) {
75 return null;
76 }
77
78 return (
79 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
80 <ChatView
81 key={draftId}
82 draftId={draftId}
83 environmentId={draftSession.environmentId}
84 threadId={draftSession.threadId}
85 routeKind="draft"
86 forceExpandedMobileComposer
87 />
88 </SidebarInset>
89 );
90}
912
3// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's
4// promotion to `/$environmentId/$threadId` keeps the same ChatView mounted.
92export const Route = createFileRoute("/_chat/draft/$draftId")({5export const Route = createFileRoute("/_chat/draft/$draftId")({
93 component: DraftChatThreadRouteView,6 component: () => null,
94});7});
apps/web/src/routes/_chat.tsx+10-2
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";1import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { useEffect, useMemo } from "react";3import { useEffect, useMemo } from "react";
44
5import { isCommandPaletteOpen } from "../commandPaletteBus";5import { isCommandPaletteOpen } from "../commandPaletteBus";
6import { ThreadRouteView } from "../components/ThreadRouteView";
7import { resolveThreadRouteTarget } from "../threadRoutes";
6import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";8import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
7import { openCommandPalette } from "../commandPaletteBus";9import { openCommandPalette } from "../commandPaletteBus";
8import { useProjects } from "../state/entities";10import { useProjects } from "../state/entities";
...@@ -175,10 +177,16 @@ function ChatRouteGlobalShortcuts() {...@@ -175,10 +177,16 @@ function ChatRouteGlobalShortcuts() {
175}177}
176178
177function ChatRouteLayout() {179function ChatRouteLayout() {
180 // Both thread routes render here, not in their own leaf components, so the
181 // draft-to-thread promotion keeps one ChatView mounted across the swap.
182 const threadTarget = useParams({
183 strict: false,
184 select: (params) => resolveThreadRouteTarget(params),
185 });
178 return (186 return (
179 <>187 <>
180 <ChatRouteGlobalShortcuts />188 <ChatRouteGlobalShortcuts />
181 <Outlet />189 {threadTarget ? <ThreadRouteView target={threadTarget} /> : <Outlet />}
182 </>190 </>
183 );191 );
184}192}