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) => {
1083610836 ),
1083710837 );
1083810838
10839 assert.equal(response.sequence, 5);
10839 assert.equal(response.sequence, 6);
1084010840 assert.deepEqual(
1084110841 dispatchedCommands.map((command) => command.type),
1084210842 [
1084310843 "thread.create",
10844 "thread.session.set",
1084410845 "thread.meta.update",
1084510846 "thread.activity.append",
1084610847 "thread.activity.append",
1084710848 "thread.turn.start",
1084810849 ],
1084910850 );
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 }
1085010863 assert.deepEqual(createWorktree.mock.calls[0]?.[0], {
1085110864 cwd: "/tmp/project",
1085210865 refName: fetchedOriginCommit,
......@@ -10902,7 +10915,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1090210915 setupActivities.map((command) => command.activity.kind),
1090310916 ["setup-script.requested", "setup-script.started"],
1090410917 );
10905 const finalCommand = dispatchedCommands[4];
10918 const finalCommand = dispatchedCommands[5];
1090610919 assertTrue(finalCommand?.type === "thread.turn.start");
1090710920 if (finalCommand?.type === "thread.turn.start") {
1090810921 assert.equal(finalCommand.bootstrap, undefined);
......@@ -11284,10 +11297,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1128411297 ),
1128511298 );
1128611299
11287 assert.equal(response.sequence, 4);
11300 assert.equal(response.sequence, 5);
1128811301 assert.deepEqual(
1128911302 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 ],
1129111310 );
1129211311 const setupFailureActivity = dispatchedCommands.find(
1129311312 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>
......@@ -11411,10 +11430,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1141111430 ),
1141211431 );
1141311432
11414 assert.equal(response.sequence, 4);
11433 assert.equal(response.sequence, 5);
1141511434 assert.deepEqual(
1141611435 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 ],
1141811443 );
1141911444 const setupActivities = dispatchedCommands.filter(
1142011445 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>
......@@ -11676,7 +11701,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1167611701 assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted");
1167711702 assert.deepEqual(
1167811703 dispatchedCommands.map((command) => command.type),
11679 ["thread.create", "thread.delete"],
11704 ["thread.create", "thread.session.set", "thread.delete"],
1168011705 );
1168111706 assert.isDefined(pendingAttachmentId);
1168211707 assert.isTrue(
......@@ -11874,8 +11899,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1187411899 assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined);
1187511900 assert.deepEqual(
1187611901 dispatchedCommands.map((command) => command.type),
11877 ["thread.create", "thread.delete"],
11878 );
11902 ["thread.create", "thread.session.set", "thread.delete", "thread.session.set"],
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 }
1187911912 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1188011913 );
1188111914
apps/server/src/ws.ts+63-1
......@@ -1017,6 +1017,30 @@ const makeWsRpcLayer = (
10171017 // one so terminals the user opened meanwhile survive.
10181018 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 });
10201044 const cleanupCreatedThread = () =>
10211045 createdThread
10221046 ? serverCommandId("bootstrap-thread-delete").pipe(
......@@ -1340,6 +1364,32 @@ const makeWsRpcLayer = (
13401364 }
13411365
13421366 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 }
13431393 yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running");
13441394 let checkoutTotal: number | null = null;
13451395 const worktree = yield* gitWorkflow.createWorktree(
......@@ -1483,7 +1533,19 @@ const makeWsRpcLayer = (
14831533 Effect.logWarning("bootstrap thread cleanup failed", {
14841534 threadId,
14851535 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 ),
14871549 onSuccess: (threadDeleted) =>
14881550 Effect.fail(
14891551 threadDeleted
apps/web/src/components/ChatView.tsx+60-9
......@@ -349,7 +349,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
349349import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
350350import { MessagesTimeline } from "./chat/MessagesTimeline";
351351import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
352import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
352import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic";
353353import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout";
354354import { ChatHeader } from "./chat/ChatHeader";
355355import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
......@@ -3493,14 +3493,47 @@ export default function ChatView(props: ChatViewProps) {
34933493 const worktreeSetupOwnerKey = draftId ?? routeThreadKey;
34943494 const worktreeSetupActive =
34953495 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]);
34963522 // The setup runs on the environment that received the dispatch, so both
34973523 // the subscription and cancel target that one even if the draft's machine
34983524 // picker changes underneath.
3525 const worktreeSetupTarget = useMemo(
3526 () =>
3527 worktreeSetupActive
3528 ? { environmentId: worktreeSetupRef.environmentId, threadId: worktreeSetupRef.threadId }
3529 : resumedWorktreeSetupRef,
3530 [resumedWorktreeSetupRef, worktreeSetupActive, worktreeSetupRef],
3531 );
34993532 const worktreeSetupQuery = useEnvironmentQuery(
3500 worktreeSetupActive
3533 worktreeSetupTarget
35013534 ? vcsEnvironment.worktreeSetup({
3502 environmentId: worktreeSetupRef.environmentId,
3503 input: { threadId: worktreeSetupRef.threadId },
3535 environmentId: worktreeSetupTarget.environmentId,
3536 input: { threadId: worktreeSetupTarget.threadId },
35043537 })
35053538 : null,
35063539 );
......@@ -3511,7 +3544,7 @@ export default function ChatView(props: ChatViewProps) {
35113544 if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup);
35123545 }, [latestWorktreeSetup]);
35133546 const worktreeSetup =
3514 worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId
3547 worktreeSetupTarget !== null && heldWorktreeSetup?.threadId === worktreeSetupTarget.threadId
35153548 ? heldWorktreeSetup
35163549 : null;
35173550 // A finished card is dropped once the agent's turn shows in the timeline:
......@@ -3537,16 +3570,32 @@ export default function ChatView(props: ChatViewProps) {
35373570 useEffect(() => {
35383571 if (worktreeSetupSettledKey) pendingWorktreeSetupByThreadKey.delete(worktreeSetupSettledKey);
35393572 }, [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;
35403589 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
35413590 reportFailure: false,
35423591 });
35433592 const onCancelWorktreeSetup = useCallback(() => {
3544 if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return;
3593 if (!worktreeSetup || !worktreeSetupTarget || worktreeSetup.phase !== "running") return;
35453594 void cancelWorktreeSetup({
3546 environmentId: worktreeSetupRef.environmentId,
3595 environmentId: worktreeSetupTarget.environmentId,
35473596 input: { threadId: worktreeSetup.threadId },
35483597 });
3549 }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]);
3598 }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupTarget]);
35503599 // The setup terminal belongs to the thread that was set up. A failed
35513600 // bootstrap deletes that thread and closes its terminals, so only offer the
35523601 // terminal while the setup thread is still the active one.
......@@ -9313,7 +9362,9 @@ export default function ChatView(props: ChatViewProps) {
93139362 ? "Sending feedback"
93149363 : threadDetailLoading
93159364 ? "Messages loading"
9316 : projectCloneSendBlockReason
9365 : worktreeSetupBlocksSend
9366 ? "Preparing worktree"
9367 : projectCloneSendBlockReason
93179368 }
93189369 isPreparingWorktree={isPreparingWorktree}
93199370 bannerItems={composerBannerItems}
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-1
......@@ -1336,7 +1336,7 @@ export function deriveMessagesTimelineRows(input: {
13361336export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
13371337
13381338/** 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 {
13401340 return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done");
13411341}
13421342