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", () => {
307307 // control sequences are stripped, and the echoed wrapper is hidden.
308308 yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`);
309309 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");
311313 // A spoofed sentinel from the script itself must not settle completion.
312314 yield* emit("__T3_SETUP_DONE__:0\r\n");
313315 yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`);
......@@ -317,6 +319,9 @@ describe("ProjectSetupScriptRunner", () => {
317319 expect(completion.exitCode).toBe(3);
318320 expect(seen).toEqual([
319321 "Resolving deps",
322 "Progress: 1/3",
323 "Progress: 2/3",
324 "Progress: 3/3",
320325 "Done in 2s",
321326 "__T3_SETUP_DONE__:0",
322327 `__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* () {
258258 }
259259 if (event.type === "output") {
260260 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/);
262268 lineBuffer = lines.pop() ?? "";
263269 // A script that never prints a newline must not grow this forever.
264270 // 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 {
1010 type ServerProvider,
1111 ThreadId,
1212 TurnId,
13 type WorktreeSetupSnapshot,
1314} from "@t3tools/contracts";
1415import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1516import { Atom, AsyncResult } from "effect/unstable/reactivity";
......@@ -2533,89 +2534,68 @@ describe("worktree setup visibility", () => {
25332534 expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull();
25342535 });
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 });
25372545 expect(
25382546 resolveVisibleWorktreeSetup({
25392547 live: base,
25402548 recorded: null,
25412549 turnStarted: false,
2542 isWorking: true,
2550 followUpSent: false,
25432551 }),
25442552 ).toEqual(base);
2545 expect(
2546 resolveVisibleWorktreeSetup({
2547 live: null,
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();
2553 expect(visible(settledDone, false)).toEqual(settledDone);
2554 expect(visible(settledDone, true)).toBeNull();
2555 expect(visible(null, true)).toBeNull();
25612556 });
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", () => {
25642559 const scriptFailed = {
25652560 ...settledDone,
25662561 stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")],
25672562 };
2568 expect(
2569 resolveVisibleWorktreeSetup({
2570 live: null,
2571 recorded: scriptFailed,
2572 turnStarted: true,
2573 isWorking: true,
2574 }),
2575 ).toEqual(scriptFailed);
2576 expect(
2563 const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) =>
25772564 resolveVisibleWorktreeSetup({
25782565 live: null,
2579 recorded: scriptFailed,
2566 recorded: snapshot,
25802567 turnStarted: true,
2581 isWorking: false,
2582 }),
2583 ).toBeNull();
2568 followUpSent,
2569 });
2570 expect(visible(scriptFailed)).toEqual(scriptFailed);
25842571 const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" };
2585 expect(
2586 resolveVisibleWorktreeSetup({
2587 live: null,
2588 recorded: failed,
2589 turnStarted: true,
2590 isWorking: false,
2591 }),
2592 ).toEqual(failed);
2572 expect(visible(failed)).toEqual(failed);
2573 const cancelled = { ...settledDone, phase: "cancelled" as const };
2574 expect(visible(cancelled)).toEqual(cancelled);
2575
2576 // The setup belongs to the first turn. A follow-up send retires every
2577 // settled outcome; only a script that is still running stays.
2578 expect(visible(scriptFailed, true)).toBeNull();
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);
25932587 });
25942588
25952589 it("prefers whichever snapshot is newer by sequence", () => {
2596 expect(
2597 resolveVisibleWorktreeSetup({
2598 live: { ...base, sequence: 3 },
2599 recorded: { ...settledDone, sequence: 7 },
2600 turnStarted: false,
2601 isWorking: false,
2602 }),
2603 ).toEqual({ ...settledDone, sequence: 7 });
2604 expect(
2605 resolveVisibleWorktreeSetup({
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);
2590 const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) =>
2591 resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false });
2592 expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({
2593 ...settledDone,
2594 sequence: 7,
2595 });
2596 expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({
2597 ...settledDone,
2598 sequence: 9,
2599 });
26202600 });
26212601});
apps/web/src/components/ChatView.logic.ts+13-6
......@@ -278,15 +278,22 @@ export function findRecordedWorktreeSetup(
278278/**
279279 * Which setup snapshot the timeline shows, if any. The live stream wins while
280280 * it has a newer sequence; the recorded activity covers everything else. A
281 * running setup always shows. Once settled, the card stays only while it
282 * still says something the turn does not: the turn has not started yet, or a
283 * stage failed and the turn is still running so the exit code stays reachable.
281 * running setup always shows. The setup belongs to the thread's first turn:
282 * once the user has sent a follow-up it is history and nothing about it is
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.
284290 */
285291export function resolveVisibleWorktreeSetup(input: {
286292 live: WorktreeSetupSnapshot | null;
287293 recorded: WorktreeSetupSnapshot | null;
288294 turnStarted: boolean;
289 isWorking: boolean;
295 /** The user sent a message after the one that created the worktree. */
296 followUpSent: boolean;
290297}): WorktreeSetupSnapshot | null {
291298 const snapshot =
292299 input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence)
......@@ -294,10 +301,10 @@ export function resolveVisibleWorktreeSetup(input: {
294301 : input.recorded;
295302 if (!snapshot) return null;
296303 if (snapshot.phase === "running") return snapshot;
304 if (input.followUpSent) return null;
297305 if (snapshot.phase !== "done") return snapshot;
298306 if (!input.turnStarted) return snapshot;
299 const stageFailed = snapshot.stages.some((stage) => stage.status === "failed");
300 return stageFailed && input.isWorking ? snapshot : null;
307 return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null;
301308}
302309
303310export function resolveDraftHeroState(input: {
apps/web/src/components/ChatView.tsx+3-1
......@@ -3529,7 +3529,9 @@ export default function ChatView(props: ChatViewProps) {
35293529 live: liveWorktreeSetup,
35303530 recorded: recordedWorktreeSetup,
35313531 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,
35333535 });
35343536 // Sends wait for the agent handoff, not for the setup script: an async
35353537 // 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", () => {
10921092});
10931093
10941094describe("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
10951108 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 });
11081109 const rows = deriveMessagesTimelineRows({
11091110 timelineEntries: [],
11101111 isWorking: true,
......@@ -1126,7 +1127,7 @@ describe("deriveMessagesTimelineRows", () => {
11261127 ]);
11271128 });
11281129
1129 it("shows the worktree setup card instead of the working placeholder", () => {
1130 it("leads the worktree setup card with the working header", () => {
11301131 const snapshot: WorktreeSetupSnapshot = {
11311132 threadId: ThreadId.make("thread-setup"),
11321133 phase: "running",
......@@ -1177,6 +1178,7 @@ describe("deriveMessagesTimelineRows", () => {
11771178 worktreeSetup: snapshot,
11781179 });
11791180 expect(withoutMessages).toEqual([
1181 { kind: "working", id: "working-indicator-row", createdAt: "2026-01-01T00:00:00Z" },
11801182 {
11811183 kind: "worktree-setup",
11821184 id: WORKTREE_SETUP_ROW_ID,
......@@ -1186,7 +1188,25 @@ describe("deriveMessagesTimelineRows", () => {
11861188 },
11871189 ]);
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.
11901210 const withMessages = deriveMessagesTimelineRows({
11911211 timelineEntries: [userEntry, assistantEntry],
11921212 isWorking: true,
......@@ -1194,16 +1214,34 @@ describe("deriveMessagesTimelineRows", () => {
11941214 turnDiffSummaries: [],
11951215 supportsConversationRollback: false,
11961216 worktreeSetup: { ...snapshot, phase: "failed" },
1217 queuedMessages: [queuedMessage("q1", "later")],
11971218 });
11981219 expect(withMessages.map((row) => row.kind)).toEqual([
11991220 "message",
12001221 "worktree-setup",
12011222 "working",
12021223 "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",
12031241 ]);
12041242
1205 // Once the agent stage is done the setup script may still be running in
1206 // the background: the turn owns the header and the script row follows it.
1243 // Once the agent stage is done and the turn is live, a still-running
1244 // script leaves the timeline; the working header surfaces it instead.
12071245 const stage = (id: "agent" | "setup-script", status: "done" | "running") =>
12081246 ({
12091247 id,
......@@ -1233,13 +1271,7 @@ describe("deriveMessagesTimelineRows", () => {
12331271 supportsConversationRollback: false,
12341272 worktreeSetup: asyncSnapshot,
12351273 });
1236 expect(asyncRows.map((row) => row.kind)).toEqual([
1237 "message",
1238 "working",
1239 "worktree-setup",
1240 "thinking",
1241 ]);
1242 expect(asyncRows[2]).toMatchObject({ kind: "worktree-setup", embedded: true });
1274 expect(asyncRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]);
12431275
12441276 // Dispatched but not yet visible as a turn: the full card stays put so
12451277 // nothing collapses during the handoff.
......@@ -1251,11 +1283,26 @@ describe("deriveMessagesTimelineRows", () => {
12511283 supportsConversationRollback: false,
12521284 worktreeSetup: asyncSnapshot,
12531285 });
1254 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "worktree-setup"]);
1255 expect(handoffRows[1]).toMatchObject({ kind: "worktree-setup", embedded: false });
1286 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);
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.
1258 const finishedRows = deriveMessagesTimelineRows({
1289 // A script that outlives the reply never trails the assistant's message.
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({
12591306 timelineEntries: [userEntry],
12601307 latestTurn: liveTurn,
12611308 isWorking: true,
......@@ -1264,10 +1311,18 @@ describe("deriveMessagesTimelineRows", () => {
12641311 supportsConversationRollback: false,
12651312 worktreeSetup: {
12661313 ...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")],
12681317 },
12691318 });
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 });
12711326 });
12721327
12731328 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 =
401401 id: string;
402402 createdAt: string | null;
403403 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 */
405409 embedded: boolean;
406410 }
407411 | {
......@@ -1273,67 +1277,63 @@ export function deriveMessagesTimelineRows(input: {
12731277 });
12741278 }
12751279
1276 // Until the agent's turn is live, the setup card takes the place of the
1277 // working and thinking placeholders. It stays after a failed or cancelled
1278 // setup so the outcome and its actions remain visible until the thread
1279 // state moves on. "Live" means the turn is in the timeline, not just that
1280 // the server dispatched it: the card must not collapse in the gap between.
1280 // Until the agent's turn is live, the setup card sits under the send with
1281 // the working header above it (the header reads "Setting up worktree…" and
1282 // later swaps its text in place, so nothing moves at the handoff). "Live"
1283 // means the turn is in the timeline, not just that the server dispatched
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.
12811288 const setupHandedOff =
12821289 input.worktreeSetup !== null &&
12831290 input.worktreeSetup !== undefined &&
12841291 worktreeSetupAgentStarted(input.worktreeSetup) &&
12851292 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")) {
12871295 const setupRow = {
12881296 kind: "worktree-setup",
12891297 id: WORKTREE_SETUP_ROW_ID,
12901298 createdAt: input.worktreeSetup.startedAt,
12911299 snapshot: input.worktreeSetup,
1292 embedded: false,
1300 embedded: setupHandedOff,
12931301 } 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.
12971302 const firstUserRowIndex = nextRows.findIndex(
12981303 (row) => row.kind === "message" && row.message.role === "user",
12991304 );
1300 if (firstUserRowIndex >= 0) {
1301 nextRows.splice(firstUserRowIndex + 1, 0, setupRow);
1305 // While the setup runs, the working header leads the card in the same
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);
13021311 } 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 );
13041327 }
1305 return attachTrailingToolGroupsToAssistant(nextRows);
13061328 }
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) {
13091334 appendWorkingRow();
13101335 }
1311 // An async setup script outlives the handoff. The turn owns the header, so
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)) {
1336 if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) {
13371337 nextRows.push({
13381338 kind: "thinking",
13391339 id: LIVE_ACTIVITY_ROW_ID,
apps/web/src/components/chat/MessagesTimeline.tsx+90-25
......@@ -185,6 +185,7 @@ import {
185185 toolGroupAction,
186186 workEntryDisplayLabel,
187187 workEntryIsVisibleInGroup,
188 worktreeSetupAgentStarted,
188189 type StableMessagesTimelineRowsState,
189190 type MessagesTimelineRow,
190191 TIMELINE_MINIMAP_MIN_ITEMS,
......@@ -192,6 +193,8 @@ import {
192193 type WorkGroupScrollAnchor,
193194} from "./MessagesTimeline.logic";
194195import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
196import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
197import { Spinner } from "../ui/spinner";
195198import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
196199import { WorktreeSetupCard } from "./WorktreeSetupCard";
197200import {
......@@ -295,6 +298,12 @@ interface TimelineRowActivityState {
295298 isCompacting: boolean;
296299 isRevertingCheckpoint: boolean;
297300 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;
298307}
299308
300309const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
......@@ -980,6 +989,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({
980989 onRemoveQueuedMessage,
981990 ],
982991 );
992 const backgroundWorktreeSetup =
993 worktreeSetup !== null &&
994 worktreeSetup.phase === "running" &&
995 worktreeSetupAgentStarted(worktreeSetup) &&
996 latestTurn?.startedAt != null
997 ? worktreeSetup
998 : null;
983999 const activityState = useMemo<TimelineRowActivityState>(
9841000 () => ({
9851001 isWorking,
......@@ -987,8 +1003,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
9871003 isCompacting,
9881004 isRevertingCheckpoint,
9891005 latestTurnId: latestTurn?.turnId ?? null,
1006 backgroundWorktreeSetup,
9901007 }),
991 [isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],
1008 [
1009 backgroundWorktreeSetup,
1010 isCompacting,
1011 isRevertingCheckpoint,
1012 isWorking,
1013 isPreparingWorktree,
1014 latestTurn?.turnId,
1015 ],
9921016 );
9931017
9941018 // Stable renderItem — no closure deps. Row components read shared state
......@@ -2210,40 +2234,81 @@ function ProposedPlanTimelineRow({
22102234}
22112235
22122236function 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 );
22142253 return (
22152254 <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">
22172256 <span
2218 key={isPreparingWorktree ? "setup" : isCompacting ? "compacting" : "working"}
2219 ref={isPreparingWorktree || isCompacting ? observeVisibleAnimation : undefined}
2220 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
2257 ref={shimmer ? observeVisibleAnimation : undefined}
2258 className="relative shrink-0 overflow-hidden whitespace-nowrap"
22212259 >
2222 {isPreparingWorktree ? (
2223 <>
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 )}
2260 {label}
2261 {shimmer ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
22412262 </span>
2263 {backgroundWorktreeSetup ? (
2264 <BackgroundWorktreeSetupChip snapshot={backgroundWorktreeSetup} />
2265 ) : null}
22422266 </div>
22432267 </div>
22442268 );
22452269}
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
22472312function ThinkingTimelineRow() {
22482313 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
22492314 // 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({
222222 );
223223}
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 */
225234function 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 }));
227239 return (
228240 <pre
229241 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",
231243 failed
232244 ? "border-destructive/20 bg-error-surface text-destructive-foreground"
233245 : "border-border bg-code text-muted-foreground",
234246 )}
235247 >
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 ))}
237253 </pre>
238254 );
239255}
......@@ -269,6 +285,47 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
269285 );
270286}
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
272329export function WorktreeSetupCard({
273330 snapshot,
274331 onCancel,
......@@ -277,8 +334,9 @@ export function WorktreeSetupCard({
277334 embedded = false,
278335}: WorktreeSetupCardProps & {
279336 /**
280 * The agent already started (async setup script), so the turn owns the
281 * "Working for" header and only the script's row sits among the worklog.
337 * The agent's turn is live and owns the "Working for" header. The stage
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.
282340 */
283341 embedded?: boolean;
284342}) {
......@@ -292,24 +350,40 @@ export function WorktreeSetupCard({
292350 })();
293351 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
294352 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
295 const stages = embedded
296 ? snapshot.stages.filter((stage) => stage.id === "setup-script")
297 : snapshot.stages;
353 const collapsed = embedded && !running;
354 // While running, the timeline's working row above the card carries the
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
299366 return (
300367 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>
301 {embedded ? null : <SetupHeaderRow snapshot={snapshot} totalElapsed={totalElapsed} />}
302 <div className={embedded ? undefined : "pt-1.5"}>
303 {stages.map((stage) => (
304 <div key={stage.id}>
305 <StageRow stage={stage} nowMs={nowMs} scriptName={snapshot.setupScript?.name ?? null} />
306 {stage.id === "setup-script" &&
307 (stage.status === "running" || stage.status === "failed") ? (
308 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
309 ) : null}
310 </div>
311 ))}
312 </div>
368 {showHeader ? <SetupHeaderRow snapshot={snapshot} totalElapsed={totalElapsed} /> : null}
369 {collapsed ? (
370 <CollapsedSummaryRow snapshot={snapshot} totalElapsed={totalElapsed} />
371 ) : (
372 <div className={showHeader ? "pt-1.5" : undefined}>
373 {snapshot.stages.map((stage) => (
374 <div key={stage.id}>
375 <StageRow
376 stage={stage}
377 nowMs={nowMs}
378 scriptName={snapshot.setupScript?.name ?? null}
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
314388 {snapshot.phase === "failed" && snapshot.error ? (
315389 <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 {
498498 getDraftSession: (draftId: DraftId) => DraftSessionState | null;
499499 /** Resolves a server-thread ref back to a matching draft session when one exists. */
500500 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;
501503 getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null;
502504 getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null;
503505 listDraftThreadKeys: () => string[];
......@@ -2583,6 +2585,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
25832585 }
25842586 return null;
25852587 },
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 },
25862599 getDraftThread: (threadRef) => {
25872600 if (typeof threadRef === "string") {
25882601 return get().getDraftSession(DraftId.make(threadRef));
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+4-102
......@@ -1,105 +1,7 @@
1import { createFileRoute, useNavigate } 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}
1import { createFileRoute } from "@tanstack/react-router";
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.
1035export const Route = createFileRoute("/_chat/$environmentId/$threadId")({
104 component: ChatThreadRouteView,
6 component: () => null,
1057});
apps/web/src/routes/_chat.draft.$draftId.tsx+4-91
......@@ -1,94 +1,7 @@
1import { createFileRoute, useNavigate } 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}
1import { createFileRoute } from "@tanstack/react-router";
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.
925export const Route = createFileRoute("/_chat/draft/$draftId")({
93 component: DraftChatThreadRouteView,
6 component: () => null,
947});
apps/web/src/routes/_chat.tsx+10-2
......@@ -1,8 +1,10 @@
1import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
1import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router";
22import { useAtomValue } from "@effect/atom-react";
33import { useEffect, useMemo } from "react";
44
55import { isCommandPaletteOpen } from "../commandPaletteBus";
6import { ThreadRouteView } from "../components/ThreadRouteView";
7import { resolveThreadRouteTarget } from "../threadRoutes";
68import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
79import { openCommandPalette } from "../commandPaletteBus";
810import { useProjects } from "../state/entities";
......@@ -175,10 +177,16 @@ function ChatRouteGlobalShortcuts() {
175177}
176178
177179function 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 });
178186 return (
179187 <>
180188 <ChatRouteGlobalShortcuts />
181 <Outlet />
189 {threadTarget ? <ThreadRouteView target={threadTarget} /> : <Outlet />}
182190 </>
183191 );
184192}