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", () => {
203203 scriptCommand: "bun install",
204204 terminalId: "setup-setup",
205205 cwd: "/repo/worktrees/a",
206 async: true,
206207 });
207208 expect(open).toHaveBeenCalledWith({
208209 threadId: "thread-1",
apps/server/src/project/ProjectSetupScriptRunner.ts+3
......@@ -30,6 +30,8 @@ export interface ProjectSetupScriptRunnerResultStarted {
3030 readonly scriptCommand: string;
3131 readonly terminalId: string;
3232 readonly cwd: string;
33 /** False when the script's `async` flag asks the agent to wait for it. */
34 readonly async: boolean;
3335 /**
3436 * Resolves when the script's shell prints the completion sentinel. The
3537 * exit code is null when the terminal exited or was closed before the
......@@ -410,6 +412,7 @@ export const make = Effect.gen(function* () {
410412 scriptCommand: script.command,
411413 terminalId,
412414 cwd,
415 async: script.async !== false,
413416 ...(observed ? { completion: observed.completion } : {}),
414417 } as const;
415418 });
apps/server/src/server.test.ts+151
......@@ -44,6 +44,8 @@ import {
4444 WS_METHODS,
4545 WsRpcGroup,
4646 EditorId,
47 type WorktreeSetupSnapshot,
48 type WorktreeSetupStageId,
4749} from "@t3tools/contracts";
4850import {
4951 computeDpopAccessTokenHash,
......@@ -10759,6 +10761,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1075910761 scriptCommand: "npm install",
1076010762 terminalId: "setup-setup",
1076110763 cwd: "/tmp/bootstrap-worktree",
10764 async: true,
1076210765 }),
1076310766 );
1076410767
......@@ -11324,6 +11327,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1132411327 scriptCommand: "npm install",
1132511328 terminalId: "setup-setup",
1132611329 cwd: "/tmp/bootstrap-worktree",
11330 async: true,
1132711331 }),
1132811332 );
1132911333 let setupActivityAppendAttempt = 0;
......@@ -11427,6 +11431,153 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1142711431 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1142811432 );
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
1143011581 it.effect("cleans up created bootstrap threads when worktree creation defects", () =>
1143111582 Effect.gen(function* () {
1143211583 const dispatchedCommands: Array<OrchestrationCommand> = [];
apps/server/src/ws.ts+49-21
......@@ -1112,15 +1112,16 @@ const makeWsRpcLayer = (
11121112 const threadId = command.threadId;
11131113 const track = (effect: Effect.Effect<void>) => (tracked ? effect : Effect.void);
11141114
1115 // Runs the setup script and, for tracked bootstraps, waits for it to
1116 // exit so the card can show the exit code and the agent stage never
1117 // starts on a half-installed tree. Untracked callers keep the old
1118 // fire-and-forget behavior.
1115 // Starts the setup script. For tracked bootstraps it returns the
1116 // effect that waits for the script to exit and records the outcome
1117 // on the card; whether the agent stage waits on it depends on the
1118 // script's `async` flag. Returns null when nothing is left to await.
1119 // Untracked callers keep the old fire-and-forget behavior.
11191120 const runSetupProgram = () =>
11201121 Effect.gen(function* () {
11211122 if (!bootstrap?.runSetupScript || !targetWorktreePath) {
11221123 yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped"));
1123 return;
1124 return null;
11241125 }
11251126 const worktreePath = targetWorktreePath;
11261127 const requestedAt = yield* nowIso;
......@@ -1197,21 +1198,38 @@ const makeWsRpcLayer = (
11971198 }),
11981199 );
11991200 if (!tracked || !setupResult?.completion) {
1200 return;
1201 return null;
12011202 }
12021203 // The setup script is best effort, like the untracked path: a
12031204 // failed install must not throw away the worktree the user just
12041205 // waited for. The card keeps the failed stage and its terminal.
1205 const completion = yield* setupResult.completion;
1206 if (completion.exitCode === 0) {
1207 yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "done");
1208 return;
1206 // Forked right away so the terminal listener behind `completion`
1207 // is always consumed, even when the turn dispatch fails before
1208 // anyone would otherwise wait on it. The tracker update is a
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;
12091231 }
1210 const detail =
1211 completion.exitCode === null
1212 ? "terminal closed before the script finished"
1213 : `exit ${completion.exitCode}`;
1214 yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "failed", detail);
1232 return completionFiber;
12151233 });
12161234
12171235 const bootstrapProgram = Effect.gen(function* () {
......@@ -1414,7 +1432,7 @@ const makeWsRpcLayer = (
14141432 yield* refreshGitStatus(targetWorktreePath);
14151433 }
14161434
1417 yield* runSetupProgram();
1435 const pendingSetupScript = yield* runSetupProgram();
14181436
14191437 yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running"));
14201438 // Past this point a cancel would roll back a thread whose turn has
......@@ -1423,11 +1441,21 @@ const makeWsRpcLayer = (
14231441 const started = yield* Effect.uninterruptible(
14241442 dispatchFromClient(finalTurnStartCommand),
14251443 );
1426 yield* track(
1427 worktreeSetupTracker
1428 .stageStatus(threadId, "agent", "done")
1429 .pipe(Effect.andThen(worktreeSetupTracker.finish(threadId, "done"))),
1430 );
1444 yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "done"));
1445 // An async setup script outlives the handoff: the snapshot stays
1446 // running so the client keeps its row next to the agent's work,
1447 // and settles when the script exits. The turn already started, so
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 }
14311459 return started;
14321460 });
14331461
apps/web/src/components/ChatView.tsx+38-3
......@@ -1430,6 +1430,16 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n
14301430 return current.messageId === null ? current : { ...current, messageId: null };
14311431}
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
14331443export default function ChatView(props: ChatViewProps) {
14341444 const {
14351445 environmentId,
......@@ -1659,7 +1669,12 @@ export default function ChatView(props: ChatViewProps) {
16591669 environmentId: EnvironmentId;
16601670 threadId: ThreadId;
16611671 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 });
16631678 const [heldWorktreeSetup, setHeldWorktreeSetup] = useState<WorktreeSetupSnapshot | null>(null);
16641679 // Set by "Work locally": the draft whose restored message should be resent
16651680 // once the cancelled dispatch has settled and the draft is in local mode.
......@@ -3500,14 +3515,28 @@ export default function ChatView(props: ChatViewProps) {
35003515 ? heldWorktreeSetup
35013516 : null;
35023517 // 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.
35043522 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"));
35063526 useEffect(() => {
35073527 if (!worktreeSetupDoneAndTurnVisible) return;
35083528 setWorktreeSetupRef(null);
35093529 setHeldWorktreeSetup(null);
35103530 }, [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]);
35113540 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
35123541 reportFailure: false,
35133542 });
......@@ -7515,6 +7544,12 @@ export default function ChatView(props: ChatViewProps) {
75157544 ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey }
75167545 : null,
75177546 );
7547 if (baseBranchForWorktree) {
7548 pendingWorktreeSetupByThreadKey.set(
7549 scopedThreadKey(scopeThreadRef(environmentId, threadIdForSend)),
7550 { environmentId, threadId: threadIdForSend },
7551 );
7552 }
75187553
75197554 const messageIdForSend = newMessageId();
75207555 const messageCreatedAt = new Date().toISOString();
apps/web/src/components/ProjectScriptsControl.tsx+1
......@@ -113,6 +113,7 @@ export default function ProjectScriptsControl({
113113 command: fileScript.command,
114114 icon: fileScript.icon ?? "play",
115115 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
116 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
116117 keybinding: null,
117118 previewUrl: fileScript.previewUrl ?? null,
118119 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+70-2
......@@ -1148,17 +1148,18 @@ describe("deriveMessagesTimelineRows", () => {
11481148 id: WORKTREE_SETUP_ROW_ID,
11491149 createdAt: "2026-01-01T00:00:00Z",
11501150 snapshot,
1151 embedded: false,
11511152 },
11521153 ]);
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.
11551156 const withMessages = deriveMessagesTimelineRows({
11561157 timelineEntries: [userEntry, assistantEntry],
11571158 isWorking: true,
11581159 activeTurnStartedAt: "2026-01-01T00:00:00Z",
11591160 turnDiffSummaries: [],
11601161 supportsConversationRollback: false,
1161 worktreeSetup: { ...snapshot, phase: "done" },
1162 worktreeSetup: { ...snapshot, phase: "failed" },
11621163 });
11631164 expect(withMessages.map((row) => row.kind)).toEqual([
11641165 "message",
......@@ -1166,6 +1167,73 @@ describe("deriveMessagesTimelineRows", () => {
11661167 "working",
11671168 "message",
11681169 ]);
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"]);
11691237 });
11701238
11711239 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 =
400400 id: string;
401401 createdAt: string | null;
402402 snapshot: WorktreeSetupSnapshot;
403 /** The agent already started; render only the script row under the turn header. */
404 embedded: boolean;
403405 };
404406
405407export interface StableMessagesTimelineRowsState {
......@@ -1260,15 +1262,23 @@ export function deriveMessagesTimelineRows(input: {
12601262 });
12611263 }
12621264
1263 // The setup card takes the place of the working and thinking placeholders
1264 // while a worktree is being prepared. It stays after the setup settles so a
1265 // failure and its actions remain visible until the thread state moves on.
1266 if (input.worktreeSetup) {
1265 // Until the agent's turn is live, the setup card takes the place of the
1266 // working and thinking placeholders. It stays after a failed or cancelled
1267 // setup so the outcome and its actions remain visible until the thread
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) {
12671276 const setupRow = {
12681277 kind: "worktree-setup",
12691278 id: WORKTREE_SETUP_ROW_ID,
12701279 createdAt: input.worktreeSetup.startedAt,
12711280 snapshot: input.worktreeSetup,
1281 embedded: false,
12721282 } as const;
12731283 // Sit directly under the first user message: a finished snapshot can
12741284 // outlive the first assistant reply, and it belongs to the send, not the
......@@ -1287,6 +1297,31 @@ export function deriveMessagesTimelineRows(input: {
12871297 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
12881298 appendWorkingRow();
12891299 }
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 }
12901325 if (input.isWorking && (!hasActivityRow || latestToolFailed)) {
12911326 nextRows.push({
12921327 kind: "thinking",
......@@ -1300,6 +1335,11 @@ export function deriveMessagesTimelineRows(input: {
13001335
13011336export 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
13031343type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];
13041344
13051345export interface MessagesTimelineRowsProjection {
apps/web/src/components/chat/MessagesTimeline.tsx+5-2
......@@ -1464,8 +1464,11 @@ function WorktreeSetupTimelineRow({
14641464 return (
14651465 <WorktreeSetupCard
14661466 snapshot={row.snapshot}
1467 onCancel={ctx.onCancelWorktreeSetup}
1468 onWorkLocally={row.snapshot.phase === "running" ? ctx.onWorktreeSetupWorkLocally : null}
1467 embedded={row.embedded}
1468 onCancel={row.embedded ? null : ctx.onCancelWorktreeSetup}
1469 onWorkLocally={
1470 !row.embedded && row.snapshot.phase === "running" ? ctx.onWorktreeSetupWorkLocally : null
1471 }
14691472 onOpenTerminal={onOpenTerminal}
14701473 />
14711474 );
apps/web/src/components/chat/WorktreeSetupCard.tsx+169-111
......@@ -10,16 +10,16 @@ import {
1010 ChevronRightIcon,
1111 CircleAlertIcon,
1212 CircleIcon,
13 GitBranchIcon,
1413 LaptopIcon,
1514 MinusIcon,
1615 TerminalIcon,
1716 XIcon,
1817} from "lucide-react";
19import { useEffect, useState } from "react";
18import { useEffect, useState, type ReactNode } from "react";
2019
2120import { Button } from "~/components/ui/button";
2221import { Spinner } from "~/components/ui/spinner";
22import { observeVisibleAnimation } from "~/lib/visibleAnimation";
2323import { cn } from "~/lib/utils";
2424
2525interface WorktreeSetupCardProps {
......@@ -74,20 +74,95 @@ function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) {
7474
7575function stageRowClassName(status: WorktreeSetupStage["status"]): string {
7676 switch (status) {
77 case "running":
78 return "text-foreground";
7977 case "failed":
8078 return "text-destructive-foreground";
8179 case "warning":
8280 return "text-warning-foreground";
8381 case "pending":
82 return "text-secondary-label opacity-40";
83 case "running":
8484 case "skipped":
85 return "text-secondary-label opacity-50";
8685 case "done":
8786 return "text-secondary-label";
8887 }
8988}
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. */
91166function StageRow({
92167 stage,
93168 nowMs,
......@@ -100,44 +175,49 @@ function StageRow({
100175 const elapsed = stageElapsedMs(stage, nowMs);
101176 const label =
102177 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";
104179 const trailing =
105180 stage.status === "pending"
106181 ? null
107182 : stage.status === "skipped"
108183 ? (stage.detail ?? "skipped")
109 : stage.detail;
110
184 : stage.id === "checkout" && running && stage.percent !== null
185 ? `${stage.percent}%`
186 : stage.detail;
111187 return (
112188 <div
189 ref={running ? observeVisibleAnimation : undefined}
113190 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",
115192 stageRowClassName(stage.status),
116193 )}
117194 data-worktree-setup-stage={stage.id}
118195 data-worktree-setup-status={stage.status}
119196 >
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">
121198 <StageIcon status={stage.status} />
122199 </span>
123200 <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">
125 {showBar ? (
126 <>
127 <span className="h-1 w-18 overflow-hidden rounded-full bg-input">
128 <span
129 className="block h-full rounded-full bg-primary"
130 style={{ width: `${stage.percent ?? 0}%` }}
131 />
201 {trailing ? (
202 <span className="min-w-0 truncate text-xs text-muted-foreground tabular-nums">
203 {trailing}
204 </span>
205 ) : null}
206 {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? (
207 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
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} />
132216 </span>
133 <span className="text-foreground">{stage.percent}%</span>
134 </>
135 ) : null}
136 {!showBar && trailing ? <span className="truncate">{trailing}</span> : null}
137 {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? (
138 <span>{formatDuration(elapsed)}</span>
139 ) : null}
140 </span>
217 <span className="min-w-0 flex-1 truncate">{label}</span>
218 </span>
219 </ShimmerOverlay>
220 ) : null}
141221 </div>
142222 );
143223}
......@@ -158,19 +238,35 @@ function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: b
158238 );
159239}
160240
161function headerLabel(snapshot: WorktreeSetupSnapshot): string {
162 switch (snapshot.phase) {
163 case "running":
164 return "Creating worktree";
165 case "done":
166 return snapshot.stages.some((stage) => stage.status === "failed")
167 ? "Worktree ready, setup script failed"
168 : "Worktree ready";
169 case "failed":
170 return "Worktree setup failed";
171 case "cancelled":
172 return "Worktree setup cancelled";
173 }
241function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
242 return (
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">
244 {snapshot.branch ? (
245 <>
246 <dt className="text-foreground/80">Branch</dt>
247 <dd className="truncate font-mono">{snapshot.branch}</dd>
248 </>
249 ) : null}
250 {snapshot.baseRef ? (
251 <>
252 <dt className="text-foreground/80">Base</dt>
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 );
174270}
175271
176272export function WorktreeSetupCard({
......@@ -178,7 +274,14 @@ export function WorktreeSetupCard({
178274 onCancel,
179275 onWorkLocally,
180276 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}) {
182285 const running = snapshot.phase === "running";
183286 const nowMs = useNowWhile(running);
184287 const [detailsOpen, setDetailsOpen] = useState(false);
......@@ -188,79 +291,35 @@ export function WorktreeSetupCard({
188291 return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
189292 })();
190293 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
191 const failed = snapshot.phase === "failed";
192 const finishedWithFailedStage =
193 snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed");
194 const headerClassName = failed
195 ? "text-destructive-foreground"
196 : finishedWithFailedStage
197 ? "text-warning-foreground"
198 : snapshot.phase === "cancelled"
199 ? "text-muted-foreground"
200 : "text-secondary-label";
294 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
295 const stages = embedded
296 ? snapshot.stages.filter((stage) => stage.id === "setup-script")
297 : snapshot.stages;
201298
202299 return (
203 <section
204 aria-label="Worktree setup"
205 className="mt-3 rounded-lg border border-border bg-card/60 px-2.5 pt-1.5 pb-2"
206 data-worktree-setup-phase={snapshot.phase}
207 >
208 <div className={cn("flex min-h-6 items-center gap-1.5 px-0.5 text-sm", headerClassName)}>
209 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
210 <GitBranchIcon aria-hidden className="size-4 shrink-0 stroke-[1.8]" />
211 </span>
212 <span className="min-w-0 flex-1 truncate">{headerLabel(snapshot)}</span>
213 {totalElapsed !== null ? (
214 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
215 {formatDuration(totalElapsed)}
216 </span>
217 ) : null}
300 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>
301 {embedded ? null : <SetupHeaderRow snapshot={snapshot} totalElapsed={totalElapsed} />}
302 <div className={embedded ? undefined : "pt-1.5"}>
303 {stages.map((stage) => (
304 <div key={stage.id}>
305 <StageRow stage={stage} nowMs={nowMs} scriptName={snapshot.setupScript?.name ?? null} />
306 {stage.id === "setup-script" &&
307 (stage.status === "running" || stage.status === "failed") ? (
308 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
309 ) : null}
310 </div>
311 ))}
218312 </div>
219313
220 {snapshot.stages.map((stage) => (
221 <div key={stage.id}>
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>
314 {snapshot.phase === "failed" && snapshot.error ? (
315 <p className="mt-1 ml-8 text-xs text-muted-foreground">{snapshot.error}</p>
232316 ) : null}
233317
234 {detailsOpen ? (
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}
318 {detailsOpen ? <SetupDetails snapshot={snapshot} /> : 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">
264323 <Button
265324 type="button"
266325 size="xs"
......@@ -271,21 +330,20 @@ export function WorktreeSetupCard({
271330 {detailsOpen ? <ChevronDownIcon aria-hidden /> : <ChevronRightIcon aria-hidden />}
272331 Details
273332 </Button>
274 <span className="flex-1" />
275 {onOpenTerminal && setupStage && setupStage.status !== "pending" ? (
276 <Button type="button" size="xs" variant="outline" onClick={onOpenTerminal}>
333 {showTerminal ? (
334 <Button type="button" size="xs" variant="ghost-muted" onClick={onOpenTerminal}>
277335 <TerminalIcon aria-hidden />
278336 Open terminal
279337 </Button>
280338 ) : null}
281339 {onWorkLocally ? (
282 <Button type="button" size="xs" variant="outline" onClick={onWorkLocally}>
340 <Button type="button" size="xs" variant="ghost-muted" onClick={onWorkLocally}>
283341 <LaptopIcon aria-hidden />
284342 Work locally
285343 </Button>
286344 ) : null}
287345 {onCancel && running ? (
288 <Button type="button" size="xs" variant="outline" onClick={onCancel}>
346 <Button type="button" size="xs" variant="ghost-muted" onClick={onCancel}>
289347 <XIcon aria-hidden />
290348 Cancel
291349 </Button>
apps/web/src/components/projectScriptEditor.tsx+19
......@@ -85,6 +85,8 @@ export interface NewProjectScriptInput {
8585 command: string;
8686 icon: ProjectScriptIcon;
8787 runOnWorktreeCreate: boolean;
88 /** Setup scripts only: hold the agent until the script exits. */
89 waitForSetup: boolean;
8890 keybinding: string | null;
8991 /** Optional URL to open in the in-app preview when this script runs. */
9092 previewUrl: string | null;
......@@ -99,6 +101,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = {
99101 command: "",
100102 icon: "play",
101103 runOnWorktreeCreate: false,
104 waitForSetup: false,
102105 keybinding: null,
103106 previewUrl: null,
104107 autoOpenPreview: false,
......@@ -123,6 +126,7 @@ export function editorRequestForScript(
123126 command: script.command,
124127 icon: script.icon,
125128 runOnWorktreeCreate: script.runOnWorktreeCreate,
129 waitForSetup: script.runOnWorktreeCreate && script.async === false,
126130 keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)),
127131 previewUrl: script.previewUrl ?? null,
128132 autoOpenPreview: script.autoOpenPreview ?? false,
......@@ -158,6 +162,7 @@ export function ProjectScriptEditorDialog({
158162 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
159163 const [iconPickerOpen, setIconPickerOpen] = useState(false);
160164 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
165 const [waitForSetup, setWaitForSetup] = useState(false);
161166 const [keybinding, setKeybinding] = useState("");
162167 const [previewUrl, setPreviewUrl] = useState("");
163168 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
......@@ -188,6 +193,7 @@ export function ProjectScriptEditorDialog({
188193 setIcon(request.initial.icon);
189194 setIconPickerOpen(false);
190195 setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate);
196 setWaitForSetup(request.initial.waitForSetup);
191197 setKeybinding(request.initial.keybinding ?? "");
192198 setPreviewUrl(request.initial.previewUrl ?? "");
193199 setAutoOpenPreview(request.initial.autoOpenPreview);
......@@ -247,6 +253,7 @@ export function ProjectScriptEditorDialog({
247253 command: trimmedCommand,
248254 icon,
249255 runOnWorktreeCreate,
256 waitForSetup: runOnWorktreeCreate && waitForSetup,
250257 keybinding: keybindingRule?.key ?? null,
251258 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
252259 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
......@@ -396,6 +403,18 @@ export function ProjectScriptEditorDialog({
396403 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
397404 />
398405 </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>
399418 <label
400419 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] ${
401420 previewUrl.trim().length === 0 ? "opacity-60" : ""
apps/web/src/components/settings/ProjectActionsSettings.tsx+1
......@@ -111,6 +111,7 @@ export function ProjectActionsSettings() {
111111 command: fileScript.command,
112112 icon: fileScript.icon ?? "play",
113113 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
114 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
114115 keybinding: null,
115116 previewUrl: fileScript.previewUrl ?? null,
116117 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
apps/web/src/projectScripts.test.ts+21
......@@ -23,6 +23,7 @@ describe("projectScripts helpers", () => {
2323 command: "pnpm dev",
2424 icon: "debug",
2525 runOnWorktreeCreate: false,
26 waitForSetup: false,
2627 previewUrl: "http://localhost:5733",
2728 autoOpenPreview: true,
2829 }),
......@@ -44,6 +45,7 @@ describe("projectScripts helpers", () => {
4445 command: "pnpm test",
4546 icon: "test",
4647 runOnWorktreeCreate: false,
48 waitForSetup: false,
4749 previewUrl: null,
4850 autoOpenPreview: false,
4951 }),
......@@ -56,6 +58,25 @@ describe("projectScripts helpers", () => {
5658 });
5759 });
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
5980 it("builds and parses script run commands", () => {
6081 const command = commandForProjectScript("lint");
6182 expect(command).toBe("script.lint.run");
apps/web/src/projectScripts.ts+2
......@@ -12,6 +12,7 @@ export interface ProjectScriptInput {
1212 readonly command: ProjectScript["command"];
1313 readonly icon: ProjectScript["icon"];
1414 readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];
15 readonly waitForSetup: boolean;
1516 readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null;
1617 readonly autoOpenPreview: boolean;
1718}
......@@ -23,6 +24,7 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje
2324 command: input.command,
2425 icon: input.icon,
2526 runOnWorktreeCreate: input.runOnWorktreeCreate,
27 ...(input.runOnWorktreeCreate && input.waitForSetup ? { async: false } : {}),
2628 ...(input.previewUrl === null
2729 ? {}
2830 : {
packages/contracts/src/orchestration.ts+6
......@@ -404,6 +404,12 @@ export const ProjectScript = Schema.Struct({
404404 command: TrimmedNonEmptyString,
405405 icon: ProjectScriptIcon,
406406 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),
407413 /**
408414 * URL to open in the in-app browser preview when this script runs (or
409415 * 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", () => {
2020 autoOpenPreview: true,
2121 },
2222 { name: "Test", command: "pnpm test" },
23 { name: "Setup", command: "pnpm i", runOnWorktreeCreate: true, async: false },
2324 ],
2425 });
2526
2627 expect(decoded.iconPath).toBe("assets/logo.svg");
27 expect(decoded.scripts).toHaveLength(2);
28 expect(decoded.scripts).toHaveLength(3);
2829 expect(decoded.scripts?.[1]).toEqual({ name: "Test", command: "pnpm test" });
30 expect(decoded.scripts?.[2]?.async).toBe(false);
2931 });
3032
3133 it("decodes an empty object and ignores unknown fields", () => {
packages/contracts/src/t3ProjectFile.ts+6
......@@ -42,6 +42,12 @@ export const T3ProjectFileScript = Schema.Struct({
4242 "When true, the script runs automatically after a worktree is created for a new thread.",
4343 }),
4444 ),
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 ),
4551 previewUrl: Schema.optionalKey(
4652 trimmedNonEmpty({
4753 description:
packages/shared/src/t3ProjectFile.test.ts+1
......@@ -44,6 +44,7 @@ describe("buildT3ProjectFileJsonSchema", () => {
4444 const script = schema.properties.scripts?.items;
4545 expect(script?.required).toEqual(["name", "command"]);
4646 expect(Object.keys(script?.properties ?? {}).sort()).toEqual([
47 "async",
4748 "autoOpenPreview",
4849 "command",
4950 "icon",