From a8d794035f117575f0b651e01d15f358931d4087 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:29:26 -0700 Subject: [PATCH 1/4] feat(web): render worktree setup inline and let the agent start while the setup script runs The worktree setup card was a boxed stepper that swapped wholesale for the turn header once the agent started, and the agent could not start until the setup script had exited. The setup now renders as the same shimmering header row the turn uses plus worklog-style rows underneath, so the handoff only swaps the header text. Setup scripts default to async: the agent starts right after checkout and the script's row stays under the turn header until it exits, then leaves (or stays with its exit code if it failed). A new `async: false` on a t3.json script, or the "Wait for it to finish" toggle in the action editor, keeps the old blocking behaviour. Co-Authored-By: Claude Fable 5 --- .../project/ProjectSetupScriptRunner.test.ts | 1 + .../src/project/ProjectSetupScriptRunner.ts | 3 + apps/server/src/server.test.ts | 151 ++++++++++ apps/server/src/ws.ts | 65 ++-- apps/web/src/components/ChatView.tsx | 39 ++- .../src/components/ProjectScriptsControl.tsx | 1 + .../chat/MessagesTimeline.logic.test.ts | 33 ++- .../components/chat/MessagesTimeline.logic.ts | 39 ++- .../src/components/chat/MessagesTimeline.tsx | 7 +- .../src/components/chat/WorktreeSetupCard.tsx | 280 +++++++++++------- .../src/components/projectScriptEditor.tsx | 19 ++ .../settings/ProjectActionsSettings.tsx | 1 + apps/web/src/projectScripts.test.ts | 21 ++ apps/web/src/projectScripts.ts | 2 + packages/contracts/src/orchestration.ts | 6 + packages/contracts/src/t3ProjectFile.test.ts | 4 +- packages/contracts/src/t3ProjectFile.ts | 6 + packages/shared/src/t3ProjectFile.test.ts | 1 + 18 files changed, 534 insertions(+), 145 deletions(-) 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..4ae84c3c2f43 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,33 @@ 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; + const awaitCompletion = 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, + ); + }), + ); + if (!setupResult.async) { + yield* awaitCompletion; + 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 awaitCompletion; }); const bootstrapProgram = Effect.gen(function* () { @@ -1414,7 +1427,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 +1436,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* 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..f2fc5aa069ef 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,24 @@ 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; + if (worktreeSetupRef) { + pendingWorktreeSetupByThreadKey.delete( + scopedThreadKey(scopeThreadRef(worktreeSetupRef.environmentId, worktreeSetupRef.threadId)), + ); + } setWorktreeSetupRef(null); setHeldWorktreeSetup(null); - }, [worktreeSetupDoneAndTurnVisible]); + }, [worktreeSetupDoneAndTurnVisible, worktreeSetupRef]); const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); @@ -7515,6 +7540,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..06a2e49d8948 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,34 @@ 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 agentStage = { + id: "agent", + status: "done", + startedAt: "2026-01-01T00:00:10Z", + endedAt: "2026-01-01T00:00:11Z", + percent: null, + detail: null, + tail: [], + } as const; + const asyncSnapshot: WorktreeSetupSnapshot = { ...snapshot, stages: [agentStage] }; + const asyncRows = deriveMessagesTimelineRows({ + timelineEntries: [userEntry], + 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 }); }); 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..6f3be2ab3ead 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,20 @@ 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 starts, 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. + const setupAgentStarted = + input.worktreeSetup !== null && + input.worktreeSetup !== undefined && + worktreeSetupAgentStarted(input.worktreeSetup); + if (input.worktreeSetup && !setupAgentStarted) { 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 +1294,25 @@ 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. + if (input.worktreeSetup && setupAgentStarted) { + 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 +1326,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). */ +export 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))} /> +