diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 8bfd86bf6ac5..31fd6a195827 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -307,7 +307,9 @@ describe("ProjectSetupScriptRunner", () => { // control sequences are stripped, and the echoed wrapper is hidden. yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`); yield* emit("\u001b[32mResolving"); - yield* emit(" deps\u001b[0m\r\nDone in 2s\r\n"); + yield* emit(" deps\u001b[0m\r\n"); + // Progress redraws separated by bare carriage returns are their own lines. + yield* emit("Progress: 1/3\rProgress: 2/3\rProgress: 3/3\r\nDone in 2s\r\n"); // A spoofed sentinel from the script itself must not settle completion. yield* emit("__T3_SETUP_DONE__:0\r\n"); yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`); @@ -317,6 +319,9 @@ describe("ProjectSetupScriptRunner", () => { expect(completion.exitCode).toBe(3); expect(seen).toEqual([ "Resolving deps", + "Progress: 1/3", + "Progress: 2/3", + "Progress: 3/3", "Done in 2s", "__T3_SETUP_DONE__:0", `__T3_SETUP_DONE___${"0".repeat(32)}:0`, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 2835750f8c71..16cbfaa59496 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -258,7 +258,13 @@ export const make = Effect.gen(function* () { } if (event.type === "output") { lineBuffer += event.data; - const lines = lineBuffer.split(/\r?\n/); + // A bare carriage return is how installers redraw a progress line in + // place; each redraw becomes a short line of its own instead of + // being glued into one long one. The wrapper echo is filtered per + // segment too, which is why `echoedWrapperLines` is split on the + // same `\r`: a line editor repainting the typed command yields the + // same segments. + const lines = lineBuffer.split(/\r\n|\r|\n/); lineBuffer = lines.pop() ?? ""; // A script that never prints a newline must not grow this forever. // The sentinel is always on its own line, so keeping the tail is safe. diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c226a3a65c78..8a7e17adee12 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -10,6 +10,7 @@ import { type ServerProvider, ThreadId, TurnId, + type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { Atom, AsyncResult } from "effect/unstable/reactivity"; @@ -2533,89 +2534,68 @@ describe("worktree setup visibility", () => { expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull(); }); - it("shows a running setup and hides a clean one once the turn started", () => { + it("shows a running setup and drops a clean one once the turn started", () => { + const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) => + resolveVisibleWorktreeSetup({ + live: null, + recorded: snapshot, + turnStarted, + followUpSent: false, + }); expect( resolveVisibleWorktreeSetup({ live: base, recorded: null, turnStarted: false, - isWorking: true, + followUpSent: false, }), ).toEqual(base); - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: settledDone, - turnStarted: false, - isWorking: true, - }), - ).toEqual(settledDone); - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: settledDone, - turnStarted: true, - isWorking: true, - }), - ).toBeNull(); + expect(visible(settledDone, false)).toEqual(settledDone); + expect(visible(settledDone, true)).toBeNull(); + expect(visible(null, true)).toBeNull(); }); - it("keeps a failed script visible for the running turn and a failed setup always", () => { + it("keeps a failed script, a failed setup, and a cancelled setup visible", () => { const scriptFailed = { ...settledDone, stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], }; - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: scriptFailed, - turnStarted: true, - isWorking: true, - }), - ).toEqual(scriptFailed); - expect( + const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) => resolveVisibleWorktreeSetup({ live: null, - recorded: scriptFailed, + recorded: snapshot, turnStarted: true, - isWorking: false, - }), - ).toBeNull(); + followUpSent, + }); + expect(visible(scriptFailed)).toEqual(scriptFailed); const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" }; - expect( - resolveVisibleWorktreeSetup({ - live: null, - recorded: failed, - turnStarted: true, - isWorking: false, - }), - ).toEqual(failed); + expect(visible(failed)).toEqual(failed); + const cancelled = { ...settledDone, phase: "cancelled" as const }; + expect(visible(cancelled)).toEqual(cancelled); + + // The setup belongs to the first turn. A follow-up send retires every + // settled outcome; only a script that is still running stays. + expect(visible(scriptFailed, true)).toBeNull(); + expect(visible(failed, true)).toBeNull(); + expect(visible(cancelled, true)).toBeNull(); + expect(visible(settledDone, true)).toBeNull(); + const stillRunning = { + ...base, + stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")], + }; + expect(visible(stillRunning, true)).toEqual(stillRunning); }); it("prefers whichever snapshot is newer by sequence", () => { - expect( - resolveVisibleWorktreeSetup({ - live: { ...base, sequence: 3 }, - recorded: { ...settledDone, sequence: 7 }, - turnStarted: false, - isWorking: false, - }), - ).toEqual({ ...settledDone, sequence: 7 }); - expect( - resolveVisibleWorktreeSetup({ - live: { ...settledDone, sequence: 9 }, - recorded: { ...base, sequence: 1 }, - turnStarted: false, - isWorking: false, - }), - ).toEqual({ ...settledDone, sequence: 9 }); - expect( - resolveVisibleWorktreeSetup({ - live: base, - recorded: null, - turnStarted: false, - isWorking: false, - }), - ).toEqual(base); + const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) => + resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false }); + expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({ + ...settledDone, + sequence: 7, + }); + expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({ + ...settledDone, + sequence: 9, + }); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1dbe11f6c4c2..f5d9e7ad2578 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -278,15 +278,22 @@ export function findRecordedWorktreeSetup( /** * Which setup snapshot the timeline shows, if any. The live stream wins while * it has a newer sequence; the recorded activity covers everything else. A - * running setup always shows. Once settled, the card stays only while it - * still says something the turn does not: the turn has not started yet, or a - * stage failed and the turn is still running so the exit code stays reachable. + * running setup always shows. The setup belongs to the thread's first turn: + * once the user has sent a follow-up it is history and nothing about it is + * shown again, whatever its outcome. Within that first turn, a clean finish + * leaves no trace once the turn is live (the setup is a means to the reply, + * not part of the conversation), while a failed script, a failed setup, or a + * cancelled one stays so the outcome, exit code, and terminal are reachable. + * Before the turn is live everything stays so nothing collapses in the + * handoff gap. Visibility never depends on whether a turn happens to be + * running, which would make the row come and go. */ export function resolveVisibleWorktreeSetup(input: { live: WorktreeSetupSnapshot | null; recorded: WorktreeSetupSnapshot | null; turnStarted: boolean; - isWorking: boolean; + /** The user sent a message after the one that created the worktree. */ + followUpSent: boolean; }): WorktreeSetupSnapshot | null { const snapshot = input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) @@ -294,10 +301,10 @@ export function resolveVisibleWorktreeSetup(input: { : input.recorded; if (!snapshot) return null; if (snapshot.phase === "running") return snapshot; + if (input.followUpSent) return null; if (snapshot.phase !== "done") return snapshot; if (!input.turnStarted) return snapshot; - const stageFailed = snapshot.stages.some((stage) => stage.status === "failed"); - return stageFailed && input.isWorking ? snapshot : null; + return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null; } export function resolveDraftHeroState(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 38f773cf037e..708809712917 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3529,7 +3529,9 @@ export default function ChatView(props: ChatViewProps) { live: liveWorktreeSetup, recorded: recordedWorktreeSetup, turnStarted: activeThread?.latestTurn?.startedAt != null, - isWorking, + // Counts the optimistic send too, so the row retires the moment the + // follow-up is on screen rather than when the server echoes it back. + followUpSent: timelineMessages.filter((message) => message.role === "user").length > 1, }); // Sends wait for the agent handoff, not for the setup script: an async // script keeps the snapshot running while the agent already works, and a diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx new file mode 100644 index 000000000000..ed902d06ede1 --- /dev/null +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -0,0 +1,214 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +import ChatView from "./ChatView"; +import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic"; +import { waitForDraftHeroTransition } from "./chat/draftHeroTransition"; +import { SidebarInset } from "./ui/sidebar"; +import { + finalizePromotedDraftThreadByRef, + markPromotedDraftThreadByRef, + useBackgroundDraftSubmissionPending, + useComposerDraftStore, +} from "../composerDraftStore"; +import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; +import { + useEnvironmentThreadRefs, + useThread, + useThreadDetail, + useThreadRefs, + useThreadShell, + useThreadStatus, +} from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { environmentShell } from "../state/shell"; +import { + buildThreadRouteParams, + resolveThreadRouteRenderState, + type ThreadRouteTarget, +} from "../threadRoutes"; +import { resolveThreadSyncPhase } from "../threadSync"; + +/** + * The single chat surface behind both `/draft/$draftId` and + * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so + * a background send's state stays with the draft it came from), and that + * instance carries the draft through its promotion to a server thread: the + * thread route keeps keying by the draft id while the draft record exists, + * so the route swap only changes props and the timeline never paints an + * empty frame. Plain server threads are unkeyed, so navigating between them + * reuses one instance as ChatView expects. + * + * Rendered by the `_chat` layout rather than by the two leaf routes, since + * an element only survives a route swap when the same parent renders it. + */ +export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { + const navigate = useNavigate(); + const draftId = target.kind === "draft" ? target.draftId : null; + const draftSession = useComposerDraftStore((store) => + draftId === null ? null : store.getDraftSession(draftId), + ); + const threadRefs = useThreadRefs(); + // The server thread this view is about: the route's own ref, or the draft's + // reserved ref once the server knows it. + const inferredThreadRef = draftSession + ? (threadRefs.find( + (ref) => + ref.environmentId === draftSession.environmentId && + ref.threadId === draftSession.threadId, + ) ?? null) + : null; + const serverThreadRef: ScopedThreadRef | null = + target.kind === "server" ? target.threadRef : (draftSession?.promotedTo ?? inferredThreadRef); + const serverThread = useThread(serverThreadRef); + const backgroundSubmissionPending = useBackgroundDraftSubmissionPending( + target.kind === "draft" ? serverThreadRef : null, + ); + const canonicalThreadRef = + target.kind === "draft" + ? resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread, + backgroundSubmissionPending, + }) + : null; + + const shell = useEnvironmentQuery( + serverThreadRef === null ? null : environmentShell.stateAtom(serverThreadRef.environmentId), + ); + const serverThreadShell = useThreadShell(serverThreadRef); + const serverThreadDetail = useThreadDetail(serverThreadRef); + const serverThreadStatus = useThreadStatus(serverThreadRef); + const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); + const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const draftThread = useComposerDraftStore((store) => + serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, + ); + const promotedDraftId = useComposerDraftStore((store) => + target.kind === "server" ? store.getDraftIdByRef(target.threadRef) : null, + ); + // The draft record is removed once the promoted thread has started, which + // is after the route swap. Latch the key so the element that carried the + // draft keeps its identity for as long as this thread stays on screen. + const [chatViewKey, setChatViewKey] = useState<{ threadKey: string; key: string } | null>(null); + const serverThreadKey = target.kind === "server" ? scopedThreadKey(target.threadRef) : null; + const nextChatViewKey = + serverThreadKey === null + ? null + : chatViewKey?.threadKey === serverThreadKey + ? chatViewKey + : promotedDraftId + ? { threadKey: serverThreadKey, key: promotedDraftId } + : null; + if (nextChatViewKey !== chatViewKey) { + setChatViewKey(nextChatViewKey); + } + const environmentHasDraftThreads = useComposerDraftStore((store) => + serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false, + ); + const renderState = resolveThreadRouteRenderState({ + bootstrapComplete, + serverThreadShellExists: serverThreadShell !== null, + serverThreadDetailExists: serverThreadDetail !== null, + serverThreadDetailDeleted: serverThreadStatus === "deleted", + draftThreadExists: draftThread !== null, + }); + const threadSyncPhase = resolveThreadSyncPhase({ + detailExists: serverThreadDetail !== null, + shellExists: serverThreadShell !== null, + status: serverThreadStatus, + }); + const serverThreadStarted = threadHasStarted(serverThreadDetail); + const environmentHasAnyThreads = environmentThreadRefs.length > 0 || environmentHasDraftThreads; + + useEffect(() => { + if (!inferredThreadRef || draftSession?.promotedTo) { + return; + } + markPromotedDraftThreadByRef(inferredThreadRef); + }, [draftSession?.promotedTo, inferredThreadRef]); + + useEffect(() => { + if (!canonicalThreadRef) { + return; + } + let cancelled = false; + void waitForDraftHeroTransition().then(() => { + if (cancelled) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(canonicalThreadRef), + replace: true, + }); + }); + return () => { + cancelled = true; + }; + }, [canonicalThreadRef, navigate]); + + useEffect(() => { + if (target.kind !== "draft" || draftSession || canonicalThreadRef) { + return; + } + void navigate({ to: "/", replace: true }); + }, [canonicalThreadRef, draftSession, navigate, target.kind]); + + useEffect(() => { + if (target.kind !== "server" || !bootstrapComplete) { + return; + } + // Navigation already resolved onto this path, so a drop aimed here + // passed its landing check; once the thread reads as missing it can + // never be attached, release it even when there is nowhere to redirect. + if (renderState === "missing") { + const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); + clearPendingFileDropsForThread(target.threadRef); + if (environmentHasAnyThreads) { + void navigate({ to: "/", replace: true }); + } + } + }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); + + useEffect(() => { + if (target.kind !== "server" || !serverThreadStarted || !draftThread) { + return; + } + finalizePromotedDraftThreadByRef(target.threadRef); + }, [draftThread, serverThreadStarted, target]); + + let view: React.ReactNode = null; + if (target.kind === "draft") { + if (draftSession) { + view = ( + + ); + } + } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) { + view = ( + + ); + } + + return ( + + {view} + + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 24078b5f0af0..76938d5365af 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1092,19 +1092,20 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + const queuedMessage = (id: string, prompt: string) => ({ + id, + prompt, + images: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + submissionIntent: "foreground" as const, + queuedAfterToolActivityId: null, + createdAt: "2026-01-01T00:00:01Z", + }); + it("appends queued messages after the live rows, marking the oldest as next", () => { - const queuedMessage = (id: string, prompt: string) => ({ - id, - prompt, - images: [], - files: [], - terminalContexts: [], - previewAnnotations: [], - reviewComments: [], - submissionIntent: "foreground" as const, - queuedAfterToolActivityId: null, - createdAt: "2026-01-01T00:00:01Z", - }); const rows = deriveMessagesTimelineRows({ timelineEntries: [], isWorking: true, @@ -1126,7 +1127,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("shows the worktree setup card instead of the working placeholder", () => { + it("leads the worktree setup card with the working header", () => { const snapshot: WorktreeSetupSnapshot = { threadId: ThreadId.make("thread-setup"), phase: "running", @@ -1177,6 +1178,7 @@ describe("deriveMessagesTimelineRows", () => { worktreeSetup: snapshot, }); expect(withoutMessages).toEqual([ + { kind: "working", id: "working-indicator-row", createdAt: "2026-01-01T00:00:00Z" }, { kind: "worktree-setup", id: WORKTREE_SETUP_ROW_ID, @@ -1186,7 +1188,25 @@ describe("deriveMessagesTimelineRows", () => { }, ]); - // A failed setup never handed off, so the card stays under the send. + // The main pass already places the working header after the send while a + // bootstrap counts as working; the card slots under that one header. + const withUserMessage = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + }); + expect(withUserMessage.map((row) => row.kind)).toEqual([ + "message", + "working", + "worktree-setup", + ]); + + // A failed setup never handed off, so the card stays under the send. The + // rest of the timeline is untouched: a running send still gets its + // working and thinking rows, and queued follow-ups still trail. const withMessages = deriveMessagesTimelineRows({ timelineEntries: [userEntry, assistantEntry], isWorking: true, @@ -1194,16 +1214,34 @@ describe("deriveMessagesTimelineRows", () => { turnDiffSummaries: [], supportsConversationRollback: false, worktreeSetup: { ...snapshot, phase: "failed" }, + queuedMessages: [queuedMessage("q1", "later")], }); expect(withMessages.map((row) => row.kind)).toEqual([ "message", "worktree-setup", "working", "message", + "thinking", + "queued-message", + ]); + const runningWithQueue = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: snapshot, + queuedMessages: [queuedMessage("q1", "later")], + }); + expect(runningWithQueue.map((row) => row.kind)).toEqual([ + "message", + "working", + "worktree-setup", + "queued-message", ]); - // Once the agent stage is done the setup script may still be running in - // the background: the turn owns the header and the script row follows it. + // Once the agent stage is done and the turn is live, a still-running + // script leaves the timeline; the working header surfaces it instead. const stage = (id: "agent" | "setup-script", status: "done" | "running") => ({ id, @@ -1233,13 +1271,7 @@ describe("deriveMessagesTimelineRows", () => { supportsConversationRollback: false, worktreeSetup: asyncSnapshot, }); - expect(asyncRows.map((row) => row.kind)).toEqual([ - "message", - "working", - "worktree-setup", - "thinking", - ]); - expect(asyncRows[2]).toMatchObject({ kind: "worktree-setup", embedded: true }); + expect(asyncRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]); // Dispatched but not yet visible as a turn: the full card stays put so // nothing collapses during the handoff. @@ -1251,11 +1283,26 @@ describe("deriveMessagesTimelineRows", () => { supportsConversationRollback: false, worktreeSetup: asyncSnapshot, }); - expect(handoffRows.map((row) => row.kind)).toEqual(["message", "worktree-setup"]); - expect(handoffRows[1]).toMatchObject({ kind: "worktree-setup", embedded: false }); + expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]); + expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false }); - // A script that already finished has nothing left to show once the turn is live. - const finishedRows = deriveMessagesTimelineRows({ + // A script that outlives the reply never trails the assistant's message. + const outlivedRows = deriveMessagesTimelineRows({ + timelineEntries: [ + userEntry, + { ...assistantEntry, message: { ...assistantEntry.message, streaming: false } }, + ], + latestTurn: { ...liveTurn, state: "completed", completedAt: "2026-01-01T00:00:40Z" }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(outlivedRows.map((row) => row.kind)).toEqual(["message", "message"]); + + // A failed script after the handoff keeps its row under the send. + const failedRows = deriveMessagesTimelineRows({ timelineEntries: [userEntry], latestTurn: liveTurn, isWorking: true, @@ -1264,10 +1311,18 @@ describe("deriveMessagesTimelineRows", () => { supportsConversationRollback: false, worktreeSetup: { ...asyncSnapshot, - stages: [stage("setup-script", "done"), stage("agent", "done")], + phase: "done", + endedAt: "2026-01-01T00:00:20Z", + stages: [{ ...stage("setup-script", "done"), status: "failed" }, stage("agent", "done")], }, }); - expect(finishedRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]); + expect(failedRows.map((row) => row.kind)).toEqual([ + "message", + "worktree-setup", + "working", + "thinking", + ]); + expect(failedRows[1]).toMatchObject({ kind: "worktree-setup", embedded: true }); }); it("keeps context compaction visible outside folded work", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 983e49dfaa87..98d1de7ad307 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -401,7 +401,11 @@ export type MessagesTimelineRow = id: string; createdAt: string | null; snapshot: WorktreeSetupSnapshot; - /** The agent already started; render only the script row under the turn header. */ + /** + * The agent's turn is live and owns the "Working for" header, so the + * card drops its own header and settle-time actions. The stage list + * stays put so nothing jumps when the handoff happens. + */ embedded: boolean; } | { @@ -1273,67 +1277,63 @@ export function deriveMessagesTimelineRows(input: { }); } - // Until the agent's turn is live, the setup card takes the place of the - // working and thinking placeholders. It stays after a failed or cancelled - // setup so the outcome and its actions remain visible until the thread - // state moves on. "Live" means the turn is in the timeline, not just that - // the server dispatched it: the card must not collapse in the gap between. + // Until the agent's turn is live, the setup card sits under the send with + // the working header above it (the header reads "Setting up worktree…" and + // later swaps its text in place, so nothing moves at the handoff). "Live" + // means the turn is in the timeline, not just that the server dispatched + // it: the card must not vanish in the gap between. Once the turn is live + // the stage list leaves the timeline; a script that is still running is + // surfaced by the working header itself. A failed or cancelled setup stays + // under the send so its outcome and actions remain reachable. const setupHandedOff = input.worktreeSetup !== null && input.worktreeSetup !== undefined && worktreeSetupAgentStarted(input.worktreeSetup) && input.latestTurn?.startedAt != null; - if (input.worktreeSetup && !setupHandedOff) { + const setupRunning = !setupHandedOff && input.worktreeSetup?.phase === "running"; + if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) { const setupRow = { kind: "worktree-setup", id: WORKTREE_SETUP_ROW_ID, createdAt: input.worktreeSetup.startedAt, snapshot: input.worktreeSetup, - embedded: false, + embedded: setupHandedOff, } as const; - // Sit directly under the first user message: a finished snapshot can - // outlive the first assistant reply, and it belongs to the send, not the - // end of the thread. const firstUserRowIndex = nextRows.findIndex( (row) => row.kind === "message" && row.message.role === "user", ); - if (firstUserRowIndex >= 0) { - nextRows.splice(firstUserRowIndex + 1, 0, setupRow); + // While the setup runs, the working header leads the card in the same + // slot it keeps once the agent's own turn takes over. The main pass may + // already have placed that header (a bootstrap counts as working). + const workingRowIndex = setupRunning ? nextRows.findIndex((row) => row.kind === "working") : -1; + if (workingRowIndex >= 0) { + nextRows.splice(workingRowIndex + 1, 0, setupRow); } else { - nextRows.push(setupRow); + const insertAt = firstUserRowIndex >= 0 ? firstUserRowIndex + 1 : nextRows.length; + nextRows.splice( + insertAt, + 0, + ...(setupRunning + ? [ + { + kind: "working", + id: "working-indicator-row", + createdAt: input.worktreeSetup.startedAt, + } as const, + setupRow, + ] + : [setupRow]), + ); } - return attachTrailingToolGroupsToAssistant(nextRows); } - if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { + // A running setup owns the working slot above its card and shows no + // activity row of its own; every other state gets the usual tail. + const hasWorkingRow = nextRows.some((row) => row.kind === "working"); + if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - // An async setup script outlives the handoff. The turn owns the header, so - // the script's row sits first under it, ahead of the agent's own work. A - // script that already finished (or never ran) has nothing left to show. - const setupScriptStage = input.worktreeSetup?.stages.find((stage) => stage.id === "setup-script"); - if ( - input.worktreeSetup && - setupHandedOff && - (setupScriptStage?.status === "running" || setupScriptStage?.status === "failed") - ) { - const setupRow = { - kind: "worktree-setup", - id: WORKTREE_SETUP_ROW_ID, - createdAt: input.worktreeSetup.startedAt, - snapshot: input.worktreeSetup, - embedded: true, - } as const; - const workingRowIndex = nextRows.findIndex((row) => row.kind === "working"); - if (workingRowIndex >= 0) { - nextRows.splice(workingRowIndex + 1, 0, setupRow); - } else { - // The turn already finished (or has not been dispatched yet): the row - // trails the reply so a still-running script stays visible after it. - nextRows.push(setupRow); - } - } - if (input.isWorking && (!hasActivityRow || latestToolFailed)) { + if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 307a893342f3..09aa61bd5186 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -185,6 +185,7 @@ import { toolGroupAction, workEntryDisplayLabel, workEntryIsVisibleInGroup, + worktreeSetupAgentStarted, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -192,6 +193,8 @@ import { type WorkGroupScrollAnchor, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { @@ -295,6 +298,12 @@ interface TimelineRowActivityState { isCompacting: boolean; isRevertingCheckpoint: boolean; latestTurnId: TurnId | null; + /** + * A worktree setup whose script is still running after the agent took + * over. The working header shows it as a chip with a popover; the stage + * list itself has already left the timeline. + */ + backgroundWorktreeSetup: WorktreeSetupSnapshot | null; } const TimelineRowCtx = createContext(null!); @@ -980,6 +989,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRemoveQueuedMessage, ], ); + const backgroundWorktreeSetup = + worktreeSetup !== null && + worktreeSetup.phase === "running" && + worktreeSetupAgentStarted(worktreeSetup) && + latestTurn?.startedAt != null + ? worktreeSetup + : null; const activityState = useMemo( () => ({ isWorking, @@ -987,8 +1003,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isCompacting, isRevertingCheckpoint, latestTurnId: latestTurn?.turnId ?? null, + backgroundWorktreeSetup, }), - [isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], + [ + backgroundWorktreeSetup, + isCompacting, + isRevertingCheckpoint, + isWorking, + isPreparingWorktree, + latestTurn?.turnId, + ], ); // Stable renderItem — no closure deps. Row components read shared state @@ -2210,40 +2234,81 @@ function ProposedPlanTimelineRow({ } function WorkingTimelineRow({ row }: { row: Extract }) { - const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); + const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup } = + use(TimelineRowActivityCtx); + // One span for every label so the setup-to-working handoff swaps text in + // place instead of remounting the row. + const shimmer = isPreparingWorktree || isCompacting; + const label = isPreparingWorktree ? ( + "Setting up worktree…" + ) : isCompacting ? ( + + ) : row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + ); return (
-
+
- {isPreparingWorktree ? ( - <> - Setting up worktree… - Setting up worktree… - - ) : isCompacting ? ( - <> - - - - - - ) : row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} + {label} + {shimmer ? {label} : null} + {backgroundWorktreeSetup ? ( + + ) : null}
); } +/** + * Trailing chip in the working header while a setup script still runs after + * the agent started. Opens the stage list and live output in a popover; the + * chip leaves with the script, so nothing lingers in the timeline. + */ +function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { + const ctx = use(TimelineRowCtx); + const terminalId = snapshot.setupScript?.terminalId ?? null; + const openTerminal = ctx.onOpenWorktreeSetupTerminal; + const onOpenTerminal = useMemo( + () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null), + [openTerminal, terminalId], + ); + const scriptName = snapshot.setupScript?.name ?? "Setup script"; + return ( + + + } + > + + {scriptName} + + + + + + ); +} + function ThinkingTimelineRow() { const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 6fe54269cd10..6fbe7a2b9b5e 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -222,18 +222,34 @@ function StageRow({ ); } +/** The server keeps this many trailing lines; the box is sized for exactly that. */ +const OUTPUT_TAIL_LINES = 4; +const OUTPUT_TAIL_SLOTS = Array.from({ length: OUTPUT_TAIL_LINES }, (_, slot) => slot); + +/** + * Fixed-height window onto the script's last lines. Rows never wrap and the + * box never grows or shrinks, so streaming output cannot push the timeline + * around while the script runs. + */ function OutputTail({ lines, failed }: { lines: ReadonlyArray; failed: boolean }) { - if (lines.length === 0) return null; + const rows = OUTPUT_TAIL_SLOTS.map((slot) => ({ + slot, + line: lines[lines.length - OUTPUT_TAIL_LINES + slot] ?? "", + })); return (
-      {lines.join("\n")}
+      {rows.map(({ slot, line }) => (
+        
+ {line.length === 0 ? "\u00a0" : line} +
+ ))}
); } @@ -269,6 +285,47 @@ function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { ); } +/** + * One-line summary of a settled setup under a live turn. A clean finish is + * removed from the timeline altogether, so this only renders the outcomes + * worth keeping: a failed script, a failed setup, or a cancelled one. + */ +function CollapsedSummaryRow({ + snapshot, + totalElapsed, +}: { + snapshot: WorktreeSetupSnapshot; + totalElapsed: number | null; +}) { + const status: WorktreeSetupStage["status"] = + snapshot.phase === "failed" || snapshot.phase === "cancelled" + ? "failed" + : snapshot.stages.some((stage) => stage.id === "setup-script" && stage.status === "failed") + ? "failed" + : "done"; + const label = headerLabel(snapshot); + return ( +
+ + + + {label} + {totalElapsed !== null ? ( + + {formatDuration(totalElapsed)} + + ) : null} +
+ ); +} + export function WorktreeSetupCard({ snapshot, onCancel, @@ -277,8 +334,9 @@ export function WorktreeSetupCard({ embedded = false, }: WorktreeSetupCardProps & { /** - * The agent already started (async setup script), so the turn owns the - * "Working for" header and only the script's row sits among the worklog. + * The agent's turn is live and owns the "Working for" header. The stage + * list stays exactly where it was so the handoff never moves anything; a + * failed script that outlives the handoff collapses to a single row. */ embedded?: boolean; }) { @@ -292,24 +350,40 @@ export function WorktreeSetupCard({ })(); const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending"; - const stages = embedded - ? snapshot.stages.filter((stage) => stage.id === "setup-script") - : snapshot.stages; + const collapsed = embedded && !running; + // While running, the timeline's working row above the card carries the + // "Setting up worktree…" label (and keeps that slot when the agent takes + // over). The card only brings its own header for a settled outcome that + // has no working row to sit under. + const showHeader = !embedded && !running; + // The tail box is part of the script row's footprint while the script runs + // (and after it failed, so the last lines explain the failure). It mounts + // as soon as the script is running, empty lines and all, so the card takes + // its final height once instead of growing with each output line. + const showTail = + setupStage !== undefined && (setupStage.status === "running" || setupStage.status === "failed"); return (
- {embedded ? null : } -
- {stages.map((stage) => ( -
- - {stage.id === "setup-script" && - (stage.status === "running" || stage.status === "failed") ? ( - - ) : null} -
- ))} -
+ {showHeader ? : null} + {collapsed ? ( + + ) : ( +
+ {snapshot.stages.map((stage) => ( +
+ + {stage.id === "setup-script" && showTail ? ( + + ) : null} +
+ ))} +
+ )} {snapshot.phase === "failed" && snapshot.error ? (

{snapshot.error}

diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index a5e333f218d6..ce2c51fe5247 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -498,6 +498,8 @@ interface ComposerDraftStoreState { getDraftSession: (draftId: DraftId) => DraftSessionState | null; /** Resolves a server-thread ref back to a matching draft session when one exists. */ getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null; + /** The draft id that reserved a server-thread ref, while its draft record still exists. */ + getDraftIdByRef: (threadRef: ScopedThreadRef) => DraftId | null; getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null; getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; listDraftThreadKeys: () => string[]; @@ -2583,6 +2585,17 @@ const composerDraftStore = create()( } return null; }, + getDraftIdByRef: (threadRef) => { + for (const [draftId, draftSession] of Object.entries(get().draftThreadsByThreadKey)) { + if ( + draftSession.environmentId === threadRef.environmentId && + draftSession.threadId === threadRef.threadId + ) { + return DraftId.make(draftId); + } + } + return null; + }, getDraftThread: (threadRef) => { if (typeof threadRef === "string") { return get().getDraftSession(DraftId.make(threadRef)); diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 16c0c4dfcc6d..0a1bd024f3d6 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,105 +1,7 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; - -import ChatView from "../components/ChatView"; -import { threadHasStarted } from "../components/ChatView.logic"; -import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; -import { resolveThreadSyncPhase } from "../threadSync"; -import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { SidebarInset } from "~/components/ui/sidebar"; -import { - useEnvironmentThreadRefs, - useThreadDetail, - useThreadShell, - useThreadStatus, -} from "../state/entities"; -import { useEnvironmentQuery } from "../state/query"; -import { environmentShell } from "../state/shell"; - -function ChatThreadRouteView() { - const navigate = useNavigate(); - const threadRef = Route.useParams({ - select: (params) => resolveThreadRouteRef(params), - }); - const shell = useEnvironmentQuery( - threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), - ); - const serverThreadShell = useThreadShell(threadRef); - const serverThreadDetail = useThreadDetail(threadRef); - const serverThreadStatus = useThreadStatus(threadRef); - const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); - const bootstrapComplete = shell.data?.snapshot._tag === "Some"; - const environmentHasServerThreads = environmentThreadRefs.length > 0; - const draftThreadExists = useComposerDraftStore((store) => - threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, - ); - const draftThread = useComposerDraftStore((store) => - threadRef ? store.getDraftThreadByRef(threadRef) : null, - ); - const environmentHasDraftThreads = useComposerDraftStore((store) => { - if (!threadRef) { - return false; - } - return store.hasDraftThreadsInEnvironment(threadRef.environmentId); - }); - const renderState = resolveThreadRouteRenderState({ - bootstrapComplete, - serverThreadShellExists: serverThreadShell !== null, - serverThreadDetailExists: serverThreadDetail !== null, - serverThreadDetailDeleted: serverThreadStatus === "deleted", - draftThreadExists, - }); - const threadSyncPhase = resolveThreadSyncPhase({ - detailExists: serverThreadDetail !== null, - shellExists: serverThreadShell !== null, - status: serverThreadStatus, - }); - const serverThreadStarted = threadHasStarted(serverThreadDetail); - const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; - - useEffect(() => { - if (!threadRef || !bootstrapComplete) { - return; - } - - // Navigation already resolved onto this path, so a drop aimed here - // passed its landing check; once the thread reads as missing it can - // never be attached, release it even when there is nowhere to redirect. - if (renderState === "missing") { - const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); - clearPendingFileDropsForThread(threadRef); - if (environmentHasAnyThreads) { - void navigate({ to: "/", replace: true }); - } - } - }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); - - useEffect(() => { - if (!threadRef || !serverThreadStarted || !draftThread) { - return; - } - finalizePromotedDraftThreadByRef(threadRef); - }, [draftThread, serverThreadStarted, threadRef]); - - if (!threadRef) { - return null; - } - - return ( - - {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? ( - - ) : null} - - ); -} +import { createFileRoute } from "@tanstack/react-router"; +// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's +// promotion onto this route keeps the same ChatView mounted. export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ - component: ChatThreadRouteView, + component: () => null, }); diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 9d393f27e0bd..5a7f58cfbc66 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,94 +1,7 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; -import ChatView from "../components/ChatView"; -import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic"; -import { - DraftId, - markPromotedDraftThreadByRef, - useBackgroundDraftSubmissionPending, - useComposerDraftStore, -} from "../composerDraftStore"; -import { SidebarInset } from "../components/ui/sidebar"; -import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition"; -import { buildThreadRouteParams } from "../threadRoutes"; -import { useThread, useThreadRefs } from "../state/entities"; - -function DraftChatThreadRouteView() { - const navigate = useNavigate(); - const { draftId: rawDraftId } = Route.useParams(); - const draftId = DraftId.make(rawDraftId); - const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); - const threadRefs = useThreadRefs(); - const inferredThreadRef = draftSession - ? (threadRefs.find( - (ref) => - ref.environmentId === draftSession.environmentId && - ref.threadId === draftSession.threadId, - ) ?? null) - : null; - const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; - const serverThread = useThread(serverThreadRef); - const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); - const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread, - backgroundSubmissionPending, - }); - - useEffect(() => { - if (!inferredThreadRef || draftSession?.promotedTo) { - return; - } - markPromotedDraftThreadByRef(inferredThreadRef); - }, [draftSession?.promotedTo, inferredThreadRef]); - - useEffect(() => { - if (!canonicalThreadRef) { - return; - } - - let cancelled = false; - void waitForDraftHeroTransition().then(() => { - if (cancelled) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(canonicalThreadRef), - replace: true, - }); - }); - - return () => { - cancelled = true; - }; - }, [canonicalThreadRef, navigate]); - - useEffect(() => { - if (draftSession || canonicalThreadRef) { - return; - } - void navigate({ to: "/", replace: true }); - }, [canonicalThreadRef, draftSession, navigate]); - - if (!draftSession) { - return null; - } - - return ( - - - - ); -} +import { createFileRoute } from "@tanstack/react-router"; +// The view lives in the `_chat` layout (see ThreadRouteView) so a draft's +// promotion to `/$environmentId/$threadId` keeps the same ChatView mounted. export const Route = createFileRoute("/_chat/draft/$draftId")({ - component: DraftChatThreadRouteView, + component: () => null, }); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..980177004daa 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,8 +1,10 @@ -import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { ThreadRouteView } from "../components/ThreadRouteView"; +import { resolveThreadRouteTarget } from "../threadRoutes"; import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; @@ -175,10 +177,16 @@ function ChatRouteGlobalShortcuts() { } function ChatRouteLayout() { + // Both thread routes render here, not in their own leaf components, so the + // draft-to-thread promotion keeps one ChatView mounted across the swap. + const threadTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); return ( <> - + {threadTarget ? : } ); }