| author | |
| committer | |
| log | 0f5a1513e09fb41e0d0f86157aff64cc3657975d |
| tree | 52a6b9e52e3bc6e054b9fb1cd19f4873a7d3fcfe |
| parent | fe896e6a90dd2a91e9499c098bb621568a3f341f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 307 | // control sequences are stripped, and the echoed wrapper is hidden. |
| 308 | 308 | yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`); |
| 309 | 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 | 313 | // A spoofed sentinel from the script itself must not settle completion. |
| 312 | 314 | yield* emit("__T3_SETUP_DONE__:0\r\n"); |
| 313 | 315 | yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`); |
| ... | ... | @@ -317,6 +319,9 @@ describe("ProjectSetupScriptRunner", () => { |
| 317 | 319 | expect(completion.exitCode).toBe(3); |
| 318 | 320 | expect(seen).toEqual([ |
| 319 | 321 | "Resolving deps", |
| 322 | "Progress: 1/3", | |
| 323 | "Progress: 2/3", | |
| 324 | "Progress: 3/3", | |
| 320 | 325 | "Done in 2s", |
| 321 | 326 | "__T3_SETUP_DONE__:0", |
| 322 | 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 | 258 | } |
| 259 | 259 | if (event.type === "output") { |
| 260 | 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 | 268 | lineBuffer = lines.pop() ?? ""; |
| 263 | 269 | // A script that never prints a newline must not grow this forever. |
| 264 | 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 | 10 | type ServerProvider, |
| 11 | 11 | ThreadId, |
| 12 | 12 | TurnId, |
| 13 | type WorktreeSetupSnapshot, | |
| 13 | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 15 | 16 | import { Atom, AsyncResult } from "effect/unstable/reactivity"; |
| ... | ... | @@ -2533,89 +2534,68 @@ describe("worktree setup visibility", () => { |
| 2533 | 2534 | expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull(); |
| 2534 | 2535 | }); |
| 2535 | 2536 | |
| 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 | 2545 | expect( |
| 2538 | 2546 | resolveVisibleWorktreeSetup({ |
| 2539 | 2547 | live: base, |
| 2540 | 2548 | recorded: null, |
| 2541 | 2549 | turnStarted: false, |
| 2542 | isWorking: true, | |
| 2550 | followUpSent: false, | |
| 2543 | 2551 | }), |
| 2544 | 2552 | ).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(); | |
| 2561 | 2556 | }); |
| 2562 | 2557 | |
| 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 | 2559 | const scriptFailed = { |
| 2565 | 2560 | ...settledDone, |
| 2566 | 2561 | stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], |
| 2567 | 2562 | }; |
| 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) => | |
| 2577 | 2564 | resolveVisibleWorktreeSetup({ |
| 2578 | 2565 | live: null, |
| 2579 | recorded: scriptFailed, | |
| 2566 | recorded: snapshot, | |
| 2580 | 2567 | turnStarted: true, |
| 2581 | isWorking: false, | |
| 2582 | }), | |
| 2583 | ).toBeNull(); | |
| 2568 | followUpSent, | |
| 2569 | }); | |
| 2570 | expect(visible(scriptFailed)).toEqual(scriptFailed); | |
| 2584 | 2571 | 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); | |
| 2593 | 2587 | }); |
| 2594 | 2588 | |
| 2595 | 2589 | 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 | }); | |
| 2620 | 2600 | }); |
| 2621 | 2601 | }); |
apps/web/src/components/ChatView.logic.ts+13-6| ... | ... | @@ -278,15 +278,22 @@ export function findRecordedWorktreeSetup( |
| 278 | 278 | /** |
| 279 | 279 | * Which setup snapshot the timeline shows, if any. The live stream wins while |
| 280 | 280 | * 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. | |
| 284 | 290 | */ |
| 285 | 291 | export function resolveVisibleWorktreeSetup(input: { |
| 286 | 292 | live: WorktreeSetupSnapshot | null; |
| 287 | 293 | recorded: WorktreeSetupSnapshot | null; |
| 288 | 294 | turnStarted: boolean; |
| 289 | isWorking: boolean; | |
| 295 | /** The user sent a message after the one that created the worktree. */ | |
| 296 | followUpSent: boolean; | |
| 290 | 297 | }): WorktreeSetupSnapshot | null { |
| 291 | 298 | const snapshot = |
| 292 | 299 | input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) |
| ... | ... | @@ -294,10 +301,10 @@ export function resolveVisibleWorktreeSetup(input: { |
| 294 | 301 | : input.recorded; |
| 295 | 302 | if (!snapshot) return null; |
| 296 | 303 | if (snapshot.phase === "running") return snapshot; |
| 304 | if (input.followUpSent) return null; | |
| 297 | 305 | if (snapshot.phase !== "done") return snapshot; |
| 298 | 306 | 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; | |
| 301 | 308 | } |
| 302 | 309 | |
| 303 | 310 | export function resolveDraftHeroState(input: { |
apps/web/src/components/ChatView.tsx+3-1| ... | ... | @@ -3529,7 +3529,9 @@ export default function ChatView(props: ChatViewProps) { |
| 3529 | 3529 | live: liveWorktreeSetup, |
| 3530 | 3530 | recorded: recordedWorktreeSetup, |
| 3531 | 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 | 3536 | // Sends wait for the agent handoff, not for the setup script: an async |
| 3535 | 3537 | // script keeps the snapshot running while the agent already works, and a |
apps/web/src/components/ThreadRouteView.tsx created+214| ... | ... | @@ -0,0 +1,214 @@ |
| 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | import { useNavigate } from "@tanstack/react-router"; | |
| 4 | import { useEffect, useState } from "react"; | |
| 5 | ||
| 6 | import ChatView from "./ChatView"; | |
| 7 | import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; | |
| 8 | import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; | |
| 9 | import { SidebarInset } from "./ui/sidebar"; | |
| 10 | import { | |
| 11 | finalizePromotedDraftThreadByRef, | |
| 12 | markPromotedDraftThreadByRef, | |
| 13 | useBackgroundDraftSubmissionPending, | |
| 14 | useComposerDraftStore, | |
| 15 | } from "../composerDraftStore"; | |
| 16 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; | |
| 17 | import { | |
| 18 | useEnvironmentThreadRefs, | |
| 19 | useThread, | |
| 20 | useThreadDetail, | |
| 21 | useThreadRefs, | |
| 22 | useThreadShell, | |
| 23 | useThreadStatus, | |
| 24 | } from "../state/entities"; | |
| 25 | import { useEnvironmentQuery } from "../state/query"; | |
| 26 | import { environmentShell } from "../state/shell"; | |
| 27 | import { | |
| 28 | buildThreadRouteParams, | |
| 29 | resolveThreadRouteRenderState, | |
| 30 | type ThreadRouteTarget, | |
| 31 | } from "../threadRoutes"; | |
| 32 | import { 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 | */ | |
| 47 | export 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 | 1092 | }); |
| 1093 | 1093 | |
| 1094 | 1094 | describe("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 | 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 | 1109 | const rows = deriveMessagesTimelineRows({ |
| 1109 | 1110 | timelineEntries: [], |
| 1110 | 1111 | isWorking: true, |
| ... | ... | @@ -1126,7 +1127,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1126 | 1127 | ]); |
| 1127 | 1128 | }); |
| 1128 | 1129 | |
| 1129 | it("shows the worktree setup card instead of the working placeholder", () => { | |
| 1130 | it("leads the worktree setup card with the working header", () => { | |
| 1130 | 1131 | const snapshot: WorktreeSetupSnapshot = { |
| 1131 | 1132 | threadId: ThreadId.make("thread-setup"), |
| 1132 | 1133 | phase: "running", |
| ... | ... | @@ -1177,6 +1178,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1177 | 1178 | worktreeSetup: snapshot, |
| 1178 | 1179 | }); |
| 1179 | 1180 | expect(withoutMessages).toEqual([ |
| 1181 | { kind: "working", id: "working-indicator-row", createdAt: "2026-01-01T00:00:00Z" }, | |
| 1180 | 1182 | { |
| 1181 | 1183 | kind: "worktree-setup", |
| 1182 | 1184 | id: WORKTREE_SETUP_ROW_ID, |
| ... | ... | @@ -1186,7 +1188,25 @@ describe("deriveMessagesTimelineRows", () => { |
| 1186 | 1188 | }, |
| 1187 | 1189 | ]); |
| 1188 | 1190 | |
| 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 | 1210 | const withMessages = deriveMessagesTimelineRows({ |
| 1191 | 1211 | timelineEntries: [userEntry, assistantEntry], |
| 1192 | 1212 | isWorking: true, |
| ... | ... | @@ -1194,16 +1214,34 @@ describe("deriveMessagesTimelineRows", () => { |
| 1194 | 1214 | turnDiffSummaries: [], |
| 1195 | 1215 | supportsConversationRollback: false, |
| 1196 | 1216 | worktreeSetup: { ...snapshot, phase: "failed" }, |
| 1217 | queuedMessages: [queuedMessage("q1", "later")], | |
| 1197 | 1218 | }); |
| 1198 | 1219 | expect(withMessages.map((row) => row.kind)).toEqual([ |
| 1199 | 1220 | "message", |
| 1200 | 1221 | "worktree-setup", |
| 1201 | 1222 | "working", |
| 1202 | 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 | ]); |
| 1204 | 1242 | |
| 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. | |
| 1207 | 1245 | const stage = (id: "agent" | "setup-script", status: "done" | "running") => |
| 1208 | 1246 | ({ |
| 1209 | 1247 | id, |
| ... | ... | @@ -1233,13 +1271,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1233 | 1271 | supportsConversationRollback: false, |
| 1234 | 1272 | worktreeSetup: asyncSnapshot, |
| 1235 | 1273 | }); |
| 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"]); | |
| 1243 | 1275 | |
| 1244 | 1276 | // Dispatched but not yet visible as a turn: the full card stays put so |
| 1245 | 1277 | // nothing collapses during the handoff. |
| ... | ... | @@ -1251,11 +1283,26 @@ describe("deriveMessagesTimelineRows", () => { |
| 1251 | 1283 | supportsConversationRollback: false, |
| 1252 | 1284 | worktreeSetup: asyncSnapshot, |
| 1253 | 1285 | }); |
| 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 }); | |
| 1256 | 1288 | |
| 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({ | |
| 1259 | 1306 | timelineEntries: [userEntry], |
| 1260 | 1307 | latestTurn: liveTurn, |
| 1261 | 1308 | isWorking: true, |
| ... | ... | @@ -1264,10 +1311,18 @@ describe("deriveMessagesTimelineRows", () => { |
| 1264 | 1311 | supportsConversationRollback: false, |
| 1265 | 1312 | worktreeSetup: { |
| 1266 | 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 | }); |
| 1272 | 1327 | |
| 1273 | 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 | 401 | id: string; |
| 402 | 402 | createdAt: string | null; |
| 403 | 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 | 409 | embedded: boolean; |
| 406 | 410 | } |
| 407 | 411 | | { |
| ... | ... | @@ -1273,67 +1277,63 @@ export function deriveMessagesTimelineRows(input: { |
| 1273 | 1277 | }); |
| 1274 | 1278 | } |
| 1275 | 1279 | |
| 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. | |
| 1281 | 1288 | const setupHandedOff = |
| 1282 | 1289 | input.worktreeSetup !== null && |
| 1283 | 1290 | input.worktreeSetup !== undefined && |
| 1284 | 1291 | worktreeSetupAgentStarted(input.worktreeSetup) && |
| 1285 | 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 | 1295 | const setupRow = { |
| 1288 | 1296 | kind: "worktree-setup", |
| 1289 | 1297 | id: WORKTREE_SETUP_ROW_ID, |
| 1290 | 1298 | createdAt: input.worktreeSetup.startedAt, |
| 1291 | 1299 | snapshot: input.worktreeSetup, |
| 1292 | embedded: false, | |
| 1300 | embedded: setupHandedOff, | |
| 1293 | 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 | 1302 | const firstUserRowIndex = nextRows.findIndex( |
| 1298 | 1303 | (row) => row.kind === "message" && row.message.role === "user", |
| 1299 | 1304 | ); |
| 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); | |
| 1302 | 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 | } |
| 1307 | 1329 | |
| 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 | 1334 | appendWorkingRow(); |
| 1310 | 1335 | } |
| 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)) { | |
| 1337 | 1337 | nextRows.push({ |
| 1338 | 1338 | kind: "thinking", |
| 1339 | 1339 | id: LIVE_ACTIVITY_ROW_ID, |
apps/web/src/components/chat/MessagesTimeline.tsx+90-25| ... | ... | @@ -185,6 +185,7 @@ import { |
| 185 | 185 | toolGroupAction, |
| 186 | 186 | workEntryDisplayLabel, |
| 187 | 187 | workEntryIsVisibleInGroup, |
| 188 | worktreeSetupAgentStarted, | |
| 188 | 189 | type StableMessagesTimelineRowsState, |
| 189 | 190 | type MessagesTimelineRow, |
| 190 | 191 | TIMELINE_MINIMAP_MIN_ITEMS, |
| ... | ... | @@ -192,6 +193,8 @@ import { |
| 192 | 193 | type WorkGroupScrollAnchor, |
| 193 | 194 | } from "./MessagesTimeline.logic"; |
| 194 | 195 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 196 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 197 | import { Spinner } from "../ui/spinner"; | |
| 195 | 198 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 196 | 199 | import { WorktreeSetupCard } from "./WorktreeSetupCard"; |
| 197 | 200 | import { |
| ... | ... | @@ -295,6 +298,12 @@ interface TimelineRowActivityState { |
| 295 | 298 | isCompacting: boolean; |
| 296 | 299 | isRevertingCheckpoint: boolean; |
| 297 | 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 | } |
| 299 | 308 | |
| 300 | 309 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| ... | ... | @@ -980,6 +989,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 980 | 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 | 999 | const activityState = useMemo<TimelineRowActivityState>( |
| 984 | 1000 | () => ({ |
| 985 | 1001 | isWorking, |
| ... | ... | @@ -987,8 +1003,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 987 | 1003 | isCompacting, |
| 988 | 1004 | isRevertingCheckpoint, |
| 989 | 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 | ); |
| 993 | 1017 | |
| 994 | 1018 | // Stable renderItem — no closure deps. Row components read shared state |
| ... | ... | @@ -2210,40 +2234,81 @@ function ProposedPlanTimelineRow({ |
| 2210 | 2234 | } |
| 2211 | 2235 | |
| 2212 | 2236 | function 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 | 2253 | return ( |
| 2215 | 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 | 2256 | <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" | |
| 2221 | 2259 | > |
| 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} | |
| 2241 | 2262 | </span> |
| 2263 | {backgroundWorktreeSetup ? ( | |
| 2264 | <BackgroundWorktreeSetupChip snapshot={backgroundWorktreeSetup} /> | |
| 2265 | ) : null} | |
| 2242 | 2266 | </div> |
| 2243 | 2267 | </div> |
| 2244 | 2268 | ); |
| 2245 | 2269 | } |
| 2246 | 2270 | |
| 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 | */ | |
| 2276 | function 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 | ||
| 2247 | 2312 | function ThinkingTimelineRow() { |
| 2248 | 2313 | const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); |
| 2249 | 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 | 222 | ); |
| 223 | 223 | } |
| 224 | 224 | |
| 225 | /** The server keeps this many trailing lines; the box is sized for exactly that. */ | |
| 226 | const OUTPUT_TAIL_LINES = 4; | |
| 227 | const 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 | */ | |
| 225 | 234 | function 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 | 239 | return ( |
| 228 | 240 | <pre |
| 229 | 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 | 243 | failed |
| 232 | 244 | ? "border-destructive/20 bg-error-surface text-destructive-foreground" |
| 233 | 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 | 253 | </pre> |
| 238 | 254 | ); |
| 239 | 255 | } |
| ... | ... | @@ -269,6 +285,47 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { |
| 269 | 285 | ); |
| 270 | 286 | } |
| 271 | 287 | |
| 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 | */ | |
| 293 | function 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 | ||
| 272 | 329 | export function WorktreeSetupCard({ |
| 273 | 330 | snapshot, |
| 274 | 331 | onCancel, |
| ... | ... | @@ -277,8 +334,9 @@ export function WorktreeSetupCard({ |
| 277 | 334 | embedded = false, |
| 278 | 335 | }: WorktreeSetupCardProps & { |
| 279 | 336 | /** |
| 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. | |
| 282 | 340 | */ |
| 283 | 341 | embedded?: boolean; |
| 284 | 342 | }) { |
| ... | ... | @@ -292,24 +350,40 @@ export function WorktreeSetupCard({ |
| 292 | 350 | })(); |
| 293 | 351 | const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); |
| 294 | 352 | 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"); | |
| 298 | 365 | |
| 299 | 366 | return ( |
| 300 | 367 | <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 | )} | |
| 313 | 387 | |
| 314 | 388 | {snapshot.phase === "failed" && snapshot.error ? ( |
| 315 | 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 | 498 | getDraftSession: (draftId: DraftId) => DraftSessionState | null; |
| 499 | 499 | /** Resolves a server-thread ref back to a matching draft session when one exists. */ |
| 500 | 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 | 503 | getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null; |
| 502 | 504 | getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; |
| 503 | 505 | listDraftThreadKeys: () => string[]; |
| ... | ... | @@ -2583,6 +2585,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2583 | 2585 | } |
| 2584 | 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 | 2599 | getDraftThread: (threadRef) => { |
| 2587 | 2600 | if (typeof threadRef === "string") { |
| 2588 | 2601 | return get().getDraftSession(DraftId.make(threadRef)); |
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+4-102| ... | ... | @@ -1,105 +1,7 @@ |
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | import { useEffect } from "react"; | |
| 3 | ||
| 4 | import ChatView from "../components/ChatView"; | |
| 5 | import { threadHasStarted } from "../components/ChatView.logic"; | |
| 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; | |
| 7 | import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; | |
| 8 | import { resolveThreadSyncPhase } from "../threadSync"; | |
| 9 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; | |
| 10 | import { SidebarInset } from "~/components/ui/sidebar"; | |
| 11 | import { | |
| 12 | useEnvironmentThreadRefs, | |
| 13 | useThreadDetail, | |
| 14 | useThreadShell, | |
| 15 | useThreadStatus, | |
| 16 | } from "../state/entities"; | |
| 17 | import { useEnvironmentQuery } from "../state/query"; | |
| 18 | import { environmentShell } from "../state/shell"; | |
| 19 | ||
| 20 | function 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 | } | |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 102 | 2 | |
| 3 | // The view lives in the `_chat` layout (see ThreadRouteView) so a draft's | |
| 4 | // promotion onto this route keeps the same ChatView mounted. | |
| 103 | 5 | export 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 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | import { useEffect } from "react"; | |
| 3 | import ChatView from "../components/ChatView"; | |
| 4 | import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic"; | |
| 5 | import { | |
| 6 | DraftId, | |
| 7 | markPromotedDraftThreadByRef, | |
| 8 | useBackgroundDraftSubmissionPending, | |
| 9 | useComposerDraftStore, | |
| 10 | } from "../composerDraftStore"; | |
| 11 | import { SidebarInset } from "../components/ui/sidebar"; | |
| 12 | import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition"; | |
| 13 | import { buildThreadRouteParams } from "../threadRoutes"; | |
| 14 | import { useThread, useThreadRefs } from "../state/entities"; | |
| 15 | ||
| 16 | function 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 | } | |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 91 | 2 | |
| 3 | // The view lives in the `_chat` layout (see ThreadRouteView) so a draft's | |
| 4 | // promotion to `/$environmentId/$threadId` keeps the same ChatView mounted. | |
| 92 | 5 | export 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 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; | |
| 1 | import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router"; | |
| 2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import { useEffect, useMemo } from "react"; |
| 4 | 4 | |
| 5 | 5 | import { isCommandPaletteOpen } from "../commandPaletteBus"; |
| 6 | import { ThreadRouteView } from "../components/ThreadRouteView"; | |
| 7 | import { resolveThreadRouteTarget } from "../threadRoutes"; | |
| 6 | 8 | import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; |
| 7 | 9 | import { openCommandPalette } from "../commandPaletteBus"; |
| 8 | 10 | import { useProjects } from "../state/entities"; |
| ... | ... | @@ -175,10 +177,16 @@ function ChatRouteGlobalShortcuts() { |
| 175 | 177 | } |
| 176 | 178 | |
| 177 | 179 | function 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 | 186 | return ( |
| 179 | 187 | <> |
| 180 | 188 | <ChatRouteGlobalShortcuts /> |
| 181 | <Outlet /> | |
| 189 | {threadTarget ? <ThreadRouteView target={threadTarget} /> : <Outlet />} | |
| 182 | 190 | </> |
| 183 | 191 | ); |
| 184 | 192 | } |