| author | |
| committer | |
| log | 5ea6439816470288d3f2b6b43635fea41fbbb101 |
| tree | 01e988511d317d59d836be8d1126b27eda77aac8 |
| parent | 6dbea7ed0947780694943bd86c4cd22cebbcd8eb |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 203 | scriptCommand: "bun install", |
| 204 | 204 | terminalId: "setup-setup", |
| 205 | 205 | cwd: "/repo/worktrees/a", |
| 206 | async: true, | |
| 206 | 207 | }); |
| 207 | 208 | expect(open).toHaveBeenCalledWith({ |
| 208 | 209 | threadId: "thread-1", |
apps/server/src/project/ProjectSetupScriptRunner.ts+3| ... | ... | @@ -30,6 +30,8 @@ export interface ProjectSetupScriptRunnerResultStarted { |
| 30 | 30 | readonly scriptCommand: string; |
| 31 | 31 | readonly terminalId: string; |
| 32 | 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 | 36 | * Resolves when the script's shell prints the completion sentinel. The |
| 35 | 37 | * exit code is null when the terminal exited or was closed before the |
| ... | ... | @@ -410,6 +412,7 @@ export const make = Effect.gen(function* () { |
| 410 | 412 | scriptCommand: script.command, |
| 411 | 413 | terminalId, |
| 412 | 414 | cwd, |
| 415 | async: script.async !== false, | |
| 413 | 416 | ...(observed ? { completion: observed.completion } : {}), |
| 414 | 417 | } as const; |
| 415 | 418 | }); |
apps/server/src/server.test.ts+151| ... | ... | @@ -44,6 +44,8 @@ import { |
| 44 | 44 | WS_METHODS, |
| 45 | 45 | WsRpcGroup, |
| 46 | 46 | EditorId, |
| 47 | type WorktreeSetupSnapshot, | |
| 48 | type WorktreeSetupStageId, | |
| 47 | 49 | } from "@t3tools/contracts"; |
| 48 | 50 | import { |
| 49 | 51 | computeDpopAccessTokenHash, |
| ... | ... | @@ -10759,6 +10761,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 10759 | 10761 | scriptCommand: "npm install", |
| 10760 | 10762 | terminalId: "setup-setup", |
| 10761 | 10763 | cwd: "/tmp/bootstrap-worktree", |
| 10764 | async: true, | |
| 10762 | 10765 | }), |
| 10763 | 10766 | ); |
| 10764 | 10767 | |
| ... | ... | @@ -11324,6 +11327,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11324 | 11327 | scriptCommand: "npm install", |
| 11325 | 11328 | terminalId: "setup-setup", |
| 11326 | 11329 | cwd: "/tmp/bootstrap-worktree", |
| 11330 | async: true, | |
| 11327 | 11331 | }), |
| 11328 | 11332 | ); |
| 11329 | 11333 | let setupActivityAppendAttempt = 0; |
| ... | ... | @@ -11427,6 +11431,153 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11427 | 11431 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 11428 | 11432 | ); |
| 11429 | 11433 | |
| 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 | 11581 | it.effect("cleans up created bootstrap threads when worktree creation defects", () => |
| 11431 | 11582 | Effect.gen(function* () { |
| 11432 | 11583 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
apps/server/src/ws.ts+49-21| ... | ... | @@ -1112,15 +1112,16 @@ const makeWsRpcLayer = ( |
| 1112 | 1112 | const threadId = command.threadId; |
| 1113 | 1113 | const track = (effect: Effect.Effect<void>) => (tracked ? effect : Effect.void); |
| 1114 | 1114 | |
| 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. | |
| 1119 | 1120 | const runSetupProgram = () => |
| 1120 | 1121 | Effect.gen(function* () { |
| 1121 | 1122 | if (!bootstrap?.runSetupScript || !targetWorktreePath) { |
| 1122 | 1123 | yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped")); |
| 1123 | return; | |
| 1124 | return null; | |
| 1124 | 1125 | } |
| 1125 | 1126 | const worktreePath = targetWorktreePath; |
| 1126 | 1127 | const requestedAt = yield* nowIso; |
| ... | ... | @@ -1197,21 +1198,38 @@ const makeWsRpcLayer = ( |
| 1197 | 1198 | }), |
| 1198 | 1199 | ); |
| 1199 | 1200 | if (!tracked || !setupResult?.completion) { |
| 1200 | return; | |
| 1201 | return null; | |
| 1201 | 1202 | } |
| 1202 | 1203 | // The setup script is best effort, like the untracked path: a |
| 1203 | 1204 | // failed install must not throw away the worktree the user just |
| 1204 | 1205 | // 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; | |
| 1209 | 1231 | } |
| 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; | |
| 1215 | 1233 | }); |
| 1216 | 1234 | |
| 1217 | 1235 | const bootstrapProgram = Effect.gen(function* () { |
| ... | ... | @@ -1414,7 +1432,7 @@ const makeWsRpcLayer = ( |
| 1414 | 1432 | yield* refreshGitStatus(targetWorktreePath); |
| 1415 | 1433 | } |
| 1416 | 1434 | |
| 1417 | yield* runSetupProgram(); | |
| 1435 | const pendingSetupScript = yield* runSetupProgram(); | |
| 1418 | 1436 | |
| 1419 | 1437 | yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running")); |
| 1420 | 1438 | // Past this point a cancel would roll back a thread whose turn has |
| ... | ... | @@ -1423,11 +1441,21 @@ const makeWsRpcLayer = ( |
| 1423 | 1441 | const started = yield* Effect.uninterruptible( |
| 1424 | 1442 | dispatchFromClient(finalTurnStartCommand), |
| 1425 | 1443 | ); |
| 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 | } | |
| 1431 | 1459 | return started; |
| 1432 | 1460 | }); |
| 1433 | 1461 |
apps/web/src/components/ChatView.tsx+38-3| ... | ... | @@ -1430,6 +1430,16 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n |
| 1430 | 1430 | return current.messageId === null ? current : { ...current, messageId: null }; |
| 1431 | 1431 | } |
| 1432 | 1432 | |
| 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 | */ | |
| 1438 | const pendingWorktreeSetupByThreadKey = new Map< | |
| 1439 | string, | |
| 1440 | { environmentId: EnvironmentId; threadId: ThreadId } | |
| 1441 | >(); | |
| 1442 | ||
| 1433 | 1443 | export default function ChatView(props: ChatViewProps) { |
| 1434 | 1444 | const { |
| 1435 | 1445 | environmentId, |
| ... | ... | @@ -1659,7 +1669,12 @@ export default function ChatView(props: ChatViewProps) { |
| 1659 | 1669 | environmentId: EnvironmentId; |
| 1660 | 1670 | threadId: ThreadId; |
| 1661 | 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 | 1678 | const [heldWorktreeSetup, setHeldWorktreeSetup] = useState<WorktreeSetupSnapshot | null>(null); |
| 1664 | 1679 | // Set by "Work locally": the draft whose restored message should be resent |
| 1665 | 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 | 3515 | ? heldWorktreeSetup |
| 3501 | 3516 | : null; |
| 3502 | 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 | 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 | 3526 | useEffect(() => { |
| 3507 | 3527 | if (!worktreeSetupDoneAndTurnVisible) return; |
| 3508 | 3528 | setWorktreeSetupRef(null); |
| 3509 | 3529 | setHeldWorktreeSetup(null); |
| 3510 | 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 | 3540 | const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { |
| 3512 | 3541 | reportFailure: false, |
| 3513 | 3542 | }); |
| ... | ... | @@ -7515,6 +7544,12 @@ export default function ChatView(props: ChatViewProps) { |
| 7515 | 7544 | ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey } |
| 7516 | 7545 | : null, |
| 7517 | 7546 | ); |
| 7547 | if (baseBranchForWorktree) { | |
| 7548 | pendingWorktreeSetupByThreadKey.set( | |
| 7549 | scopedThreadKey(scopeThreadRef(environmentId, threadIdForSend)), | |
| 7550 | { environmentId, threadId: threadIdForSend }, | |
| 7551 | ); | |
| 7552 | } | |
| 7518 | 7553 | |
| 7519 | 7554 | const messageIdForSend = newMessageId(); |
| 7520 | 7555 | const messageCreatedAt = new Date().toISOString(); |
apps/web/src/components/ProjectScriptsControl.tsx+1| ... | ... | @@ -113,6 +113,7 @@ export default function ProjectScriptsControl({ |
| 113 | 113 | command: fileScript.command, |
| 114 | 114 | icon: fileScript.icon ?? "play", |
| 115 | 115 | runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, |
| 116 | waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, | |
| 116 | 117 | keybinding: null, |
| 117 | 118 | previewUrl: fileScript.previewUrl ?? null, |
| 118 | 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 | 1148 | id: WORKTREE_SETUP_ROW_ID, |
| 1149 | 1149 | createdAt: "2026-01-01T00:00:00Z", |
| 1150 | 1150 | snapshot, |
| 1151 | embedded: false, | |
| 1151 | 1152 | }, |
| 1152 | 1153 | ]); |
| 1153 | 1154 | |
| 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 | 1156 | const withMessages = deriveMessagesTimelineRows({ |
| 1156 | 1157 | timelineEntries: [userEntry, assistantEntry], |
| 1157 | 1158 | isWorking: true, |
| 1158 | 1159 | activeTurnStartedAt: "2026-01-01T00:00:00Z", |
| 1159 | 1160 | turnDiffSummaries: [], |
| 1160 | 1161 | supportsConversationRollback: false, |
| 1161 | worktreeSetup: { ...snapshot, phase: "done" }, | |
| 1162 | worktreeSetup: { ...snapshot, phase: "failed" }, | |
| 1162 | 1163 | }); |
| 1163 | 1164 | expect(withMessages.map((row) => row.kind)).toEqual([ |
| 1164 | 1165 | "message", |
| ... | ... | @@ -1166,6 +1167,73 @@ describe("deriveMessagesTimelineRows", () => { |
| 1166 | 1167 | "working", |
| 1167 | 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 | }); |
| 1170 | 1238 | |
| 1171 | 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 | 400 | id: string; |
| 401 | 401 | createdAt: string | null; |
| 402 | 402 | snapshot: WorktreeSetupSnapshot; |
| 403 | /** The agent already started; render only the script row under the turn header. */ | |
| 404 | embedded: boolean; | |
| 403 | 405 | }; |
| 404 | 406 | |
| 405 | 407 | export interface StableMessagesTimelineRowsState { |
| ... | ... | @@ -1260,15 +1262,23 @@ export function deriveMessagesTimelineRows(input: { |
| 1260 | 1262 | }); |
| 1261 | 1263 | } |
| 1262 | 1264 | |
| 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) { | |
| 1267 | 1276 | const setupRow = { |
| 1268 | 1277 | kind: "worktree-setup", |
| 1269 | 1278 | id: WORKTREE_SETUP_ROW_ID, |
| 1270 | 1279 | createdAt: input.worktreeSetup.startedAt, |
| 1271 | 1280 | snapshot: input.worktreeSetup, |
| 1281 | embedded: false, | |
| 1272 | 1282 | } as const; |
| 1273 | 1283 | // Sit directly under the first user message: a finished snapshot can |
| 1274 | 1284 | // outlive the first assistant reply, and it belongs to the send, not the |
| ... | ... | @@ -1287,6 +1297,31 @@ export function deriveMessagesTimelineRows(input: { |
| 1287 | 1297 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1288 | 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 | 1325 | if (input.isWorking && (!hasActivityRow || latestToolFailed)) { |
| 1291 | 1326 | nextRows.push({ |
| 1292 | 1327 | kind: "thinking", |
| ... | ... | @@ -1300,6 +1335,11 @@ export function deriveMessagesTimelineRows(input: { |
| 1300 | 1335 | |
| 1301 | 1336 | export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; |
| 1302 | 1337 | |
| 1338 | /** True once the bootstrap handed off to the agent (async setup script may still run). */ | |
| 1339 | function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { | |
| 1340 | return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); | |
| 1341 | } | |
| 1342 | ||
| 1303 | 1343 | type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0]; |
| 1304 | 1344 | |
| 1305 | 1345 | export interface MessagesTimelineRowsProjection { |
apps/web/src/components/chat/MessagesTimeline.tsx+5-2| ... | ... | @@ -1464,8 +1464,11 @@ function WorktreeSetupTimelineRow({ |
| 1464 | 1464 | return ( |
| 1465 | 1465 | <WorktreeSetupCard |
| 1466 | 1466 | 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 | } | |
| 1469 | 1472 | onOpenTerminal={onOpenTerminal} |
| 1470 | 1473 | /> |
| 1471 | 1474 | ); |
apps/web/src/components/chat/WorktreeSetupCard.tsx+169-111| ... | ... | @@ -10,16 +10,16 @@ import { |
| 10 | 10 | ChevronRightIcon, |
| 11 | 11 | CircleAlertIcon, |
| 12 | 12 | CircleIcon, |
| 13 | GitBranchIcon, | |
| 14 | 13 | LaptopIcon, |
| 15 | 14 | MinusIcon, |
| 16 | 15 | TerminalIcon, |
| 17 | 16 | XIcon, |
| 18 | 17 | } from "lucide-react"; |
| 19 | import { useEffect, useState } from "react"; | |
| 18 | import { useEffect, useState, type ReactNode } from "react"; | |
| 20 | 19 | |
| 21 | 20 | import { Button } from "~/components/ui/button"; |
| 22 | 21 | import { Spinner } from "~/components/ui/spinner"; |
| 22 | import { observeVisibleAnimation } from "~/lib/visibleAnimation"; | |
| 23 | 23 | import { cn } from "~/lib/utils"; |
| 24 | 24 | |
| 25 | 25 | interface WorktreeSetupCardProps { |
| ... | ... | @@ -74,20 +74,95 @@ function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) { |
| 74 | 74 | |
| 75 | 75 | function stageRowClassName(status: WorktreeSetupStage["status"]): string { |
| 76 | 76 | switch (status) { |
| 77 | case "running": | |
| 78 | return "text-foreground"; | |
| 79 | 77 | case "failed": |
| 80 | 78 | return "text-destructive-foreground"; |
| 81 | 79 | case "warning": |
| 82 | 80 | return "text-warning-foreground"; |
| 83 | 81 | case "pending": |
| 82 | return "text-secondary-label opacity-40"; | |
| 83 | case "running": | |
| 84 | 84 | case "skipped": |
| 85 | return "text-secondary-label opacity-50"; | |
| 86 | 85 | case "done": |
| 87 | 86 | return "text-secondary-label"; |
| 88 | 87 | } |
| 89 | 88 | } |
| 90 | 89 | |
| 90 | /** Same shimmer treatment as the live tool rows in the timeline. */ | |
| 91 | function 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 | ||
| 104 | function 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 | */ | |
| 123 | function 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. */ | |
| 91 | 166 | function StageRow({ |
| 92 | 167 | stage, |
| 93 | 168 | nowMs, |
| ... | ... | @@ -100,44 +175,49 @@ function StageRow({ |
| 100 | 175 | const elapsed = stageElapsedMs(stage, nowMs); |
| 101 | 176 | const label = |
| 102 | 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 | 179 | const trailing = |
| 105 | 180 | stage.status === "pending" |
| 106 | 181 | ? null |
| 107 | 182 | : stage.status === "skipped" |
| 108 | 183 | ? (stage.detail ?? "skipped") |
| 109 | : stage.detail; | |
| 110 | ||
| 184 | : stage.id === "checkout" && running && stage.percent !== null | |
| 185 | ? `${stage.percent}%` | |
| 186 | : stage.detail; | |
| 111 | 187 | return ( |
| 112 | 188 | <div |
| 189 | ref={running ? observeVisibleAnimation : undefined} | |
| 113 | 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 | 192 | stageRowClassName(stage.status), |
| 116 | 193 | )} |
| 117 | 194 | data-worktree-setup-stage={stage.id} |
| 118 | 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 | 198 | <StageIcon status={stage.status} /> |
| 122 | 199 | </span> |
| 123 | 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"> | |
| 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} /> | |
| 132 | 216 | </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} | |
| 141 | 221 | </div> |
| 142 | 222 | ); |
| 143 | 223 | } |
| ... | ... | @@ -158,19 +238,35 @@ function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: b |
| 158 | 238 | ); |
| 159 | 239 | } |
| 160 | 240 | |
| 161 | function 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 | } | |
| 241 | function 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 | ); | |
| 174 | 270 | } |
| 175 | 271 | |
| 176 | 272 | export function WorktreeSetupCard({ |
| ... | ... | @@ -178,7 +274,14 @@ export function WorktreeSetupCard({ |
| 178 | 274 | onCancel, |
| 179 | 275 | onWorkLocally, |
| 180 | 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 | 285 | const running = snapshot.phase === "running"; |
| 183 | 286 | const nowMs = useNowWhile(running); |
| 184 | 287 | const [detailsOpen, setDetailsOpen] = useState(false); |
| ... | ... | @@ -188,79 +291,35 @@ export function WorktreeSetupCard({ |
| 188 | 291 | return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; |
| 189 | 292 | })(); |
| 190 | 293 | 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; | |
| 201 | 298 | |
| 202 | 299 | 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 | ))} | |
| 218 | 312 | </div> |
| 219 | 313 | |
| 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> | |
| 232 | 316 | ) : null} |
| 233 | 317 | |
| 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} | |
| 262 | 319 | |
| 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 | 323 | <Button |
| 265 | 324 | type="button" |
| 266 | 325 | size="xs" |
| ... | ... | @@ -271,21 +330,20 @@ export function WorktreeSetupCard({ |
| 271 | 330 | {detailsOpen ? <ChevronDownIcon aria-hidden /> : <ChevronRightIcon aria-hidden />} |
| 272 | 331 | Details |
| 273 | 332 | </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}> | |
| 277 | 335 | <TerminalIcon aria-hidden /> |
| 278 | 336 | Open terminal |
| 279 | 337 | </Button> |
| 280 | 338 | ) : null} |
| 281 | 339 | {onWorkLocally ? ( |
| 282 | <Button type="button" size="xs" variant="outline" onClick={onWorkLocally}> | |
| 340 | <Button type="button" size="xs" variant="ghost-muted" onClick={onWorkLocally}> | |
| 283 | 341 | <LaptopIcon aria-hidden /> |
| 284 | 342 | Work locally |
| 285 | 343 | </Button> |
| 286 | 344 | ) : null} |
| 287 | 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 | 347 | <XIcon aria-hidden /> |
| 290 | 348 | Cancel |
| 291 | 349 | </Button> |
apps/web/src/components/projectScriptEditor.tsx+19| ... | ... | @@ -85,6 +85,8 @@ export interface NewProjectScriptInput { |
| 85 | 85 | command: string; |
| 86 | 86 | icon: ProjectScriptIcon; |
| 87 | 87 | runOnWorktreeCreate: boolean; |
| 88 | /** Setup scripts only: hold the agent until the script exits. */ | |
| 89 | waitForSetup: boolean; | |
| 88 | 90 | keybinding: string | null; |
| 89 | 91 | /** Optional URL to open in the in-app preview when this script runs. */ |
| 90 | 92 | previewUrl: string | null; |
| ... | ... | @@ -99,6 +101,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { |
| 99 | 101 | command: "", |
| 100 | 102 | icon: "play", |
| 101 | 103 | runOnWorktreeCreate: false, |
| 104 | waitForSetup: false, | |
| 102 | 105 | keybinding: null, |
| 103 | 106 | previewUrl: null, |
| 104 | 107 | autoOpenPreview: false, |
| ... | ... | @@ -123,6 +126,7 @@ export function editorRequestForScript( |
| 123 | 126 | command: script.command, |
| 124 | 127 | icon: script.icon, |
| 125 | 128 | runOnWorktreeCreate: script.runOnWorktreeCreate, |
| 129 | waitForSetup: script.runOnWorktreeCreate && script.async === false, | |
| 126 | 130 | keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), |
| 127 | 131 | previewUrl: script.previewUrl ?? null, |
| 128 | 132 | autoOpenPreview: script.autoOpenPreview ?? false, |
| ... | ... | @@ -158,6 +162,7 @@ export function ProjectScriptEditorDialog({ |
| 158 | 162 | const [icon, setIcon] = useState<ProjectScriptIcon>("play"); |
| 159 | 163 | const [iconPickerOpen, setIconPickerOpen] = useState(false); |
| 160 | 164 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); |
| 165 | const [waitForSetup, setWaitForSetup] = useState(false); | |
| 161 | 166 | const [keybinding, setKeybinding] = useState(""); |
| 162 | 167 | const [previewUrl, setPreviewUrl] = useState(""); |
| 163 | 168 | const [autoOpenPreview, setAutoOpenPreview] = useState(false); |
| ... | ... | @@ -188,6 +193,7 @@ export function ProjectScriptEditorDialog({ |
| 188 | 193 | setIcon(request.initial.icon); |
| 189 | 194 | setIconPickerOpen(false); |
| 190 | 195 | setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); |
| 196 | setWaitForSetup(request.initial.waitForSetup); | |
| 191 | 197 | setKeybinding(request.initial.keybinding ?? ""); |
| 192 | 198 | setPreviewUrl(request.initial.previewUrl ?? ""); |
| 193 | 199 | setAutoOpenPreview(request.initial.autoOpenPreview); |
| ... | ... | @@ -247,6 +253,7 @@ export function ProjectScriptEditorDialog({ |
| 247 | 253 | command: trimmedCommand, |
| 248 | 254 | icon, |
| 249 | 255 | runOnWorktreeCreate, |
| 256 | waitForSetup: runOnWorktreeCreate && waitForSetup, | |
| 250 | 257 | keybinding: keybindingRule?.key ?? null, |
| 251 | 258 | previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, |
| 252 | 259 | autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, |
| ... | ... | @@ -396,6 +403,18 @@ export function ProjectScriptEditorDialog({ |
| 396 | 403 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} |
| 397 | 404 | /> |
| 398 | 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 | 418 | <label |
| 400 | 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 | 420 | previewUrl.trim().length === 0 ? "opacity-60" : "" |
apps/web/src/components/settings/ProjectActionsSettings.tsx+1| ... | ... | @@ -111,6 +111,7 @@ export function ProjectActionsSettings() { |
| 111 | 111 | command: fileScript.command, |
| 112 | 112 | icon: fileScript.icon ?? "play", |
| 113 | 113 | runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, |
| 114 | waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, | |
| 114 | 115 | keybinding: null, |
| 115 | 116 | previewUrl: fileScript.previewUrl ?? null, |
| 116 | 117 | autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, |
apps/web/src/projectScripts.test.ts+21| ... | ... | @@ -23,6 +23,7 @@ describe("projectScripts helpers", () => { |
| 23 | 23 | command: "pnpm dev", |
| 24 | 24 | icon: "debug", |
| 25 | 25 | runOnWorktreeCreate: false, |
| 26 | waitForSetup: false, | |
| 26 | 27 | previewUrl: "http://localhost:5733", |
| 27 | 28 | autoOpenPreview: true, |
| 28 | 29 | }), |
| ... | ... | @@ -44,6 +45,7 @@ describe("projectScripts helpers", () => { |
| 44 | 45 | command: "pnpm test", |
| 45 | 46 | icon: "test", |
| 46 | 47 | runOnWorktreeCreate: false, |
| 48 | waitForSetup: false, | |
| 47 | 49 | previewUrl: null, |
| 48 | 50 | autoOpenPreview: false, |
| 49 | 51 | }), |
| ... | ... | @@ -56,6 +58,25 @@ describe("projectScripts helpers", () => { |
| 56 | 58 | }); |
| 57 | 59 | }); |
| 58 | 60 | |
| 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 | 80 | it("builds and parses script run commands", () => { |
| 60 | 81 | const command = commandForProjectScript("lint"); |
| 61 | 82 | expect(command).toBe("script.lint.run"); |
apps/web/src/projectScripts.ts+2| ... | ... | @@ -12,6 +12,7 @@ export interface ProjectScriptInput { |
| 12 | 12 | readonly command: ProjectScript["command"]; |
| 13 | 13 | readonly icon: ProjectScript["icon"]; |
| 14 | 14 | readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"]; |
| 15 | readonly waitForSetup: boolean; | |
| 15 | 16 | readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null; |
| 16 | 17 | readonly autoOpenPreview: boolean; |
| 17 | 18 | } |
| ... | ... | @@ -23,6 +24,7 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje |
| 23 | 24 | command: input.command, |
| 24 | 25 | icon: input.icon, |
| 25 | 26 | runOnWorktreeCreate: input.runOnWorktreeCreate, |
| 27 | ...(input.runOnWorktreeCreate && input.waitForSetup ? { async: false } : {}), | |
| 26 | 28 | ...(input.previewUrl === null |
| 27 | 29 | ? {} |
| 28 | 30 | : { |
packages/contracts/src/orchestration.ts+6| ... | ... | @@ -404,6 +404,12 @@ export const ProjectScript = Schema.Struct({ |
| 404 | 404 | command: TrimmedNonEmptyString, |
| 405 | 405 | icon: ProjectScriptIcon, |
| 406 | 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 | 414 | * URL to open in the in-app browser preview when this script runs (or |
| 409 | 415 | * 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 | 20 | autoOpenPreview: true, |
| 21 | 21 | }, |
| 22 | 22 | { name: "Test", command: "pnpm test" }, |
| 23 | { name: "Setup", command: "pnpm i", runOnWorktreeCreate: true, async: false }, | |
| 23 | 24 | ], |
| 24 | 25 | }); |
| 25 | 26 | |
| 26 | 27 | expect(decoded.iconPath).toBe("assets/logo.svg"); |
| 27 | expect(decoded.scripts).toHaveLength(2); | |
| 28 | expect(decoded.scripts).toHaveLength(3); | |
| 28 | 29 | expect(decoded.scripts?.[1]).toEqual({ name: "Test", command: "pnpm test" }); |
| 30 | expect(decoded.scripts?.[2]?.async).toBe(false); | |
| 29 | 31 | }); |
| 30 | 32 | |
| 31 | 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 | 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 | 51 | previewUrl: Schema.optionalKey( |
| 46 | 52 | trimmedNonEmpty({ |
| 47 | 53 | description: |
packages/shared/src/t3ProjectFile.test.ts+1| ... | ... | @@ -44,6 +44,7 @@ describe("buildT3ProjectFileJsonSchema", () => { |
| 44 | 44 | const script = schema.properties.scripts?.items; |
| 45 | 45 | expect(script?.required).toEqual(["name", "command"]); |
| 46 | 46 | expect(Object.keys(script?.properties ?? {}).sort()).toEqual([ |
| 47 | "async", | |
| 47 | 48 | "autoOpenPreview", |
| 48 | 49 | "command", |
| 49 | 50 | "icon", |