diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index c0d15f105af7..d9d74d926717 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -203,6 +203,7 @@ describe("ProjectSetupScriptRunner", () => { scriptCommand: "bun install", terminalId: "setup-setup", cwd: "/repo/worktrees/a", + async: true, }); expect(open).toHaveBeenCalledWith({ threadId: "thread-1", diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index c80dd535f514..d69198e30917 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -30,6 +30,8 @@ export interface ProjectSetupScriptRunnerResultStarted { readonly scriptCommand: string; readonly terminalId: string; readonly cwd: string; + /** False when the script's `async` flag asks the agent to wait for it. */ + readonly async: boolean; /** * Resolves when the script's shell prints the completion sentinel. The * exit code is null when the terminal exited or was closed before the @@ -410,6 +412,7 @@ export const make = Effect.gen(function* () { scriptCommand: script.command, terminalId, cwd, + async: script.async !== false, ...(observed ? { completion: observed.completion } : {}), } as const; }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7402e908fcf8..540436a69a5e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -44,6 +44,8 @@ import { WS_METHODS, WsRpcGroup, EditorId, + type WorktreeSetupSnapshot, + type WorktreeSetupStageId, } from "@t3tools/contracts"; import { computeDpopAccessTokenHash, @@ -10781,6 +10783,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { scriptCommand: "npm install", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", + async: true, }), ); @@ -11346,6 +11349,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { scriptCommand: "npm install", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", + async: true, }), ); let setupActivityAppendAttempt = 0; @@ -11449,6 +11453,153 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect.each([ + { caseName: "async setup scripts let the turn start before the script exits", async: true }, + { caseName: "sync setup scripts hold the turn until the script exits", async: false }, + ])("$caseName", ({ async }) => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const scriptExit = yield* Deferred.make(); + const runForThread = vi.fn( + ( + _: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "npm install", + terminalId: "setup-setup", + cwd: "/tmp/bootstrap-worktree", + async, + completion: Deferred.await(scriptExit).pipe(Effect.as({ exitCode: 0, durationMs: 1 })), + }), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), + createWorktree: () => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + projectSetupScriptRunner: { + runForThread, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make(`thread-bootstrap-${async ? "async" : "sync"}-setup`); + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchFiber = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-bootstrap-${async ? "async" : "sync"}-setup`), + threadId, + message: { + messageId: MessageId.make("msg-bootstrap-setup"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ).pipe(Effect.forkChild); + + const turnStarted = () => + dispatchedCommands.some((command) => command.type === "thread.turn.start"); + const snapshotWhere = (predicate: (snapshot: WorktreeSetupSnapshot) => boolean) => + Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeWorktreeSetup]({ threadId }).pipe( + Stream.filter( + (snapshot): snapshot is WorktreeSetupSnapshot => + snapshot !== null && predicate(snapshot), + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + ), + ); + const stageStatus = (snapshot: WorktreeSetupSnapshot, id: WorktreeSetupStageId) => + snapshot.stages.find((stage) => stage.id === id)?.status; + + if (async) { + // The turn is dispatched while the script is still running. + const started = yield* snapshotWhere( + (snapshot) => stageStatus(snapshot, "agent") === "done", + ); + assertTrue(turnStarted()); + assert.equal(started.phase, "running"); + assert.equal(stageStatus(started, "setup-script"), "running"); + yield* Fiber.join(dispatchFiber); + + yield* Deferred.succeed(scriptExit, undefined); + const settled = yield* snapshotWhere((snapshot) => snapshot.phase !== "running"); + assert.equal(settled.phase, "done"); + assert.equal(stageStatus(settled, "setup-script"), "done"); + return; + } + + // The script is running and the turn has not been dispatched yet. + const running = yield* snapshotWhere( + (snapshot) => stageStatus(snapshot, "setup-script") === "running", + ); + assert.equal(stageStatus(running, "agent"), "pending"); + assert.isFalse(turnStarted()); + + yield* Deferred.succeed(scriptExit, undefined); + yield* Fiber.join(dispatchFiber); + assertTrue(turnStarted()); + const settled = yield* snapshotWhere((snapshot) => snapshot.phase !== "running"); + assert.equal(settled.phase, "done"); + assert.equal(stageStatus(settled, "setup-script"), "done"); + assert.equal(stageStatus(settled, "agent"), "done"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6e5d9c02db39..a3d8bbba577e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1112,15 +1112,16 @@ const makeWsRpcLayer = ( const threadId = command.threadId; const track = (effect: Effect.Effect) => (tracked ? effect : Effect.void); - // Runs the setup script and, for tracked bootstraps, waits for it to - // exit so the card can show the exit code and the agent stage never - // starts on a half-installed tree. Untracked callers keep the old - // fire-and-forget behavior. + // Starts the setup script. For tracked bootstraps it returns the + // effect that waits for the script to exit and records the outcome + // on the card; whether the agent stage waits on it depends on the + // script's `async` flag. Returns null when nothing is left to await. + // Untracked callers keep the old fire-and-forget behavior. const runSetupProgram = () => Effect.gen(function* () { if (!bootstrap?.runSetupScript || !targetWorktreePath) { yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped")); - return; + return null; } const worktreePath = targetWorktreePath; const requestedAt = yield* nowIso; @@ -1197,21 +1198,38 @@ const makeWsRpcLayer = ( }), ); if (!tracked || !setupResult?.completion) { - return; + return null; } // The setup script is best effort, like the untracked path: a // failed install must not throw away the worktree the user just // waited for. The card keeps the failed stage and its terminal. - const completion = yield* setupResult.completion; - if (completion.exitCode === 0) { - yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "done"); - return; + // Forked right away so the terminal listener behind `completion` + // is always consumed, even when the turn dispatch fails before + // anyone would otherwise wait on it. The tracker update is a + // no-op once the snapshot has been dropped. + const completionFiber = yield* setupResult.completion.pipe( + Effect.flatMap((completion) => { + if (completion.exitCode === 0) { + return worktreeSetupTracker.stageStatus(threadId, "setup-script", "done"); + } + const detail = + completion.exitCode === null + ? "terminal closed before the script finished" + : `exit ${completion.exitCode}`; + return worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "failed", + detail, + ); + }), + Effect.forkDetach, + ); + if (!setupResult.async) { + yield* Fiber.join(completionFiber); + return null; } - const detail = - completion.exitCode === null - ? "terminal closed before the script finished" - : `exit ${completion.exitCode}`; - yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "failed", detail); + return completionFiber; }); const bootstrapProgram = Effect.gen(function* () { @@ -1414,7 +1432,7 @@ const makeWsRpcLayer = ( yield* refreshGitStatus(targetWorktreePath); } - yield* runSetupProgram(); + const pendingSetupScript = yield* runSetupProgram(); yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running")); // Past this point a cancel would roll back a thread whose turn has @@ -1423,11 +1441,21 @@ const makeWsRpcLayer = ( const started = yield* Effect.uninterruptible( dispatchFromClient(finalTurnStartCommand), ); - yield* track( - worktreeSetupTracker - .stageStatus(threadId, "agent", "done") - .pipe(Effect.andThen(worktreeSetupTracker.finish(threadId, "done"))), - ); + yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "done")); + // An async setup script outlives the handoff: the snapshot stays + // running so the client keeps its row next to the agent's work, + // and settles when the script exits. The turn already started, so + // the wait cannot fail the dispatch. + const settle = track(worktreeSetupTracker.finish(threadId, "done")); + if (pendingSetupScript) { + yield* Fiber.join(pendingSetupScript).pipe( + Effect.ignoreCause({ log: true }), + Effect.andThen(settle), + Effect.forkDetach, + ); + } else { + yield* settle; + } return started; }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 303427ebd2e7..6b8c65410edb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1430,6 +1430,16 @@ function releaseChatTimelineAnchor(); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1659,7 +1669,12 @@ export default function ChatView(props: ChatViewProps) { environmentId: EnvironmentId; threadId: ThreadId; ownerKey: string; - } | null>(null); + } | null>(() => { + // The draft route unmounts when it promotes to the created thread, while an + // async setup script may still be running. Adopt the ref the draft left. + const handed = pendingWorktreeSetupByThreadKey.get(routeThreadKey); + return handed ? { ...handed, ownerKey: routeThreadKey } : null; + }); const [heldWorktreeSetup, setHeldWorktreeSetup] = useState(null); // Set by "Work locally": the draft whose restored message should be resent // once the cancelled dispatch has settled and the draft is in local mode. @@ -3500,14 +3515,28 @@ export default function ChatView(props: ChatViewProps) { ? heldWorktreeSetup : null; // A finished card is dropped once the agent's turn shows in the timeline: - // the card belongs to the send, and the agent takes over from there. + // the card belongs to the send, and the agent takes over from there. An + // async setup script keeps the snapshot running past the handoff and its + // row leaves the moment the script exits cleanly; a failed script stays + // for the rest of the turn so the exit code and terminal remain reachable. const worktreeSetupDoneAndTurnVisible = - worktreeSetup?.phase === "done" && activeThread?.latestTurn?.startedAt != null; + worktreeSetup?.phase === "done" && + activeThread?.latestTurn?.startedAt != null && + (!isWorking || !worktreeSetup.stages.some((stage) => stage.status === "failed")); useEffect(() => { if (!worktreeSetupDoneAndTurnVisible) return; setWorktreeSetupRef(null); setHeldWorktreeSetup(null); }, [worktreeSetupDoneAndTurnVisible]); + // The handoff entry only matters while the setup is still running: once it + // settles in any phase, a later mount of the thread must not adopt it. + const worktreeSetupSettledKey = + worktreeSetup && worktreeSetup.phase !== "running" && worktreeSetupRef + ? scopedThreadKey(scopeThreadRef(worktreeSetupRef.environmentId, worktreeSetupRef.threadId)) + : null; + useEffect(() => { + if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey); + }, [worktreeSetupSettledKey]); const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); @@ -7515,6 +7544,12 @@ export default function ChatView(props: ChatViewProps) { ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey } : null, ); + if (baseBranchForWorktree) { + pendingWorktreeSetupByThreadKey.set( + scopedThreadKey(scopeThreadRef(environmentId, threadIdForSend)), + { environmentId, threadId: threadIdForSend }, + ); + } const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b0a..15cca412ba43 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -113,6 +113,7 @@ export default function ProjectScriptsControl({ command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, + waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, keybinding: null, previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 59b4a0c9856b..39cd8f8318a8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1148,17 +1148,18 @@ describe("deriveMessagesTimelineRows", () => { id: WORKTREE_SETUP_ROW_ID, createdAt: "2026-01-01T00:00:00Z", snapshot, + embedded: false, }, ]); - // Once the agent has replied the finished card stays under the send. + // A failed setup never handed off, so the card stays under the send. const withMessages = deriveMessagesTimelineRows({ timelineEntries: [userEntry, assistantEntry], isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", turnDiffSummaries: [], supportsConversationRollback: false, - worktreeSetup: { ...snapshot, phase: "done" }, + worktreeSetup: { ...snapshot, phase: "failed" }, }); expect(withMessages.map((row) => row.kind)).toEqual([ "message", @@ -1166,6 +1167,73 @@ describe("deriveMessagesTimelineRows", () => { "working", "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. + const stage = (id: "agent" | "setup-script", status: "done" | "running") => + ({ + id, + status, + startedAt: "2026-01-01T00:00:10Z", + endedAt: status === "done" ? "2026-01-01T00:00:11Z" : null, + percent: null, + detail: null, + tail: [], + }) as const; + const asyncSnapshot: WorktreeSetupSnapshot = { + ...snapshot, + stages: [stage("setup-script", "running"), stage("agent", "done")], + }; + const liveTurn = { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:11Z", + completedAt: null, + } as const; + const asyncRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + latestTurn: liveTurn, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + 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 }); + + // Dispatched but not yet visible as a turn: the full card stays put so + // nothing collapses during the handoff. + const handoffRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: asyncSnapshot, + }); + expect(handoffRows.map((row) => row.kind)).toEqual(["message", "worktree-setup"]); + expect(handoffRows[1]).toMatchObject({ kind: "worktree-setup", embedded: false }); + + // A script that already finished has nothing left to show once the turn is live. + const finishedRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + latestTurn: liveTurn, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + worktreeSetup: { + ...asyncSnapshot, + stages: [stage("setup-script", "done"), stage("agent", "done")], + }, + }); + expect(finishedRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]); }); 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 3d7b1e12284e..89bcf214783f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -400,6 +400,8 @@ export type MessagesTimelineRow = id: string; createdAt: string | null; snapshot: WorktreeSetupSnapshot; + /** The agent already started; render only the script row under the turn header. */ + embedded: boolean; }; export interface StableMessagesTimelineRowsState { @@ -1260,15 +1262,23 @@ export function deriveMessagesTimelineRows(input: { }); } - // The setup card takes the place of the working and thinking placeholders - // while a worktree is being prepared. It stays after the setup settles so a - // failure and its actions remain visible until the thread state moves on. - if (input.worktreeSetup) { + // 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. + const setupHandedOff = + input.worktreeSetup !== null && + input.worktreeSetup !== undefined && + worktreeSetupAgentStarted(input.worktreeSetup) && + input.latestTurn?.startedAt != null; + if (input.worktreeSetup && !setupHandedOff) { const setupRow = { kind: "worktree-setup", id: WORKTREE_SETUP_ROW_ID, createdAt: input.worktreeSetup.startedAt, snapshot: input.worktreeSetup, + embedded: false, } 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 @@ -1287,6 +1297,31 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } + // An async setup script outlives the handoff. The turn owns the header, so + // the script's row sits first under it, ahead of the agent's own work. A + // script that already finished (or never ran) has nothing left to show. + const setupScriptStage = input.worktreeSetup?.stages.find((stage) => stage.id === "setup-script"); + if ( + input.worktreeSetup && + setupHandedOff && + (setupScriptStage?.status === "running" || setupScriptStage?.status === "failed") + ) { + const setupRow = { + kind: "worktree-setup", + id: WORKTREE_SETUP_ROW_ID, + createdAt: input.worktreeSetup.startedAt, + snapshot: input.worktreeSetup, + embedded: true, + } as const; + const workingRowIndex = nextRows.findIndex((row) => row.kind === "working"); + if (workingRowIndex >= 0) { + nextRows.splice(workingRowIndex + 1, 0, setupRow); + } else { + // The turn already finished (or has not been dispatched yet): the row + // trails the reply so a still-running script stays visible after it. + nextRows.push(setupRow); + } + } if (input.isWorking && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", @@ -1300,6 +1335,11 @@ export function deriveMessagesTimelineRows(input: { export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; +/** True once the bootstrap handed off to the agent (async setup script may still run). */ +function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { + return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); +} + type MessagesTimelineRowsInput = Parameters[0]; export interface MessagesTimelineRowsProjection { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bc3a66f4e28c..b6383724e036 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1464,8 +1464,11 @@ function WorktreeSetupTimelineRow({ return ( ); diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 8b838fb81cc9..6fe54269cd10 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -10,16 +10,16 @@ import { ChevronRightIcon, CircleAlertIcon, CircleIcon, - GitBranchIcon, LaptopIcon, MinusIcon, TerminalIcon, XIcon, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; import { Spinner } from "~/components/ui/spinner"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; import { cn } from "~/lib/utils"; interface WorktreeSetupCardProps { @@ -74,20 +74,95 @@ function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) { function stageRowClassName(status: WorktreeSetupStage["status"]): string { switch (status) { - case "running": - return "text-foreground"; case "failed": return "text-destructive-foreground"; case "warning": return "text-warning-foreground"; case "pending": + return "text-secondary-label opacity-40"; + case "running": case "skipped": - return "text-secondary-label opacity-50"; case "done": return "text-secondary-label"; } } +/** Same shimmer treatment as the live tool rows in the timeline. */ +function ShimmerOverlay({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +function headerLabel(snapshot: WorktreeSetupSnapshot): string { + switch (snapshot.phase) { + case "running": + return "Setting up worktree…"; + case "done": + return snapshot.stages.some((stage) => stage.status === "failed") + ? "Worktree ready, setup script failed" + : "Worktree ready"; + case "failed": + return "Worktree setup failed"; + case "cancelled": + return "Worktree setup cancelled"; + } +} + +/** + * Occupies the same slot, with the same metrics, as the "Working for" header + * so the handoff to the agent's turn only swaps the text. + */ +function SetupHeaderRow({ + snapshot, + totalElapsed, +}: { + snapshot: WorktreeSetupSnapshot; + totalElapsed: number | null; +}) { + const running = snapshot.phase === "running"; + const failed = snapshot.phase === "failed"; + const finishedWithFailedStage = + snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed"); + const text = headerLabel(snapshot); + const tone = failed + ? "text-destructive-foreground" + : finishedWithFailedStage + ? "text-warning-foreground" + : "text-muted-foreground"; + return ( +
+
+ + {text} + {running ? {text} : null} + + {totalElapsed !== null ? ( + + {formatDuration(totalElapsed)} + + ) : null} +
+
+ ); +} + +/** One stage, rendered like a live work entry row. */ function StageRow({ stage, nowMs, @@ -100,44 +175,49 @@ function StageRow({ const elapsed = stageElapsedMs(stage, nowMs); const label = stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id); - const showBar = stage.id === "checkout" && stage.status === "running" && stage.percent !== null; + const running = stage.status === "running"; const trailing = stage.status === "pending" ? null : stage.status === "skipped" ? (stage.detail ?? "skipped") - : stage.detail; - + : stage.id === "checkout" && running && stage.percent !== null + ? `${stage.percent}%` + : stage.detail; return (
- + {label} - - {showBar ? ( - <> - - + {trailing ? ( + + {trailing} + + ) : null} + {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? ( + + {formatDuration(elapsed)} + + ) : null} + {running ? ( + + + + - {stage.percent}% - - ) : null} - {!showBar && trailing ? {trailing} : null} - {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? ( - {formatDuration(elapsed)} - ) : null} - + {label} + + + ) : null}
); } @@ -158,19 +238,35 @@ function OutputTail({ lines, failed }: { lines: ReadonlyArray; failed: b ); } -function headerLabel(snapshot: WorktreeSetupSnapshot): string { - switch (snapshot.phase) { - case "running": - return "Creating worktree"; - case "done": - return snapshot.stages.some((stage) => stage.status === "failed") - ? "Worktree ready, setup script failed" - : "Worktree ready"; - case "failed": - return "Worktree setup failed"; - case "cancelled": - return "Worktree setup cancelled"; - } +function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { + return ( +
+ {snapshot.branch ? ( + <> +
Branch
+
{snapshot.branch}
+ + ) : null} + {snapshot.baseRef ? ( + <> +
Base
+
{snapshot.baseRef}
+ + ) : null} + {snapshot.worktreePath ? ( + <> +
Path
+
{snapshot.worktreePath}
+ + ) : null} + {snapshot.setupScript ? ( + <> +
Setup
+
{snapshot.setupScript.command}
+ + ) : null} +
+ ); } export function WorktreeSetupCard({ @@ -178,7 +274,14 @@ export function WorktreeSetupCard({ onCancel, onWorkLocally, onOpenTerminal, -}: WorktreeSetupCardProps) { + 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. + */ + embedded?: boolean; +}) { const running = snapshot.phase === "running"; const nowMs = useNowWhile(running); const [detailsOpen, setDetailsOpen] = useState(false); @@ -188,79 +291,35 @@ export function WorktreeSetupCard({ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; })(); const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); - const failed = snapshot.phase === "failed"; - const finishedWithFailedStage = - snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed"); - const headerClassName = failed - ? "text-destructive-foreground" - : finishedWithFailedStage - ? "text-warning-foreground" - : snapshot.phase === "cancelled" - ? "text-muted-foreground" - : "text-secondary-label"; + const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending"; + const stages = embedded + ? snapshot.stages.filter((stage) => stage.id === "setup-script") + : snapshot.stages; return ( -
-
- - - - {headerLabel(snapshot)} - {totalElapsed !== null ? ( - - {formatDuration(totalElapsed)} - - ) : null} +
+ {embedded ? null : } +
+ {stages.map((stage) => ( +
+ + {stage.id === "setup-script" && + (stage.status === "running" || stage.status === "failed") ? ( + + ) : null} +
+ ))}
- {snapshot.stages.map((stage) => ( -
- - {stage.id === "setup-script" && - (stage.status === "running" || stage.status === "failed") ? ( - - ) : null} -
- ))} - - {failed && snapshot.error ? ( -

{snapshot.error}

+ {snapshot.phase === "failed" && snapshot.error ? ( +

{snapshot.error}

) : null} - {detailsOpen ? ( -
- {snapshot.branch ? ( - <> -
Branch
-
{snapshot.branch}
- - ) : null} - {snapshot.baseRef ? ( - <> -
Base
-
{snapshot.baseRef}
- - ) : null} - {snapshot.worktreePath ? ( - <> -
Path
-
{snapshot.worktreePath}
- - ) : null} - {snapshot.setupScript ? ( - <> -
Setup
-
{snapshot.setupScript.command}
- - ) : null} -
- ) : null} + {detailsOpen ? : null} -
+ {/* Indented so the first label lines up with the stage labels: the icon + column, minus the xs button's own horizontal padding. */} +
- - {onOpenTerminal && setupStage && setupStage.status !== "pending" ? ( - ) : null} {onWorkLocally ? ( - ) : null} {onCancel && running ? ( - diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 74b189a02fc5..774398feda06 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -85,6 +85,8 @@ export interface NewProjectScriptInput { command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; + /** Setup scripts only: hold the agent until the script exits. */ + waitForSetup: boolean; keybinding: string | null; /** Optional URL to open in the in-app preview when this script runs. */ previewUrl: string | null; @@ -99,6 +101,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { command: "", icon: "play", runOnWorktreeCreate: false, + waitForSetup: false, keybinding: null, previewUrl: null, autoOpenPreview: false, @@ -123,6 +126,7 @@ export function editorRequestForScript( command: script.command, icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, + waitForSetup: script.runOnWorktreeCreate && script.async === false, keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), previewUrl: script.previewUrl ?? null, autoOpenPreview: script.autoOpenPreview ?? false, @@ -158,6 +162,7 @@ export function ProjectScriptEditorDialog({ const [icon, setIcon] = useState("play"); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); + const [waitForSetup, setWaitForSetup] = useState(false); const [keybinding, setKeybinding] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); @@ -188,6 +193,7 @@ export function ProjectScriptEditorDialog({ setIcon(request.initial.icon); setIconPickerOpen(false); setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); + setWaitForSetup(request.initial.waitForSetup); setKeybinding(request.initial.keybinding ?? ""); setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); @@ -247,6 +253,7 @@ export function ProjectScriptEditorDialog({ command: trimmedCommand, icon, runOnWorktreeCreate, + waitForSetup: runOnWorktreeCreate && waitForSetup, keybinding: keybindingRule?.key ?? null, previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, @@ -396,6 +403,18 @@ export function ProjectScriptEditorDialog({ onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} /> +