authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-14 17:50:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 17:50:49-07:00
log5ea6439816470288d3f2b6b43635fea41fbbb101
tree01e988511d317d59d836be8d1126b27eda77aac8
parent6dbea7ed0947780694943bd86c4cd22cebbcd8eb
signature Signed by PGP key B5690EEEBB952194

feat(web): inline worktree setup rows and async setup scripts (#11832)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

18 files changed, 590 insertions(+), 144 deletions(-)

apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
...@@ -203,6 +203,7 @@ describe("ProjectSetupScriptRunner", () => {...@@ -203,6 +203,7 @@ describe("ProjectSetupScriptRunner", () => {
203 scriptCommand: "bun install",203 scriptCommand: "bun install",
204 terminalId: "setup-setup",204 terminalId: "setup-setup",
205 cwd: "/repo/worktrees/a",205 cwd: "/repo/worktrees/a",
206 async: true,
206 });207 });
207 expect(open).toHaveBeenCalledWith({208 expect(open).toHaveBeenCalledWith({
208 threadId: "thread-1",209 threadId: "thread-1",
apps/server/src/project/ProjectSetupScriptRunner.ts+3
...@@ -30,6 +30,8 @@ export interface ProjectSetupScriptRunnerResultStarted {...@@ -30,6 +30,8 @@ export interface ProjectSetupScriptRunnerResultStarted {
30 readonly scriptCommand: string;30 readonly scriptCommand: string;
31 readonly terminalId: string;31 readonly terminalId: string;
32 readonly cwd: string;32 readonly cwd: string;
33 /** False when the script's `async` flag asks the agent to wait for it. */
34 readonly async: boolean;
33 /**35 /**
34 * Resolves when the script's shell prints the completion sentinel. The36 * Resolves when the script's shell prints the completion sentinel. The
35 * exit code is null when the terminal exited or was closed before the37 * exit code is null when the terminal exited or was closed before the
...@@ -410,6 +412,7 @@ export const make = Effect.gen(function* () {...@@ -410,6 +412,7 @@ export const make = Effect.gen(function* () {
410 scriptCommand: script.command,412 scriptCommand: script.command,
411 terminalId,413 terminalId,
412 cwd,414 cwd,
415 async: script.async !== false,
413 ...(observed ? { completion: observed.completion } : {}),416 ...(observed ? { completion: observed.completion } : {}),
414 } as const;417 } as const;
415 });418 });
apps/server/src/server.test.ts+151
...@@ -44,6 +44,8 @@ import {...@@ -44,6 +44,8 @@ import {
44 WS_METHODS,44 WS_METHODS,
45 WsRpcGroup,45 WsRpcGroup,
46 EditorId,46 EditorId,
47 type WorktreeSetupSnapshot,
48 type WorktreeSetupStageId,
47} from "@t3tools/contracts";49} from "@t3tools/contracts";
48import {50import {
49 computeDpopAccessTokenHash,51 computeDpopAccessTokenHash,
...@@ -10759,6 +10761,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10759,6 +10761,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10759 scriptCommand: "npm install",10761 scriptCommand: "npm install",
10760 terminalId: "setup-setup",10762 terminalId: "setup-setup",
10761 cwd: "/tmp/bootstrap-worktree",10763 cwd: "/tmp/bootstrap-worktree",
10764 async: true,
10762 }),10765 }),
10763 );10766 );
1076410767
...@@ -11324,6 +11327,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11324,6 +11327,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11324 scriptCommand: "npm install",11327 scriptCommand: "npm install",
11325 terminalId: "setup-setup",11328 terminalId: "setup-setup",
11326 cwd: "/tmp/bootstrap-worktree",11329 cwd: "/tmp/bootstrap-worktree",
11330 async: true,
11327 }),11331 }),
11328 );11332 );
11329 let setupActivityAppendAttempt = 0;11333 let setupActivityAppendAttempt = 0;
...@@ -11427,6 +11431,153 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11427,6 +11431,153 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11427 }).pipe(Effect.provide(NodeHttpServer.layerTest)),11431 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11428 );11432 );
1142911433
11434 it.effect.each([
11435 { caseName: "async setup scripts let the turn start before the script exits", async: true },
11436 { caseName: "sync setup scripts hold the turn until the script exits", async: false },
11437 ])("$caseName", ({ async }) =>
11438 Effect.gen(function* () {
11439 const dispatchedCommands: Array<OrchestrationCommand> = [];
11440 const scriptExit = yield* Deferred.make<void>();
11441 const runForThread = vi.fn(
11442 (
11443 _: Parameters<
11444 ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]
11445 >[0],
11446 ) =>
11447 Effect.succeed({
11448 status: "started" as const,
11449 scriptId: "setup",
11450 scriptName: "Setup",
11451 scriptCommand: "npm install",
11452 terminalId: "setup-setup",
11453 cwd: "/tmp/bootstrap-worktree",
11454 async,
11455 completion: Deferred.await(scriptExit).pipe(Effect.as({ exitCode: 0, durationMs: 1 })),
11456 }),
11457 );
11458
11459 yield* buildAppUnderTest({
11460 layers: {
11461 vcsDriver: {
11462 isInsideWorkTree: () => Effect.succeed(true),
11463 },
11464 gitVcsDriver: {
11465 execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
11466 createWorktree: () =>
11467 Effect.succeed({
11468 worktree: {
11469 refName: "t3code/bootstrap-refName",
11470 path: "/tmp/bootstrap-worktree",
11471 },
11472 }),
11473 },
11474 orchestrationEngine: {
11475 dispatch: (command) =>
11476 Effect.sync(() => {
11477 dispatchedCommands.push(command);
11478 return { sequence: dispatchedCommands.length };
11479 }),
11480 readEvents: () => Stream.empty,
11481 },
11482 projectSetupScriptRunner: {
11483 runForThread,
11484 },
11485 },
11486 });
11487
11488 const createdAt = "2026-01-01T00:00:00.000Z";
11489 const threadId = ThreadId.make(`thread-bootstrap-${async ? "async" : "sync"}-setup`);
11490 const wsUrl = yield* getWsServerUrl("/ws");
11491 const dispatchFiber = yield* Effect.scoped(
11492 withWsRpcClient(wsUrl, (client) =>
11493 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
11494 type: "thread.turn.start",
11495 commandId: CommandId.make(`cmd-bootstrap-${async ? "async" : "sync"}-setup`),
11496 threadId,
11497 message: {
11498 messageId: MessageId.make("msg-bootstrap-setup"),
11499 role: "user",
11500 text: "hello",
11501 attachments: [],
11502 },
11503 modelSelection: defaultModelSelection,
11504 runtimeMode: "full-access",
11505 interactionMode: "default",
11506 bootstrap: {
11507 createThread: {
11508 projectId: defaultProjectId,
11509 title: "Bootstrap Thread",
11510 modelSelection: defaultModelSelection,
11511 runtimeMode: "full-access",
11512 interactionMode: "default",
11513 branch: "main",
11514 worktreePath: null,
11515 createdAt,
11516 },
11517 prepareWorktree: {
11518 projectCwd: "/tmp/project",
11519 baseBranch: "main",
11520 branch: "t3code/bootstrap-refName",
11521 },
11522 runSetupScript: true,
11523 },
11524 createdAt,
11525 }),
11526 ),
11527 ).pipe(Effect.forkChild);
11528
11529 const turnStarted = () =>
11530 dispatchedCommands.some((command) => command.type === "thread.turn.start");
11531 const snapshotWhere = (predicate: (snapshot: WorktreeSetupSnapshot) => boolean) =>
11532 Effect.scoped(
11533 withWsRpcClient(wsUrl, (client) =>
11534 client[WS_METHODS.subscribeWorktreeSetup]({ threadId }).pipe(
11535 Stream.filter(
11536 (snapshot): snapshot is WorktreeSetupSnapshot =>
11537 snapshot !== null && predicate(snapshot),
11538 ),
11539 Stream.runHead,
11540 Effect.map(Option.getOrThrow),
11541 ),
11542 ),
11543 );
11544 const stageStatus = (snapshot: WorktreeSetupSnapshot, id: WorktreeSetupStageId) =>
11545 snapshot.stages.find((stage) => stage.id === id)?.status;
11546
11547 if (async) {
11548 // The turn is dispatched while the script is still running.
11549 const started = yield* snapshotWhere(
11550 (snapshot) => stageStatus(snapshot, "agent") === "done",
11551 );
11552 assertTrue(turnStarted());
11553 assert.equal(started.phase, "running");
11554 assert.equal(stageStatus(started, "setup-script"), "running");
11555 yield* Fiber.join(dispatchFiber);
11556
11557 yield* Deferred.succeed(scriptExit, undefined);
11558 const settled = yield* snapshotWhere((snapshot) => snapshot.phase !== "running");
11559 assert.equal(settled.phase, "done");
11560 assert.equal(stageStatus(settled, "setup-script"), "done");
11561 return;
11562 }
11563
11564 // The script is running and the turn has not been dispatched yet.
11565 const running = yield* snapshotWhere(
11566 (snapshot) => stageStatus(snapshot, "setup-script") === "running",
11567 );
11568 assert.equal(stageStatus(running, "agent"), "pending");
11569 assert.isFalse(turnStarted());
11570
11571 yield* Deferred.succeed(scriptExit, undefined);
11572 yield* Fiber.join(dispatchFiber);
11573 assertTrue(turnStarted());
11574 const settled = yield* snapshotWhere((snapshot) => snapshot.phase !== "running");
11575 assert.equal(settled.phase, "done");
11576 assert.equal(stageStatus(settled, "setup-script"), "done");
11577 assert.equal(stageStatus(settled, "agent"), "done");
11578 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11579 );
11580
11430 it.effect("cleans up created bootstrap threads when worktree creation defects", () =>11581 it.effect("cleans up created bootstrap threads when worktree creation defects", () =>
11431 Effect.gen(function* () {11582 Effect.gen(function* () {
11432 const dispatchedCommands: Array<OrchestrationCommand> = [];11583 const dispatchedCommands: Array<OrchestrationCommand> = [];
apps/server/src/ws.ts+49-21
...@@ -1112,15 +1112,16 @@ const makeWsRpcLayer = (...@@ -1112,15 +1112,16 @@ const makeWsRpcLayer = (
1112 const threadId = command.threadId;1112 const threadId = command.threadId;
1113 const track = (effect: Effect.Effect<void>) => (tracked ? effect : Effect.void);1113 const track = (effect: Effect.Effect<void>) => (tracked ? effect : Effect.void);
11141114
1115 // Runs the setup script and, for tracked bootstraps, waits for it to1115 // Starts the setup script. For tracked bootstraps it returns the
1116 // exit so the card can show the exit code and the agent stage never1116 // effect that waits for the script to exit and records the outcome
1117 // starts on a half-installed tree. Untracked callers keep the old1117 // on the card; whether the agent stage waits on it depends on the
1118 // fire-and-forget behavior.1118 // script's `async` flag. Returns null when nothing is left to await.
1119 // Untracked callers keep the old fire-and-forget behavior.
1119 const runSetupProgram = () =>1120 const runSetupProgram = () =>
1120 Effect.gen(function* () {1121 Effect.gen(function* () {
1121 if (!bootstrap?.runSetupScript || !targetWorktreePath) {1122 if (!bootstrap?.runSetupScript || !targetWorktreePath) {
1122 yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped"));1123 yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped"));
1123 return;1124 return null;
1124 }1125 }
1125 const worktreePath = targetWorktreePath;1126 const worktreePath = targetWorktreePath;
1126 const requestedAt = yield* nowIso;1127 const requestedAt = yield* nowIso;
...@@ -1197,21 +1198,38 @@ const makeWsRpcLayer = (...@@ -1197,21 +1198,38 @@ const makeWsRpcLayer = (
1197 }),1198 }),
1198 );1199 );
1199 if (!tracked || !setupResult?.completion) {1200 if (!tracked || !setupResult?.completion) {
1200 return;1201 return null;
1201 }1202 }
1202 // The setup script is best effort, like the untracked path: a1203 // The setup script is best effort, like the untracked path: a
1203 // failed install must not throw away the worktree the user just1204 // failed install must not throw away the worktree the user just
1204 // waited for. The card keeps the failed stage and its terminal.1205 // waited for. The card keeps the failed stage and its terminal.
1205 const completion = yield* setupResult.completion;1206 // Forked right away so the terminal listener behind `completion`
1206 if (completion.exitCode === 0) {1207 // is always consumed, even when the turn dispatch fails before
1207 yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "done");1208 // anyone would otherwise wait on it. The tracker update is a
1208 return;1209 // no-op once the snapshot has been dropped.
1210 const completionFiber = yield* setupResult.completion.pipe(
1211 Effect.flatMap((completion) => {
1212 if (completion.exitCode === 0) {
1213 return worktreeSetupTracker.stageStatus(threadId, "setup-script", "done");
1214 }
1215 const detail =
1216 completion.exitCode === null
1217 ? "terminal closed before the script finished"
1218 : `exit ${completion.exitCode}`;
1219 return worktreeSetupTracker.stageStatus(
1220 threadId,
1221 "setup-script",
1222 "failed",
1223 detail,
1224 );
1225 }),
1226 Effect.forkDetach,
1227 );
1228 if (!setupResult.async) {
1229 yield* Fiber.join(completionFiber);
1230 return null;
1209 }1231 }
1210 const detail =1232 return completionFiber;
1211 completion.exitCode === null
1212 ? "terminal closed before the script finished"
1213 : `exit ${completion.exitCode}`;
1214 yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "failed", detail);
1215 });1233 });
12161234
1217 const bootstrapProgram = Effect.gen(function* () {1235 const bootstrapProgram = Effect.gen(function* () {
...@@ -1414,7 +1432,7 @@ const makeWsRpcLayer = (...@@ -1414,7 +1432,7 @@ const makeWsRpcLayer = (
1414 yield* refreshGitStatus(targetWorktreePath);1432 yield* refreshGitStatus(targetWorktreePath);
1415 }1433 }
14161434
1417 yield* runSetupProgram();1435 const pendingSetupScript = yield* runSetupProgram();
14181436
1419 yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running"));1437 yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running"));
1420 // Past this point a cancel would roll back a thread whose turn has1438 // Past this point a cancel would roll back a thread whose turn has
...@@ -1423,11 +1441,21 @@ const makeWsRpcLayer = (...@@ -1423,11 +1441,21 @@ const makeWsRpcLayer = (
1423 const started = yield* Effect.uninterruptible(1441 const started = yield* Effect.uninterruptible(
1424 dispatchFromClient(finalTurnStartCommand),1442 dispatchFromClient(finalTurnStartCommand),
1425 );1443 );
1426 yield* track(1444 yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "done"));
1427 worktreeSetupTracker1445 // An async setup script outlives the handoff: the snapshot stays
1428 .stageStatus(threadId, "agent", "done")1446 // running so the client keeps its row next to the agent's work,
1429 .pipe(Effect.andThen(worktreeSetupTracker.finish(threadId, "done"))),1447 // and settles when the script exits. The turn already started, so
1430 );1448 // the wait cannot fail the dispatch.
1449 const settle = track(worktreeSetupTracker.finish(threadId, "done"));
1450 if (pendingSetupScript) {
1451 yield* Fiber.join(pendingSetupScript).pipe(
1452 Effect.ignoreCause({ log: true }),
1453 Effect.andThen(settle),
1454 Effect.forkDetach,
1455 );
1456 } else {
1457 yield* settle;
1458 }
1431 return started;1459 return started;
1432 });1460 });
14331461
apps/web/src/components/ChatView.tsx+38-3
...@@ -1430,6 +1430,16 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n...@@ -1430,6 +1430,16 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n
1430 return current.messageId === null ? current : { ...current, messageId: null };1430 return current.messageId === null ? current : { ...current, messageId: null };
1431}1431}
14321432
1433/**
1434 * Worktree setups whose async setup script may still be running after the
1435 * draft route hands off to the created thread. Keyed by scoped thread key;
1436 * entries are removed once the setup settles.
1437 */
1438const pendingWorktreeSetupByThreadKey = new Map<
1439 string,
1440 { environmentId: EnvironmentId; threadId: ThreadId }
1441>();
1442
1433export default function ChatView(props: ChatViewProps) {1443export default function ChatView(props: ChatViewProps) {
1434 const {1444 const {
1435 environmentId,1445 environmentId,
...@@ -1659,7 +1669,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -1659,7 +1669,12 @@ export default function ChatView(props: ChatViewProps) {
1659 environmentId: EnvironmentId;1669 environmentId: EnvironmentId;
1660 threadId: ThreadId;1670 threadId: ThreadId;
1661 ownerKey: string;1671 ownerKey: string;
1662 } | null>(null);1672 } | null>(() => {
1673 // The draft route unmounts when it promotes to the created thread, while an
1674 // async setup script may still be running. Adopt the ref the draft left.
1675 const handed = pendingWorktreeSetupByThreadKey.get(routeThreadKey);
1676 return handed ? { ...handed, ownerKey: routeThreadKey } : null;
1677 });
1663 const [heldWorktreeSetup, setHeldWorktreeSetup] = useState<WorktreeSetupSnapshot | null>(null);1678 const [heldWorktreeSetup, setHeldWorktreeSetup] = useState<WorktreeSetupSnapshot | null>(null);
1664 // Set by "Work locally": the draft whose restored message should be resent1679 // Set by "Work locally": the draft whose restored message should be resent
1665 // once the cancelled dispatch has settled and the draft is in local mode.1680 // once the cancelled dispatch has settled and the draft is in local mode.
...@@ -3500,14 +3515,28 @@ export default function ChatView(props: ChatViewProps) {...@@ -3500,14 +3515,28 @@ export default function ChatView(props: ChatViewProps) {
3500 ? heldWorktreeSetup3515 ? heldWorktreeSetup
3501 : null;3516 : null;
3502 // A finished card is dropped once the agent's turn shows in the timeline:3517 // A finished card is dropped once the agent's turn shows in the timeline:
3503 // the card belongs to the send, and the agent takes over from there.3518 // the card belongs to the send, and the agent takes over from there. An
3519 // async setup script keeps the snapshot running past the handoff and its
3520 // row leaves the moment the script exits cleanly; a failed script stays
3521 // for the rest of the turn so the exit code and terminal remain reachable.
3504 const worktreeSetupDoneAndTurnVisible =3522 const worktreeSetupDoneAndTurnVisible =
3505 worktreeSetup?.phase === "done" && activeThread?.latestTurn?.startedAt != null;3523 worktreeSetup?.phase === "done" &&
3524 activeThread?.latestTurn?.startedAt != null &&
3525 (!isWorking || !worktreeSetup.stages.some((stage) => stage.status === "failed"));
3506 useEffect(() => {3526 useEffect(() => {
3507 if (!worktreeSetupDoneAndTurnVisible) return;3527 if (!worktreeSetupDoneAndTurnVisible) return;
3508 setWorktreeSetupRef(null);3528 setWorktreeSetupRef(null);
3509 setHeldWorktreeSetup(null);3529 setHeldWorktreeSetup(null);
3510 }, [worktreeSetupDoneAndTurnVisible]);3530 }, [worktreeSetupDoneAndTurnVisible]);
3531 // The handoff entry only matters while the setup is still running: once it
3532 // settles in any phase, a later mount of the thread must not adopt it.
3533 const worktreeSetupSettledKey =
3534 worktreeSetup && worktreeSetup.phase !== "running" && worktreeSetupRef
3535 ? scopedThreadKey(scopeThreadRef(worktreeSetupRef.environmentId, worktreeSetupRef.threadId))
3536 : null;
3537 useEffect(() => {
3538 if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey);
3539 }, [worktreeSetupSettledKey]);
3511 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {3540 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
3512 reportFailure: false,3541 reportFailure: false,
3513 });3542 });
...@@ -7515,6 +7544,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -7515,6 +7544,12 @@ export default function ChatView(props: ChatViewProps) {
7515 ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey }7544 ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey }
7516 : null,7545 : null,
7517 );7546 );
7547 if (baseBranchForWorktree) {
7548 pendingWorktreeSetupByThreadKey.set(
7549 scopedThreadKey(scopeThreadRef(environmentId, threadIdForSend)),
7550 { environmentId, threadId: threadIdForSend },
7551 );
7552 }
75187553
7519 const messageIdForSend = newMessageId();7554 const messageIdForSend = newMessageId();
7520 const messageCreatedAt = new Date().toISOString();7555 const messageCreatedAt = new Date().toISOString();
apps/web/src/components/ProjectScriptsControl.tsx+1
...@@ -113,6 +113,7 @@ export default function ProjectScriptsControl({...@@ -113,6 +113,7 @@ export default function ProjectScriptsControl({
113 command: fileScript.command,113 command: fileScript.command,
114 icon: fileScript.icon ?? "play",114 icon: fileScript.icon ?? "play",
115 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,115 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
116 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
116 keybinding: null,117 keybinding: null,
117 previewUrl: fileScript.previewUrl ?? null,118 previewUrl: fileScript.previewUrl ?? null,
118 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,119 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+70-2
...@@ -1148,17 +1148,18 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1148,17 +1148,18 @@ describe("deriveMessagesTimelineRows", () => {
1148 id: WORKTREE_SETUP_ROW_ID,1148 id: WORKTREE_SETUP_ROW_ID,
1149 createdAt: "2026-01-01T00:00:00Z",1149 createdAt: "2026-01-01T00:00:00Z",
1150 snapshot,1150 snapshot,
1151 embedded: false,
1151 },1152 },
1152 ]);1153 ]);
11531154
1154 // Once the agent has replied the finished card stays under the send.1155 // A failed setup never handed off, so the card stays under the send.
1155 const withMessages = deriveMessagesTimelineRows({1156 const withMessages = deriveMessagesTimelineRows({
1156 timelineEntries: [userEntry, assistantEntry],1157 timelineEntries: [userEntry, assistantEntry],
1157 isWorking: true,1158 isWorking: true,
1158 activeTurnStartedAt: "2026-01-01T00:00:00Z",1159 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1159 turnDiffSummaries: [],1160 turnDiffSummaries: [],
1160 supportsConversationRollback: false,1161 supportsConversationRollback: false,
1161 worktreeSetup: { ...snapshot, phase: "done" },1162 worktreeSetup: { ...snapshot, phase: "failed" },
1162 });1163 });
1163 expect(withMessages.map((row) => row.kind)).toEqual([1164 expect(withMessages.map((row) => row.kind)).toEqual([
1164 "message",1165 "message",
...@@ -1166,6 +1167,73 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1166,6 +1167,73 @@ describe("deriveMessagesTimelineRows", () => {
1166 "working",1167 "working",
1167 "message",1168 "message",
1168 ]);1169 ]);
1170
1171 // Once the agent stage is done the setup script may still be running in
1172 // the background: the turn owns the header and the script row follows it.
1173 const stage = (id: "agent" | "setup-script", status: "done" | "running") =>
1174 ({
1175 id,
1176 status,
1177 startedAt: "2026-01-01T00:00:10Z",
1178 endedAt: status === "done" ? "2026-01-01T00:00:11Z" : null,
1179 percent: null,
1180 detail: null,
1181 tail: [],
1182 }) as const;
1183 const asyncSnapshot: WorktreeSetupSnapshot = {
1184 ...snapshot,
1185 stages: [stage("setup-script", "running"), stage("agent", "done")],
1186 };
1187 const liveTurn = {
1188 turnId: "turn-1" as never,
1189 state: "running",
1190 startedAt: "2026-01-01T00:00:11Z",
1191 completedAt: null,
1192 } as const;
1193 const asyncRows = deriveMessagesTimelineRows({
1194 timelineEntries: [userEntry],
1195 latestTurn: liveTurn,
1196 isWorking: true,
1197 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1198 turnDiffSummaries: [],
1199 supportsConversationRollback: false,
1200 worktreeSetup: asyncSnapshot,
1201 });
1202 expect(asyncRows.map((row) => row.kind)).toEqual([
1203 "message",
1204 "working",
1205 "worktree-setup",
1206 "thinking",
1207 ]);
1208 expect(asyncRows[2]).toMatchObject({ kind: "worktree-setup", embedded: true });
1209
1210 // Dispatched but not yet visible as a turn: the full card stays put so
1211 // nothing collapses during the handoff.
1212 const handoffRows = deriveMessagesTimelineRows({
1213 timelineEntries: [userEntry],
1214 isWorking: true,
1215 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1216 turnDiffSummaries: [],
1217 supportsConversationRollback: false,
1218 worktreeSetup: asyncSnapshot,
1219 });
1220 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "worktree-setup"]);
1221 expect(handoffRows[1]).toMatchObject({ kind: "worktree-setup", embedded: false });
1222
1223 // A script that already finished has nothing left to show once the turn is live.
1224 const finishedRows = deriveMessagesTimelineRows({
1225 timelineEntries: [userEntry],
1226 latestTurn: liveTurn,
1227 isWorking: true,
1228 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1229 turnDiffSummaries: [],
1230 supportsConversationRollback: false,
1231 worktreeSetup: {
1232 ...asyncSnapshot,
1233 stages: [stage("setup-script", "done"), stage("agent", "done")],
1234 },
1235 });
1236 expect(finishedRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]);
1169 });1237 });
11701238
1171 it("keeps context compaction visible outside folded work", () => {1239 it("keeps context compaction visible outside folded work", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+44-4
...@@ -400,6 +400,8 @@ export type MessagesTimelineRow =...@@ -400,6 +400,8 @@ export type MessagesTimelineRow =
400 id: string;400 id: string;
401 createdAt: string | null;401 createdAt: string | null;
402 snapshot: WorktreeSetupSnapshot;402 snapshot: WorktreeSetupSnapshot;
403 /** The agent already started; render only the script row under the turn header. */
404 embedded: boolean;
403 };405 };
404406
405export interface StableMessagesTimelineRowsState {407export interface StableMessagesTimelineRowsState {
...@@ -1260,15 +1262,23 @@ export function deriveMessagesTimelineRows(input: {...@@ -1260,15 +1262,23 @@ export function deriveMessagesTimelineRows(input: {
1260 });1262 });
1261 }1263 }
12621264
1263 // The setup card takes the place of the working and thinking placeholders1265 // Until the agent's turn is live, the setup card takes the place of the
1264 // while a worktree is being prepared. It stays after the setup settles so a1266 // working and thinking placeholders. It stays after a failed or cancelled
1265 // failure and its actions remain visible until the thread state moves on.1267 // setup so the outcome and its actions remain visible until the thread
1266 if (input.worktreeSetup) {1268 // state moves on. "Live" means the turn is in the timeline, not just that
1269 // the server dispatched it: the card must not collapse in the gap between.
1270 const setupHandedOff =
1271 input.worktreeSetup !== null &&
1272 input.worktreeSetup !== undefined &&
1273 worktreeSetupAgentStarted(input.worktreeSetup) &&
1274 input.latestTurn?.startedAt != null;
1275 if (input.worktreeSetup && !setupHandedOff) {
1267 const setupRow = {1276 const setupRow = {
1268 kind: "worktree-setup",1277 kind: "worktree-setup",
1269 id: WORKTREE_SETUP_ROW_ID,1278 id: WORKTREE_SETUP_ROW_ID,
1270 createdAt: input.worktreeSetup.startedAt,1279 createdAt: input.worktreeSetup.startedAt,
1271 snapshot: input.worktreeSetup,1280 snapshot: input.worktreeSetup,
1281 embedded: false,
1272 } as const;1282 } as const;
1273 // Sit directly under the first user message: a finished snapshot can1283 // Sit directly under the first user message: a finished snapshot can
1274 // outlive the first assistant reply, and it belongs to the send, not the1284 // outlive the first assistant reply, and it belongs to the send, not the
...@@ -1287,6 +1297,31 @@ export function deriveMessagesTimelineRows(input: {...@@ -1287,6 +1297,31 @@ export function deriveMessagesTimelineRows(input: {
1287 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {1297 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
1288 appendWorkingRow();1298 appendWorkingRow();
1289 }1299 }
1300 // An async setup script outlives the handoff. The turn owns the header, so
1301 // the script's row sits first under it, ahead of the agent's own work. A
1302 // script that already finished (or never ran) has nothing left to show.
1303 const setupScriptStage = input.worktreeSetup?.stages.find((stage) => stage.id === "setup-script");
1304 if (
1305 input.worktreeSetup &&
1306 setupHandedOff &&
1307 (setupScriptStage?.status === "running" || setupScriptStage?.status === "failed")
1308 ) {
1309 const setupRow = {
1310 kind: "worktree-setup",
1311 id: WORKTREE_SETUP_ROW_ID,
1312 createdAt: input.worktreeSetup.startedAt,
1313 snapshot: input.worktreeSetup,
1314 embedded: true,
1315 } as const;
1316 const workingRowIndex = nextRows.findIndex((row) => row.kind === "working");
1317 if (workingRowIndex >= 0) {
1318 nextRows.splice(workingRowIndex + 1, 0, setupRow);
1319 } else {
1320 // The turn already finished (or has not been dispatched yet): the row
1321 // trails the reply so a still-running script stays visible after it.
1322 nextRows.push(setupRow);
1323 }
1324 }
1290 if (input.isWorking && (!hasActivityRow || latestToolFailed)) {1325 if (input.isWorking && (!hasActivityRow || latestToolFailed)) {
1291 nextRows.push({1326 nextRows.push({
1292 kind: "thinking",1327 kind: "thinking",
...@@ -1300,6 +1335,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -1300,6 +1335,11 @@ export function deriveMessagesTimelineRows(input: {
13001335
1301export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";1336export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
13021337
1338/** True once the bootstrap handed off to the agent (async setup script may still run). */
1339function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean {
1340 return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done");
1341}
1342
1303type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];1343type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];
13041344
1305export interface MessagesTimelineRowsProjection {1345export interface MessagesTimelineRowsProjection {
apps/web/src/components/chat/MessagesTimeline.tsx+5-2
...@@ -1464,8 +1464,11 @@ function WorktreeSetupTimelineRow({...@@ -1464,8 +1464,11 @@ function WorktreeSetupTimelineRow({
1464 return (1464 return (
1465 <WorktreeSetupCard1465 <WorktreeSetupCard
1466 snapshot={row.snapshot}1466 snapshot={row.snapshot}
1467 onCancel={ctx.onCancelWorktreeSetup}1467 embedded={row.embedded}
1468 onWorkLocally={row.snapshot.phase === "running" ? ctx.onWorktreeSetupWorkLocally : null}1468 onCancel={row.embedded ? null : ctx.onCancelWorktreeSetup}
1469 onWorkLocally={
1470 !row.embedded && row.snapshot.phase === "running" ? ctx.onWorktreeSetupWorkLocally : null
1471 }
1469 onOpenTerminal={onOpenTerminal}1472 onOpenTerminal={onOpenTerminal}
1470 />1473 />
1471 );1474 );
apps/web/src/components/chat/WorktreeSetupCard.tsx+169-111
...@@ -10,16 +10,16 @@ import {...@@ -10,16 +10,16 @@ import {
10 ChevronRightIcon,10 ChevronRightIcon,
11 CircleAlertIcon,11 CircleAlertIcon,
12 CircleIcon,12 CircleIcon,
13 GitBranchIcon,
14 LaptopIcon,13 LaptopIcon,
15 MinusIcon,14 MinusIcon,
16 TerminalIcon,15 TerminalIcon,
17 XIcon,16 XIcon,
18} from "lucide-react";17} from "lucide-react";
19import { useEffect, useState } from "react";18import { useEffect, useState, type ReactNode } from "react";
2019
21import { Button } from "~/components/ui/button";20import { Button } from "~/components/ui/button";
22import { Spinner } from "~/components/ui/spinner";21import { Spinner } from "~/components/ui/spinner";
22import { observeVisibleAnimation } from "~/lib/visibleAnimation";
23import { cn } from "~/lib/utils";23import { cn } from "~/lib/utils";
2424
25interface WorktreeSetupCardProps {25interface WorktreeSetupCardProps {
...@@ -74,20 +74,95 @@ function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) {...@@ -74,20 +74,95 @@ function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) {
7474
75function stageRowClassName(status: WorktreeSetupStage["status"]): string {75function stageRowClassName(status: WorktreeSetupStage["status"]): string {
76 switch (status) {76 switch (status) {
77 case "running":
78 return "text-foreground";
79 case "failed":77 case "failed":
80 return "text-destructive-foreground";78 return "text-destructive-foreground";
81 case "warning":79 case "warning":
82 return "text-warning-foreground";80 return "text-warning-foreground";
83 case "pending":81 case "pending":
82 return "text-secondary-label opacity-40";
83 case "running":
84 case "skipped":84 case "skipped":
85 return "text-secondary-label opacity-50";
86 case "done":85 case "done":
87 return "text-secondary-label";86 return "text-secondary-label";
88 }87 }
89}88}
9089
90/** Same shimmer treatment as the live tool rows in the timeline. */
91function ShimmerOverlay({ children }: { children: ReactNode }) {
92 return (
93 <span
94 aria-hidden
95 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
96 >
97 <span className="live-activity-focus-counter block">
98 <span className="live-activity-focus-aligned block text-foreground">{children}</span>
99 </span>
100 </span>
101 );
102}
103
104function headerLabel(snapshot: WorktreeSetupSnapshot): string {
105 switch (snapshot.phase) {
106 case "running":
107 return "Setting up worktree…";
108 case "done":
109 return snapshot.stages.some((stage) => stage.status === "failed")
110 ? "Worktree ready, setup script failed"
111 : "Worktree ready";
112 case "failed":
113 return "Worktree setup failed";
114 case "cancelled":
115 return "Worktree setup cancelled";
116 }
117}
118
119/**
120 * Occupies the same slot, with the same metrics, as the "Working for" header
121 * so the handoff to the agent's turn only swaps the text.
122 */
123function SetupHeaderRow({
124 snapshot,
125 totalElapsed,
126}: {
127 snapshot: WorktreeSetupSnapshot;
128 totalElapsed: number | null;
129}) {
130 const running = snapshot.phase === "running";
131 const failed = snapshot.phase === "failed";
132 const finishedWithFailedStage =
133 snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed");
134 const text = headerLabel(snapshot);
135 const tone = failed
136 ? "text-destructive-foreground"
137 : finishedWithFailedStage
138 ? "text-warning-foreground"
139 : "text-muted-foreground";
140 return (
141 <div className="border-b border-border/60 pb-2 pt-1">
142 <div
143 className={cn(
144 "flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed tabular-nums",
145 tone,
146 )}
147 >
148 <span
149 ref={running ? observeVisibleAnimation : undefined}
150 className="relative min-w-0 shrink overflow-hidden whitespace-nowrap"
151 >
152 <span className="block truncate">{text}</span>
153 {running ? <ShimmerOverlay>{text}</ShimmerOverlay> : null}
154 </span>
155 {totalElapsed !== null ? (
156 <span className="ml-auto shrink-0 text-xs text-muted-foreground">
157 {formatDuration(totalElapsed)}
158 </span>
159 ) : null}
160 </div>
161 </div>
162 );
163}
164
165/** One stage, rendered like a live work entry row. */
91function StageRow({166function StageRow({
92 stage,167 stage,
93 nowMs,168 nowMs,
...@@ -100,44 +175,49 @@ function StageRow({...@@ -100,44 +175,49 @@ function StageRow({
100 const elapsed = stageElapsedMs(stage, nowMs);175 const elapsed = stageElapsedMs(stage, nowMs);
101 const label =176 const label =
102 stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id);177 stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id);
103 const showBar = stage.id === "checkout" && stage.status === "running" && stage.percent !== null;178 const running = stage.status === "running";
104 const trailing =179 const trailing =
105 stage.status === "pending"180 stage.status === "pending"
106 ? null181 ? null
107 : stage.status === "skipped"182 : stage.status === "skipped"
108 ? (stage.detail ?? "skipped")183 ? (stage.detail ?? "skipped")
109 : stage.detail;184 : stage.id === "checkout" && running && stage.percent !== null
110185 ? `${stage.percent}%`
186 : stage.detail;
111 return (187 return (
112 <div188 <div
189 ref={running ? observeVisibleAnimation : undefined}
113 className={cn(190 className={cn(
114 "flex min-h-6 items-center gap-1.5 px-0.5 py-0.5 text-sm leading-relaxed",191 "relative flex min-h-6 min-w-0 items-center gap-1.5 overflow-hidden rounded-md px-0.5 py-0.5 text-sm leading-relaxed",
115 stageRowClassName(stage.status),192 stageRowClassName(stage.status),
116 )}193 )}
117 data-worktree-setup-stage={stage.id}194 data-worktree-setup-stage={stage.id}
118 data-worktree-setup-status={stage.status}195 data-worktree-setup-status={stage.status}
119 >196 >
120 <span className="flex size-6 shrink-0 items-center justify-center">197 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
121 <StageIcon status={stage.status} />198 <StageIcon status={stage.status} />
122 </span>199 </span>
123 <span className="min-w-0 flex-1 truncate">{label}</span>200 <span className="min-w-0 flex-1 truncate">{label}</span>
124 <span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground tabular-nums">201 {trailing ? (
125 {showBar ? (202 <span className="min-w-0 truncate text-xs text-muted-foreground tabular-nums">
126 <>203 {trailing}
127 <span className="h-1 w-18 overflow-hidden rounded-full bg-input">204 </span>
128 <span205 ) : null}
129 className="block h-full rounded-full bg-primary"206 {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? (
130 style={{ width: `${stage.percent ?? 0}%` }}207 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
131 />208 {formatDuration(elapsed)}
209 </span>
210 ) : null}
211 {running ? (
212 <ShimmerOverlay>
213 <span className="flex min-h-6 items-center gap-1.5 px-0.5 py-0.5">
214 <span className="flex size-6 shrink-0 items-center justify-center">
215 <StageIcon status={stage.status} />
132 </span>216 </span>
133 <span className="text-foreground">{stage.percent}%</span>217 <span className="min-w-0 flex-1 truncate">{label}</span>
134 </>218 </span>
135 ) : null}219 </ShimmerOverlay>
136 {!showBar && trailing ? <span className="truncate">{trailing}</span> : null}220 ) : null}
137 {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? (
138 <span>{formatDuration(elapsed)}</span>
139 ) : null}
140 </span>
141 </div>221 </div>
142 );222 );
143}223}
...@@ -158,19 +238,35 @@ function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: b...@@ -158,19 +238,35 @@ function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: b
158 );238 );
159}239}
160240
161function headerLabel(snapshot: WorktreeSetupSnapshot): string {241function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
162 switch (snapshot.phase) {242 return (
163 case "running":243 <dl className="mt-1 mb-1.5 ml-8 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
164 return "Creating worktree";244 {snapshot.branch ? (
165 case "done":245 <>
166 return snapshot.stages.some((stage) => stage.status === "failed")246 <dt className="text-foreground/80">Branch</dt>
167 ? "Worktree ready, setup script failed"247 <dd className="truncate font-mono">{snapshot.branch}</dd>
168 : "Worktree ready";248 </>
169 case "failed":249 ) : null}
170 return "Worktree setup failed";250 {snapshot.baseRef ? (
171 case "cancelled":251 <>
172 return "Worktree setup cancelled";252 <dt className="text-foreground/80">Base</dt>
173 }253 <dd className="truncate font-mono">{snapshot.baseRef}</dd>
254 </>
255 ) : null}
256 {snapshot.worktreePath ? (
257 <>
258 <dt className="text-foreground/80">Path</dt>
259 <dd className="truncate font-mono">{snapshot.worktreePath}</dd>
260 </>
261 ) : null}
262 {snapshot.setupScript ? (
263 <>
264 <dt className="text-foreground/80">Setup</dt>
265 <dd className="truncate font-mono">{snapshot.setupScript.command}</dd>
266 </>
267 ) : null}
268 </dl>
269 );
174}270}
175271
176export function WorktreeSetupCard({272export function WorktreeSetupCard({
...@@ -178,7 +274,14 @@ export function WorktreeSetupCard({...@@ -178,7 +274,14 @@ export function WorktreeSetupCard({
178 onCancel,274 onCancel,
179 onWorkLocally,275 onWorkLocally,
180 onOpenTerminal,276 onOpenTerminal,
181}: WorktreeSetupCardProps) {277 embedded = false,
278}: WorktreeSetupCardProps & {
279 /**
280 * The agent already started (async setup script), so the turn owns the
281 * "Working for" header and only the script's row sits among the worklog.
282 */
283 embedded?: boolean;
284}) {
182 const running = snapshot.phase === "running";285 const running = snapshot.phase === "running";
183 const nowMs = useNowWhile(running);286 const nowMs = useNowWhile(running);
184 const [detailsOpen, setDetailsOpen] = useState(false);287 const [detailsOpen, setDetailsOpen] = useState(false);
...@@ -188,79 +291,35 @@ export function WorktreeSetupCard({...@@ -188,79 +291,35 @@ export function WorktreeSetupCard({
188 return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;291 return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
189 })();292 })();
190 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");293 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
191 const failed = snapshot.phase === "failed";294 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
192 const finishedWithFailedStage =295 const stages = embedded
193 snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed");296 ? snapshot.stages.filter((stage) => stage.id === "setup-script")
194 const headerClassName = failed297 : snapshot.stages;
195 ? "text-destructive-foreground"
196 : finishedWithFailedStage
197 ? "text-warning-foreground"
198 : snapshot.phase === "cancelled"
199 ? "text-muted-foreground"
200 : "text-secondary-label";
201298
202 return (299 return (
203 <section300 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>
204 aria-label="Worktree setup"301 {embedded ? null : <SetupHeaderRow snapshot={snapshot} totalElapsed={totalElapsed} />}
205 className="mt-3 rounded-lg border border-border bg-card/60 px-2.5 pt-1.5 pb-2"302 <div className={embedded ? undefined : "pt-1.5"}>
206 data-worktree-setup-phase={snapshot.phase}303 {stages.map((stage) => (
207 >304 <div key={stage.id}>
208 <div className={cn("flex min-h-6 items-center gap-1.5 px-0.5 text-sm", headerClassName)}>305 <StageRow stage={stage} nowMs={nowMs} scriptName={snapshot.setupScript?.name ?? null} />
209 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">306 {stage.id === "setup-script" &&
210 <GitBranchIcon aria-hidden className="size-4 shrink-0 stroke-[1.8]" />307 (stage.status === "running" || stage.status === "failed") ? (
211 </span>308 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
212 <span className="min-w-0 flex-1 truncate">{headerLabel(snapshot)}</span>309 ) : null}
213 {totalElapsed !== null ? (310 </div>
214 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">311 ))}
215 {formatDuration(totalElapsed)}
216 </span>
217 ) : null}
218 </div>312 </div>
219313
220 {snapshot.stages.map((stage) => (314 {snapshot.phase === "failed" && snapshot.error ? (
221 <div key={stage.id}>315 <p className="mt-1 ml-8 text-xs text-muted-foreground">{snapshot.error}</p>
222 <StageRow stage={stage} nowMs={nowMs} scriptName={snapshot.setupScript?.name ?? null} />
223 {stage.id === "setup-script" &&
224 (stage.status === "running" || stage.status === "failed") ? (
225 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
226 ) : null}
227 </div>
228 ))}
229
230 {failed && snapshot.error ? (
231 <p className="mt-1 px-0.5 text-xs text-muted-foreground">{snapshot.error}</p>
232 ) : null}316 ) : null}
233317
234 {detailsOpen ? (318 {detailsOpen ? <SetupDetails snapshot={snapshot} /> : null}
235 <dl className="mt-1 mb-1.5 ml-8 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
236 {snapshot.branch ? (
237 <>
238 <dt className="text-foreground/80">Branch</dt>
239 <dd className="truncate font-mono">{snapshot.branch}</dd>
240 </>
241 ) : null}
242 {snapshot.baseRef ? (
243 <>
244 <dt className="text-foreground/80">Base</dt>
245 <dd className="truncate font-mono">{snapshot.baseRef}</dd>
246 </>
247 ) : null}
248 {snapshot.worktreePath ? (
249 <>
250 <dt className="text-foreground/80">Path</dt>
251 <dd className="truncate font-mono">{snapshot.worktreePath}</dd>
252 </>
253 ) : null}
254 {snapshot.setupScript ? (
255 <>
256 <dt className="text-foreground/80">Setup</dt>
257 <dd className="truncate font-mono">{snapshot.setupScript.command}</dd>
258 </>
259 ) : null}
260 </dl>
261 ) : null}
262319
263 <div className="mt-1 flex flex-wrap items-center gap-1.5">320 {/* Indented so the first label lines up with the stage labels: the icon
321 column, minus the xs button's own horizontal padding. */}
322 <div className="mt-0.5 ml-[calc(--spacing(6)+2px-(--spacing(2)-1px))] flex flex-wrap items-center gap-0.5">
264 <Button323 <Button
265 type="button"324 type="button"
266 size="xs"325 size="xs"
...@@ -271,21 +330,20 @@ export function WorktreeSetupCard({...@@ -271,21 +330,20 @@ export function WorktreeSetupCard({
271 {detailsOpen ? <ChevronDownIcon aria-hidden /> : <ChevronRightIcon aria-hidden />}330 {detailsOpen ? <ChevronDownIcon aria-hidden /> : <ChevronRightIcon aria-hidden />}
272 Details331 Details
273 </Button>332 </Button>
274 <span className="flex-1" />333 {showTerminal ? (
275 {onOpenTerminal && setupStage && setupStage.status !== "pending" ? (334 <Button type="button" size="xs" variant="ghost-muted" onClick={onOpenTerminal}>
276 <Button type="button" size="xs" variant="outline" onClick={onOpenTerminal}>
277 <TerminalIcon aria-hidden />335 <TerminalIcon aria-hidden />
278 Open terminal336 Open terminal
279 </Button>337 </Button>
280 ) : null}338 ) : null}
281 {onWorkLocally ? (339 {onWorkLocally ? (
282 <Button type="button" size="xs" variant="outline" onClick={onWorkLocally}>340 <Button type="button" size="xs" variant="ghost-muted" onClick={onWorkLocally}>
283 <LaptopIcon aria-hidden />341 <LaptopIcon aria-hidden />
284 Work locally342 Work locally
285 </Button>343 </Button>
286 ) : null}344 ) : null}
287 {onCancel && running ? (345 {onCancel && running ? (
288 <Button type="button" size="xs" variant="outline" onClick={onCancel}>346 <Button type="button" size="xs" variant="ghost-muted" onClick={onCancel}>
289 <XIcon aria-hidden />347 <XIcon aria-hidden />
290 Cancel348 Cancel
291 </Button>349 </Button>
apps/web/src/components/projectScriptEditor.tsx+19
...@@ -85,6 +85,8 @@ export interface NewProjectScriptInput {...@@ -85,6 +85,8 @@ export interface NewProjectScriptInput {
85 command: string;85 command: string;
86 icon: ProjectScriptIcon;86 icon: ProjectScriptIcon;
87 runOnWorktreeCreate: boolean;87 runOnWorktreeCreate: boolean;
88 /** Setup scripts only: hold the agent until the script exits. */
89 waitForSetup: boolean;
88 keybinding: string | null;90 keybinding: string | null;
89 /** Optional URL to open in the in-app preview when this script runs. */91 /** Optional URL to open in the in-app preview when this script runs. */
90 previewUrl: string | null;92 previewUrl: string | null;
...@@ -99,6 +101,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = {...@@ -99,6 +101,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = {
99 command: "",101 command: "",
100 icon: "play",102 icon: "play",
101 runOnWorktreeCreate: false,103 runOnWorktreeCreate: false,
104 waitForSetup: false,
102 keybinding: null,105 keybinding: null,
103 previewUrl: null,106 previewUrl: null,
104 autoOpenPreview: false,107 autoOpenPreview: false,
...@@ -123,6 +126,7 @@ export function editorRequestForScript(...@@ -123,6 +126,7 @@ export function editorRequestForScript(
123 command: script.command,126 command: script.command,
124 icon: script.icon,127 icon: script.icon,
125 runOnWorktreeCreate: script.runOnWorktreeCreate,128 runOnWorktreeCreate: script.runOnWorktreeCreate,
129 waitForSetup: script.runOnWorktreeCreate && script.async === false,
126 keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)),130 keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)),
127 previewUrl: script.previewUrl ?? null,131 previewUrl: script.previewUrl ?? null,
128 autoOpenPreview: script.autoOpenPreview ?? false,132 autoOpenPreview: script.autoOpenPreview ?? false,
...@@ -158,6 +162,7 @@ export function ProjectScriptEditorDialog({...@@ -158,6 +162,7 @@ export function ProjectScriptEditorDialog({
158 const [icon, setIcon] = useState<ProjectScriptIcon>("play");162 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
159 const [iconPickerOpen, setIconPickerOpen] = useState(false);163 const [iconPickerOpen, setIconPickerOpen] = useState(false);
160 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);164 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
165 const [waitForSetup, setWaitForSetup] = useState(false);
161 const [keybinding, setKeybinding] = useState("");166 const [keybinding, setKeybinding] = useState("");
162 const [previewUrl, setPreviewUrl] = useState("");167 const [previewUrl, setPreviewUrl] = useState("");
163 const [autoOpenPreview, setAutoOpenPreview] = useState(false);168 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
...@@ -188,6 +193,7 @@ export function ProjectScriptEditorDialog({...@@ -188,6 +193,7 @@ export function ProjectScriptEditorDialog({
188 setIcon(request.initial.icon);193 setIcon(request.initial.icon);
189 setIconPickerOpen(false);194 setIconPickerOpen(false);
190 setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate);195 setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate);
196 setWaitForSetup(request.initial.waitForSetup);
191 setKeybinding(request.initial.keybinding ?? "");197 setKeybinding(request.initial.keybinding ?? "");
192 setPreviewUrl(request.initial.previewUrl ?? "");198 setPreviewUrl(request.initial.previewUrl ?? "");
193 setAutoOpenPreview(request.initial.autoOpenPreview);199 setAutoOpenPreview(request.initial.autoOpenPreview);
...@@ -247,6 +253,7 @@ export function ProjectScriptEditorDialog({...@@ -247,6 +253,7 @@ export function ProjectScriptEditorDialog({
247 command: trimmedCommand,253 command: trimmedCommand,
248 icon,254 icon,
249 runOnWorktreeCreate,255 runOnWorktreeCreate,
256 waitForSetup: runOnWorktreeCreate && waitForSetup,
250 keybinding: keybindingRule?.key ?? null,257 keybinding: keybindingRule?.key ?? null,
251 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,258 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
252 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,259 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
...@@ -396,6 +403,18 @@ export function ProjectScriptEditorDialog({...@@ -396,6 +403,18 @@ export function ProjectScriptEditorDialog({
396 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}403 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
397 />404 />
398 </label>405 </label>
406 <label
407 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
408 runOnWorktreeCreate ? "" : "opacity-60"
409 }`}
410 >
411 <span>Wait for it to finish before the agent starts</span>
412 <Switch
413 checked={waitForSetup}
414 disabled={!runOnWorktreeCreate}
415 onCheckedChange={(checked) => setWaitForSetup(Boolean(checked))}
416 />
417 </label>
399 <label418 <label
400 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${419 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
401 previewUrl.trim().length === 0 ? "opacity-60" : ""420 previewUrl.trim().length === 0 ? "opacity-60" : ""
apps/web/src/components/settings/ProjectActionsSettings.tsx+1
...@@ -111,6 +111,7 @@ export function ProjectActionsSettings() {...@@ -111,6 +111,7 @@ export function ProjectActionsSettings() {
111 command: fileScript.command,111 command: fileScript.command,
112 icon: fileScript.icon ?? "play",112 icon: fileScript.icon ?? "play",
113 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,113 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
114 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
114 keybinding: null,115 keybinding: null,
115 previewUrl: fileScript.previewUrl ?? null,116 previewUrl: fileScript.previewUrl ?? null,
116 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,117 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
apps/web/src/projectScripts.test.ts+21
...@@ -23,6 +23,7 @@ describe("projectScripts helpers", () => {...@@ -23,6 +23,7 @@ describe("projectScripts helpers", () => {
23 command: "pnpm dev",23 command: "pnpm dev",
24 icon: "debug",24 icon: "debug",
25 runOnWorktreeCreate: false,25 runOnWorktreeCreate: false,
26 waitForSetup: false,
26 previewUrl: "http://localhost:5733",27 previewUrl: "http://localhost:5733",
27 autoOpenPreview: true,28 autoOpenPreview: true,
28 }),29 }),
...@@ -44,6 +45,7 @@ describe("projectScripts helpers", () => {...@@ -44,6 +45,7 @@ describe("projectScripts helpers", () => {
44 command: "pnpm test",45 command: "pnpm test",
45 icon: "test",46 icon: "test",
46 runOnWorktreeCreate: false,47 runOnWorktreeCreate: false,
48 waitForSetup: false,
47 previewUrl: null,49 previewUrl: null,
48 autoOpenPreview: false,50 autoOpenPreview: false,
49 }),51 }),
...@@ -56,6 +58,25 @@ describe("projectScripts helpers", () => {...@@ -56,6 +58,25 @@ describe("projectScripts helpers", () => {
56 });58 });
57 });59 });
5860
61 it("only records async: false for setup scripts that should block the agent", () => {
62 const input = {
63 name: "Setup",
64 command: "pnpm i",
65 icon: "configure",
66 previewUrl: null,
67 autoOpenPreview: false,
68 } as const;
69 expect(
70 buildProjectScript("setup", { ...input, runOnWorktreeCreate: true, waitForSetup: true }),
71 ).toMatchObject({ runOnWorktreeCreate: true, async: false });
72 expect(
73 buildProjectScript("setup", { ...input, runOnWorktreeCreate: true, waitForSetup: false }),
74 ).not.toHaveProperty("async");
75 expect(
76 buildProjectScript("setup", { ...input, runOnWorktreeCreate: false, waitForSetup: true }),
77 ).not.toHaveProperty("async");
78 });
79
59 it("builds and parses script run commands", () => {80 it("builds and parses script run commands", () => {
60 const command = commandForProjectScript("lint");81 const command = commandForProjectScript("lint");
61 expect(command).toBe("script.lint.run");82 expect(command).toBe("script.lint.run");
apps/web/src/projectScripts.ts+2
...@@ -12,6 +12,7 @@ export interface ProjectScriptInput {...@@ -12,6 +12,7 @@ export interface ProjectScriptInput {
12 readonly command: ProjectScript["command"];12 readonly command: ProjectScript["command"];
13 readonly icon: ProjectScript["icon"];13 readonly icon: ProjectScript["icon"];
14 readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];14 readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];
15 readonly waitForSetup: boolean;
15 readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null;16 readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null;
16 readonly autoOpenPreview: boolean;17 readonly autoOpenPreview: boolean;
17}18}
...@@ -23,6 +24,7 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje...@@ -23,6 +24,7 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje
23 command: input.command,24 command: input.command,
24 icon: input.icon,25 icon: input.icon,
25 runOnWorktreeCreate: input.runOnWorktreeCreate,26 runOnWorktreeCreate: input.runOnWorktreeCreate,
27 ...(input.runOnWorktreeCreate && input.waitForSetup ? { async: false } : {}),
26 ...(input.previewUrl === null28 ...(input.previewUrl === null
27 ? {}29 ? {}
28 : {30 : {
packages/contracts/src/orchestration.ts+6
...@@ -404,6 +404,12 @@ export const ProjectScript = Schema.Struct({...@@ -404,6 +404,12 @@ export const ProjectScript = Schema.Struct({
404 command: TrimmedNonEmptyString,404 command: TrimmedNonEmptyString,
405 icon: ProjectScriptIcon,405 icon: ProjectScriptIcon,
406 runOnWorktreeCreate: Schema.Boolean,406 runOnWorktreeCreate: Schema.Boolean,
407 /**
408 * For `runOnWorktreeCreate` scripts: when false, the agent's first turn waits
409 * for the script to exit. Absent or true starts the agent right away and
410 * lets the script finish in the background.
411 */
412 async: Schema.optional(Schema.Boolean),
407 /**413 /**
408 * URL to open in the in-app browser preview when this script runs (or414 * URL to open in the in-app browser preview when this script runs (or
409 * when the user explicitly requests a preview). Optional; only honored on415 * when the user explicitly requests a preview). Optional; only honored on
packages/contracts/src/t3ProjectFile.test.ts+3-1
...@@ -20,12 +20,14 @@ describe("T3ProjectFile", () => {...@@ -20,12 +20,14 @@ describe("T3ProjectFile", () => {
20 autoOpenPreview: true,20 autoOpenPreview: true,
21 },21 },
22 { name: "Test", command: "pnpm test" },22 { name: "Test", command: "pnpm test" },
23 { name: "Setup", command: "pnpm i", runOnWorktreeCreate: true, async: false },
23 ],24 ],
24 });25 });
2526
26 expect(decoded.iconPath).toBe("assets/logo.svg");27 expect(decoded.iconPath).toBe("assets/logo.svg");
27 expect(decoded.scripts).toHaveLength(2);28 expect(decoded.scripts).toHaveLength(3);
28 expect(decoded.scripts?.[1]).toEqual({ name: "Test", command: "pnpm test" });29 expect(decoded.scripts?.[1]).toEqual({ name: "Test", command: "pnpm test" });
30 expect(decoded.scripts?.[2]?.async).toBe(false);
29 });31 });
3032
31 it("decodes an empty object and ignores unknown fields", () => {33 it("decodes an empty object and ignores unknown fields", () => {
packages/contracts/src/t3ProjectFile.ts+6
...@@ -42,6 +42,12 @@ export const T3ProjectFileScript = Schema.Struct({...@@ -42,6 +42,12 @@ export const T3ProjectFileScript = Schema.Struct({
42 "When true, the script runs automatically after a worktree is created for a new thread.",42 "When true, the script runs automatically after a worktree is created for a new thread.",
43 }),43 }),
44 ),44 ),
45 async: Schema.optionalKey(
46 Schema.Boolean.annotate({
47 description:
48 "Only for runOnWorktreeCreate scripts. When true (the default), the agent starts while the script is still running. Set false to hold the agent until the script exits.",
49 }),
50 ),
45 previewUrl: Schema.optionalKey(51 previewUrl: Schema.optionalKey(
46 trimmedNonEmpty({52 trimmedNonEmpty({
47 description:53 description:
packages/shared/src/t3ProjectFile.test.ts+1
...@@ -44,6 +44,7 @@ describe("buildT3ProjectFileJsonSchema", () => {...@@ -44,6 +44,7 @@ describe("buildT3ProjectFileJsonSchema", () => {
44 const script = schema.properties.scripts?.items;44 const script = schema.properties.scripts?.items;
45 expect(script?.required).toEqual(["name", "command"]);45 expect(script?.required).toEqual(["name", "command"]);
46 expect(Object.keys(script?.properties ?? {}).sort()).toEqual([46 expect(Object.keys(script?.properties ?? {}).sort()).toEqual([
47 "async",
47 "autoOpenPreview",48 "autoOpenPreview",
48 "command",49 "command",
49 "icon",50 "icon",