authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-14 21:08:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 21:08:18-07:00
log0310cbf9f46ca94e9df0231a3440bca08ac44709
treecdbad762a5725026a9d34615f51ec1fed94d0281
parent5623089aea68ca62811f51321686c259fa4c810f
signature Signed by PGP key B5690EEEBB952194

fix: keep worktree setup visible after leaving and reopening the thread (#11836)

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

4 files changed, 166 insertions(+), 20 deletions(-)

apps/server/src/server.test.ts+42-9
...@@ -10836,17 +10836,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10836,17 +10836,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10836 ),10836 ),
10837 );10837 );
1083810838
10839 assert.equal(response.sequence, 5);10839 assert.equal(response.sequence, 6);
10840 assert.deepEqual(10840 assert.deepEqual(
10841 dispatchedCommands.map((command) => command.type),10841 dispatchedCommands.map((command) => command.type),
10842 [10842 [
10843 "thread.create",10843 "thread.create",
10844 "thread.session.set",
10844 "thread.meta.update",10845 "thread.meta.update",
10845 "thread.activity.append",10846 "thread.activity.append",
10846 "thread.activity.append",10847 "thread.activity.append",
10847 "thread.turn.start",10848 "thread.turn.start",
10848 ],10849 ],
10849 );10850 );
10851 // The checkout can take minutes, so the thread reads as working from
10852 // the moment setup starts rather than only once the turn is dispatched.
10853 const preparingCommand = dispatchedCommands[1];
10854 assertTrue(preparingCommand?.type === "thread.session.set");
10855 if (preparingCommand?.type === "thread.session.set") {
10856 assert.equal(preparingCommand.session.status, "starting");
10857 assert.equal(preparingCommand.session.activeTurnId, null);
10858 assert.equal(
10859 preparingCommand.session.providerInstanceId,
10860 defaultModelSelection.instanceId,
10861 );
10862 }
10850 assert.deepEqual(createWorktree.mock.calls[0]?.[0], {10863 assert.deepEqual(createWorktree.mock.calls[0]?.[0], {
10851 cwd: "/tmp/project",10864 cwd: "/tmp/project",
10852 refName: fetchedOriginCommit,10865 refName: fetchedOriginCommit,
...@@ -10902,7 +10915,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10902,7 +10915,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10902 setupActivities.map((command) => command.activity.kind),10915 setupActivities.map((command) => command.activity.kind),
10903 ["setup-script.requested", "setup-script.started"],10916 ["setup-script.requested", "setup-script.started"],
10904 );10917 );
10905 const finalCommand = dispatchedCommands[4];10918 const finalCommand = dispatchedCommands[5];
10906 assertTrue(finalCommand?.type === "thread.turn.start");10919 assertTrue(finalCommand?.type === "thread.turn.start");
10907 if (finalCommand?.type === "thread.turn.start") {10920 if (finalCommand?.type === "thread.turn.start") {
10908 assert.equal(finalCommand.bootstrap, undefined);10921 assert.equal(finalCommand.bootstrap, undefined);
...@@ -11284,10 +11297,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11284,10 +11297,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11284 ),11297 ),
11285 );11298 );
1128611299
11287 assert.equal(response.sequence, 4);11300 assert.equal(response.sequence, 5);
11288 assert.deepEqual(11301 assert.deepEqual(
11289 dispatchedCommands.map((command) => command.type),11302 dispatchedCommands.map((command) => command.type),
11290 ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"],11303 [
11304 "thread.create",
11305 "thread.session.set",
11306 "thread.meta.update",
11307 "thread.activity.append",
11308 "thread.turn.start",
11309 ],
11291 );11310 );
11292 const setupFailureActivity = dispatchedCommands.find(11311 const setupFailureActivity = dispatchedCommands.find(
11293 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>11312 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>
...@@ -11411,10 +11430,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11411,10 +11430,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11411 ),11430 ),
11412 );11431 );
1141311432
11414 assert.equal(response.sequence, 4);11433 assert.equal(response.sequence, 5);
11415 assert.deepEqual(11434 assert.deepEqual(
11416 dispatchedCommands.map((command) => command.type),11435 dispatchedCommands.map((command) => command.type),
11417 ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"],11436 [
11437 "thread.create",
11438 "thread.session.set",
11439 "thread.meta.update",
11440 "thread.activity.append",
11441 "thread.turn.start",
11442 ],
11418 );11443 );
11419 const setupActivities = dispatchedCommands.filter(11444 const setupActivities = dispatchedCommands.filter(
11420 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>11445 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>
...@@ -11676,7 +11701,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11676,7 +11701,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11676 assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted");11701 assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted");
11677 assert.deepEqual(11702 assert.deepEqual(
11678 dispatchedCommands.map((command) => command.type),11703 dispatchedCommands.map((command) => command.type),
11679 ["thread.create", "thread.delete"],11704 ["thread.create", "thread.session.set", "thread.delete"],
11680 );11705 );
11681 assert.isDefined(pendingAttachmentId);11706 assert.isDefined(pendingAttachmentId);
11682 assert.isTrue(11707 assert.isTrue(
...@@ -11874,8 +11899,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11874,8 +11899,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11874 assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined);11899 assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined);
11875 assert.deepEqual(11900 assert.deepEqual(
11876 dispatchedCommands.map((command) => command.type),11901 dispatchedCommands.map((command) => command.type),
11877 ["thread.create", "thread.delete"],11902 ["thread.create", "thread.session.set", "thread.delete", "thread.session.set"],
11878 );11903 );
11904 // The surviving thread must not keep its preparing session, or it would
11905 // read as working forever.
11906 const failedSession = dispatchedCommands[3];
11907 assertTrue(failedSession?.type === "thread.session.set");
11908 if (failedSession?.type === "thread.session.set") {
11909 assert.equal(failedSession.session.status, "error");
11910 assert.include(failedSession.session.lastError ?? "", "worktree exploded");
11911 }
11879 }).pipe(Effect.provide(NodeHttpServer.layerTest)),11912 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11880 );11913 );
1188111914
apps/server/src/ws.ts+63-1
...@@ -1017,6 +1017,30 @@ const makeWsRpcLayer = (...@@ -1017,6 +1017,30 @@ const makeWsRpcLayer = (
1017 // one so terminals the user opened meanwhile survive.1017 // one so terminals the user opened meanwhile survive.
1018 let setupTerminalId: string | null = null;1018 let setupTerminalId: string | null = null;
10191019
1020 // Set once the checkout starts; see the session.set below.
1021 let preparingSessionSet = false;
1022 const markPreparingSessionFailed = (detail: string) =>
1023 Effect.gen(function* () {
1024 const failedAt = yield* nowIso;
1025 yield* dispatchFromClient({
1026 type: "thread.session.set",
1027 commandId: yield* serverCommandId("bootstrap-thread-preparing-failed"),
1028 threadId,
1029 session: {
1030 threadId,
1031 status: "error",
1032 providerName: null,
1033 providerInstanceId:
1034 bootstrap?.createThread?.modelSelection.instanceId ??
1035 command.modelSelection?.instanceId,
1036 runtimeMode: command.runtimeMode,
1037 activeTurnId: null,
1038 lastError: detail.trim().length > 0 ? detail : "Worktree setup failed.",
1039 updatedAt: failedAt,
1040 },
1041 createdAt: failedAt,
1042 });
1043 });
1020 const cleanupCreatedThread = () =>1044 const cleanupCreatedThread = () =>
1021 createdThread1045 createdThread
1022 ? serverCommandId("bootstrap-thread-delete").pipe(1046 ? serverCommandId("bootstrap-thread-delete").pipe(
...@@ -1340,6 +1364,32 @@ const makeWsRpcLayer = (...@@ -1340,6 +1364,32 @@ const makeWsRpcLayer = (
1340 }1364 }
13411365
1342 if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) {1366 if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) {
1367 if (bootstrap?.createThread && createdThread) {
1368 // The checkout and setup script can run for minutes before the
1369 // turn starts, and the created thread carries no message or
1370 // turn until then. Project a starting session now so every
1371 // client lists the thread as working and a reopened thread
1372 // knows to follow the setup stream. A failed or cancelled setup
1373 // deletes the thread, so nothing lingers.
1374 const preparingAt = yield* nowIso;
1375 yield* dispatchFromClient({
1376 type: "thread.session.set",
1377 commandId: yield* serverCommandId("bootstrap-thread-preparing"),
1378 threadId,
1379 session: {
1380 threadId,
1381 status: "starting",
1382 providerName: null,
1383 providerInstanceId: bootstrap.createThread.modelSelection.instanceId,
1384 runtimeMode: command.runtimeMode,
1385 activeTurnId: null,
1386 lastError: null,
1387 updatedAt: preparingAt,
1388 },
1389 createdAt: preparingAt,
1390 });
1391 preparingSessionSet = true;
1392 }
1343 yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running");1393 yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running");
1344 let checkoutTotal: number | null = null;1394 let checkoutTotal: number | null = null;
1345 const worktree = yield* gitWorkflow.createWorktree(1395 const worktree = yield* gitWorkflow.createWorktree(
...@@ -1483,7 +1533,19 @@ const makeWsRpcLayer = (...@@ -1483,7 +1533,19 @@ const makeWsRpcLayer = (
1483 Effect.logWarning("bootstrap thread cleanup failed", {1533 Effect.logWarning("bootstrap thread cleanup failed", {
1484 threadId,1534 threadId,
1485 detail: Cause.pretty(cleanupCause),1535 detail: Cause.pretty(cleanupCause),
1486 }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))),1536 }).pipe(
1537 // The thread outlived its setup. Its preparing session
1538 // must not read as working forever, so record the failure
1539 // on it instead.
1540 Effect.andThen(
1541 preparingSessionSet
1542 ? markPreparingSessionFailed(dispatchError.message).pipe(
1543 Effect.ignoreCause({ log: true }),
1544 )
1545 : Effect.void,
1546 ),
1547 Effect.flatMap(() => Effect.fail(dispatchError)),
1548 ),
1487 onSuccess: (threadDeleted) =>1549 onSuccess: (threadDeleted) =>
1488 Effect.fail(1550 Effect.fail(
1489 threadDeleted1551 threadDeleted
apps/web/src/components/ChatView.tsx+60-9
...@@ -349,7 +349,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";...@@ -349,7 +349,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
349import { PullRequestThreadDialog } from "./PullRequestThreadDialog";349import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
350import { MessagesTimeline } from "./chat/MessagesTimeline";350import { MessagesTimeline } from "./chat/MessagesTimeline";
351import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";351import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
352import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";352import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic";
353import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout";353import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout";
354import { ChatHeader } from "./chat/ChatHeader";354import { ChatHeader } from "./chat/ChatHeader";
355import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";355import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
...@@ -3493,14 +3493,47 @@ export default function ChatView(props: ChatViewProps) {...@@ -3493,14 +3493,47 @@ export default function ChatView(props: ChatViewProps) {
3493 const worktreeSetupOwnerKey = draftId ?? routeThreadKey;3493 const worktreeSetupOwnerKey = draftId ?? routeThreadKey;
3494 const worktreeSetupActive =3494 const worktreeSetupActive =
3495 worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey;3495 worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey;
3496 // A thread reopened mid-setup has no dispatch ref: this view remounted.
3497 // The server projects a starting session before any message or turn exists
3498 // for exactly that window, so follow the setup stream from the thread's own
3499 // state. The ref is adopted below so the card then follows the same
3500 // lifecycle as the original send, including an async setup script that
3501 // keeps running after the agent's turn lands.
3502 const resumedWorktreeSetupRef =
3503 !worktreeSetupActive &&
3504 isServerThread &&
3505 activeThreadRef !== null &&
3506 activeThreadShell?.session?.status === "starting" &&
3507 activeThreadShell.latestTurn === null &&
3508 activeThreadShell.latestUserMessageAt === null
3509 ? activeThreadRef
3510 : null;
3511 useEffect(() => {
3512 if (!resumedWorktreeSetupRef) return;
3513 // Only a ref owned by this route survives adoption. This component is
3514 // reused across routes, so a ref left by another thread's send is stale
3515 // here and would leave the resumed setup with no target after handoff.
3516 setWorktreeSetupRef((current) =>
3517 current?.ownerKey === worktreeSetupOwnerKey
3518 ? current
3519 : { ...resumedWorktreeSetupRef, ownerKey: worktreeSetupOwnerKey },
3520 );
3521 }, [resumedWorktreeSetupRef, worktreeSetupOwnerKey]);
3496 // The setup runs on the environment that received the dispatch, so both3522 // The setup runs on the environment that received the dispatch, so both
3497 // the subscription and cancel target that one even if the draft's machine3523 // the subscription and cancel target that one even if the draft's machine
3498 // picker changes underneath.3524 // picker changes underneath.
3525 const worktreeSetupTarget = useMemo(
3526 () =>
3527 worktreeSetupActive
3528 ? { environmentId: worktreeSetupRef.environmentId, threadId: worktreeSetupRef.threadId }
3529 : resumedWorktreeSetupRef,
3530 [resumedWorktreeSetupRef, worktreeSetupActive, worktreeSetupRef],
3531 );
3499 const worktreeSetupQuery = useEnvironmentQuery(3532 const worktreeSetupQuery = useEnvironmentQuery(
3500 worktreeSetupActive3533 worktreeSetupTarget
3501 ? vcsEnvironment.worktreeSetup({3534 ? vcsEnvironment.worktreeSetup({
3502 environmentId: worktreeSetupRef.environmentId,3535 environmentId: worktreeSetupTarget.environmentId,
3503 input: { threadId: worktreeSetupRef.threadId },3536 input: { threadId: worktreeSetupTarget.threadId },
3504 })3537 })
3505 : null,3538 : null,
3506 );3539 );
...@@ -3511,7 +3544,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3511,7 +3544,7 @@ export default function ChatView(props: ChatViewProps) {
3511 if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup);3544 if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup);
3512 }, [latestWorktreeSetup]);3545 }, [latestWorktreeSetup]);
3513 const worktreeSetup =3546 const worktreeSetup =
3514 worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId3547 worktreeSetupTarget !== null && heldWorktreeSetup?.threadId === worktreeSetupTarget.threadId
3515 ? heldWorktreeSetup3548 ? heldWorktreeSetup
3516 : null;3549 : null;
3517 // A finished card is dropped once the agent's turn shows in the timeline:3550 // A finished card is dropped once the agent's turn shows in the timeline:
...@@ -3537,16 +3570,32 @@ export default function ChatView(props: ChatViewProps) {...@@ -3537,16 +3570,32 @@ export default function ChatView(props: ChatViewProps) {
3537 useEffect(() => {3570 useEffect(() => {
3538 if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey);3571 if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey);
3539 }, [worktreeSetupSettledKey]);3572 }, [worktreeSetupSettledKey]);
3573 // Sends wait for the agent handoff, not for the setup script: an async
3574 // script keeps the snapshot running while the agent already works, and a
3575 // follow-up must not be held behind a slow install. A resumed setup has no
3576 // snapshot until its first stream event, so that gap blocks as well. The
3577 // gap is judged by the shell, not by the resumed ref, since adopting the
3578 // ref clears it before the query has delivered anything.
3579 const worktreeSetupAwaitingFirstSnapshot =
3580 worktreeSetup === null &&
3581 worktreeSetupTarget !== null &&
3582 isServerThread &&
3583 activeThreadShell?.session?.status === "starting" &&
3584 activeThreadShell.latestTurn === null;
3585 const worktreeSetupBlocksSend =
3586 worktreeSetup !== null
3587 ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup)
3588 : worktreeSetupAwaitingFirstSnapshot;
3540 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {3589 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
3541 reportFailure: false,3590 reportFailure: false,
3542 });3591 });
3543 const onCancelWorktreeSetup = useCallback(() => {3592 const onCancelWorktreeSetup = useCallback(() => {
3544 if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return;3593 if (!worktreeSetup || !worktreeSetupTarget || worktreeSetup.phase !== "running") return;
3545 void cancelWorktreeSetup({3594 void cancelWorktreeSetup({
3546 environmentId: worktreeSetupRef.environmentId,3595 environmentId: worktreeSetupTarget.environmentId,
3547 input: { threadId: worktreeSetup.threadId },3596 input: { threadId: worktreeSetup.threadId },
3548 });3597 });
3549 }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]);3598 }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupTarget]);
3550 // The setup terminal belongs to the thread that was set up. A failed3599 // The setup terminal belongs to the thread that was set up. A failed
3551 // bootstrap deletes that thread and closes its terminals, so only offer the3600 // bootstrap deletes that thread and closes its terminals, so only offer the
3552 // terminal while the setup thread is still the active one.3601 // terminal while the setup thread is still the active one.
...@@ -9313,7 +9362,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -9313,7 +9362,9 @@ export default function ChatView(props: ChatViewProps) {
9313 ? "Sending feedback"9362 ? "Sending feedback"
9314 : threadDetailLoading9363 : threadDetailLoading
9315 ? "Messages loading"9364 ? "Messages loading"
9316 : projectCloneSendBlockReason9365 : worktreeSetupBlocksSend
9366 ? "Preparing worktree"
9367 : projectCloneSendBlockReason
9317 }9368 }
9318 isPreparingWorktree={isPreparingWorktree}9369 isPreparingWorktree={isPreparingWorktree}
9319 bannerItems={composerBannerItems}9370 bannerItems={composerBannerItems}
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-1
...@@ -1336,7 +1336,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -1336,7 +1336,7 @@ export function deriveMessagesTimelineRows(input: {
1336export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";1336export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
13371337
1338/** True once the bootstrap handed off to the agent (async setup script may still run). */1338/** True once the bootstrap handed off to the agent (async setup script may still run). */
1339function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean {1339export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean {
1340 return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done");1340 return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done");
1341}1341}
13421342