authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-10 21:12:26-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 21:12:26-03:00
log477282263a9115f911380b5aec7d4973267e4edc
tree3fd42280314dc853955e9eba9aeedd3adbea1f73
parent5f7294d452a5c6d6e0b7bd2c992478f830fbe212
signature Signed by PGP key B5690EEEBB952194

fix: worktree threads keep their worktree when the agent starts, and messages sent during setup queue (#17654)


8 files changed, 312 insertions(+), 45 deletions(-)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx+14-5
......@@ -173,9 +173,15 @@ export interface ThreadDetailScreenProps {
173173 * The server has not created this thread yet. "preparing" runs while the
174174 * queued creation is delivered (a worktree may be checking out); "failed"
175175 * is a rejected creation whose content went back to the project draft.
176 * `serverOwned` marks a thread the server already created that is still
177 * setting up its worktree; follow-ups queue on the server behind that setup.
176178 */
177179 readonly creationState:
178 | { readonly kind: "preparing"; readonly preparingWorktree: boolean }
180 | {
181 readonly kind: "preparing";
182 readonly preparingWorktree: boolean;
183 readonly serverOwned: boolean;
184 }
179185 | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void }
180186 | null;
181187 readonly activePendingApproval: PendingApproval | null;
......@@ -1439,11 +1445,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
14391445 canStopThread={props.canStopThread}
14401446 environmentId={props.environmentId}
14411447 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1442 // Follow-ups typed during setup wait in the draft: queueing
1443 // them against a thread id the server may still reject
1444 // would strand them in the outbox.
1448 // Follow-ups typed before the server creates the thread wait in
1449 // the draft: queueing them against a thread id the server may
1450 // still reject would strand them in the outbox.
14451451 sendBlockedReason={
1446 props.creationState?.kind === "preparing" ? "Starting the task…" : null
1452 props.creationState?.kind === "preparing" &&
1453 !props.creationState.serverOwned
1454 ? "Starting the task…"
1455 : null
14471456 }
14481457 draftKey={props.composerDraftKey ?? undefined}
14491458 followUpBehavior={props.followUpBehavior}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+4-1
......@@ -889,7 +889,9 @@ function ThreadRouteContent(
889889 ]);
890890 const creationState = ((): ThreadDetailScreenProps["creationState"] => {
891891 if (selectedThreadCreation === null) {
892 return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null;
892 return awaitingBootstrapTurn
893 ? { kind: "preparing", preparingWorktree: true, serverOwned: true }
894 : null;
893895 }
894896 if (selectedThreadCreation.outcome?.kind === "failed") {
895897 return {
......@@ -901,6 +903,7 @@ function ThreadRouteContent(
901903 return {
902904 kind: "preparing",
903905 preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree",
906 serverOwned: false,
904907 };
905908 })();
906909 if (!environmentId || !threadId) {
apps/mobile/src/state/use-thread-composer-state.ts+16-2
......@@ -677,8 +677,20 @@ export function useThreadComposerState() {
677677 alternateModifier: followUpOverride !== undefined && followUpOverride !== followUpBehavior,
678678 activeTurnDefault: followUpBehavior,
679679 });
680 const followUpDispatchMode =
681 followUpAction === "auto" ? null : followUpAction === "queue" ? "queue" : "auto";
680 // A send while the worktree is still being set up waits behind that setup,
681 // so the server can hold it if the setup ends without a worktree.
682 // Before detail loads, the shell's latest run can be a queued follow-up
683 // while its runtime still reports the setup.
684 const setupRunning = [selectedThreadActivityRun?.status, selectedThreadRuntime?.status].some(
685 (status) => status === "preparing" || status === "starting",
686 );
687 const followUpDispatchMode = setupRunning
688 ? "queue"
689 : followUpAction === "auto"
690 ? null
691 : followUpAction === "queue"
692 ? "queue"
693 : "auto";
682694
683695 const metadata = makeQueuedMessageMetadata();
684696 const messageId = MessageId.make(metadata.messageId);
......@@ -735,7 +747,9 @@ export function useThreadComposerState() {
735747 saveQueuedRunEdit,
736748 selectedEnvironmentRuntime?.connectionState,
737749 selectedEnvironmentRuntime?.serverConfig,
750 selectedThreadActivityRun?.status,
738751 selectedThreadCreation,
752 selectedThreadRuntime?.status,
739753 selectedThreadShell,
740754 uploadThreadFeedback,
741755 ],
apps/server/src/orchestration-v2/Orchestrator.ts+54-11
......@@ -1269,7 +1269,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
12691269 ]);
12701270 });
12711271
1272 const startNextQueuedRun = (threadId: ThreadId, options?: { readonly failedRunId?: RunId }) =>
1272 const startNextQueuedRun = (
1273 threadId: ThreadId,
1274 options?: { readonly failedRunId?: RunId; readonly endedRunId?: RunId },
1275 ) =>
12731276 Effect.gen(function* () {
12741277 // Every terminal run checks the queue. Only a deliverable queued run
12751278 // needs the transcript for provider handoff and legacy import context.
......@@ -1304,15 +1307,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
13041307 // Hold the queue so the user decides when to resume it. Validation
13051308 // failures (setup, unsupported handoff) belong to that message alone,
13061309 // and a message queued for another provider is how users recover.
1310 // A message queued behind a worktree that was cancelled or never created
1311 // would run in the project checkout instead, so it waits for the user.
13071312 const failedRun = latestExecutedRun(projection.runs);
13081313 const failureClass =
13091314 failedRun?.id === options?.failedRunId
13101315 ? latestRootProviderFailure(failedRun, projection.turnItems)?.class
13111316 : undefined;
1317 const worktreeMissing =
1318 failedRun?.id === options?.endedRunId &&
1319 failedRun?.workspacePreparation?.type === "worktree" &&
1320 projection.thread.worktreePath === null;
13121321 if (
1313 failureClass !== undefined &&
1314 failureClass !== "validation_error" &&
1315 failedRun?.providerInstanceId === queuedRun.providerInstanceId
1322 worktreeMissing ||
1323 (failureClass !== undefined &&
1324 failureClass !== "validation_error" &&
1325 failedRun?.providerInstanceId === queuedRun.providerInstanceId)
13161326 ) {
13171327 const now = yield* DateTime.now;
13181328 yield* writeSystemEvents(
......@@ -4871,9 +4881,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
48714881 (candidate) => candidate.id === projection.thread.activeProviderThreadId,
48724882 );
48734883 const activeRun = projection.runs.find(isBlockingRun);
4884 // A message queued during worktree setup can arrive after that setup failed
4885 // without a worktree. It waits, held, like the messages queued before the
4886 // failure, instead of starting in the project checkout.
4887 const latestRun = latestExecutedRun(projection.runs);
4888 const worktreeMissingRun =
4889 activeRun === undefined &&
4890 dispatchMode.type === "queue_after_active" &&
4891 (latestRun?.status === "failed" || latestRun?.status === "interrupted") &&
4892 latestRun.workspacePreparation?.type === "worktree" &&
4893 projection.thread.worktreePath === null
4894 ? latestRun
4895 : undefined;
4896 const queueBehindRun = activeRun ?? worktreeMissingRun;
48744897 const pendingMergeBackTransfers = pendingMergeBackTransfersForThread(projection);
48754898 const shouldQueue =
4876 activeRun !== undefined &&
4899 queueBehindRun !== undefined &&
48774900 (dispatchMode.type === "defer_start" ||
48784901 dispatchMode.type === "start_immediately" ||
48794902 dispatchMode.type === "queue_after_active");
......@@ -4888,13 +4911,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
48884911 const queueProviderThread =
48894912 activeProviderThread ??
48904913 projection.providerThreads.find(
4891 (candidate) => candidate.id === activeRun.providerThreadId,
4914 (candidate) => candidate.id === queueBehindRun.providerThreadId,
48924915 );
48934916 if (queueProviderThread === undefined) {
48944917 return yield* new OrchestratorDispatchError({
48954918 commandId: command.commandId,
48964919 commandType: command.type,
4897 cause: `Active run ${activeRun.id} has no provider thread for queued dispatch.`,
4920 cause: `Active run ${queueBehindRun.id} has no provider thread for queued dispatch.`,
48984921 });
48994922 }
49004923 const now = yield* DateTime.now;
......@@ -4948,7 +4971,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
49484971 const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 });
49494972 const rootNodeId = idAllocator.derive.rootNode({ runId });
49504973 const checkpointScope =
4951 activeRun.status === "preparing"
4974 queueBehindRun.status === "preparing" || worktreeMissingRun !== undefined
49524975 ? null
49534976 : yield* runtimePolicy.resolve({ thread: projection.thread, modelSelection }).pipe(
49544977 Effect.flatMap((resolvedRuntimePolicy) =>
......@@ -4985,7 +5008,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
49855008 rootNodeId,
49865009 activeAttemptId: attemptId,
49875010 status: "queued",
4988 ...(projection.runs.some(
5011 ...(worktreeMissingRun !== undefined ||
5012 projection.runs.some(
49895013 (candidate) => candidate.status === "queued" && candidate.queueHeld === true,
49905014 )
49915015 ? { queueHeld: true }
......@@ -8292,6 +8316,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
82928316 type: "run.updated",
82938317 payload: { ...state.run, status: "preparing", completedAt: null },
82948318 });
8319 // Messages held when this preparation failed follow the run again. A hold
8320 // from a later run (a stop, say) is not this failure's to release.
8321 const failureOwnsQueue = latestExecutedRun(projection.runs)?.id === state.run.id;
8322 for (const run of projection.runs) {
8323 if (!failureOwnsQueue || run.status !== "queued" || run.queueHeld !== true) continue;
8324 yield* emitEvent({
8325 type: "run.updated",
8326 threadId: command.threadId,
8327 runId: run.id,
8328 providerInstanceId: run.providerInstanceId,
8329 occurredAt: now,
8330 payload: { ...run, queueHeld: false },
8331 });
8332 }
82958333 });
82968334
82978335 /**
......@@ -11056,8 +11094,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1105611094 threadId,
1105711095 startNextQueuedRun(
1105811096 threadId,
11059 stored.event.type === "run.updated" && stored.event.payload.status === "failed"
11060 ? { failedRunId: stored.event.payload.id }
11097 stored.event.type === "run.updated"
11098 ? {
11099 endedRunId: stored.event.payload.id,
11100 ...(stored.event.payload.status === "failed"
11101 ? { failedRunId: stored.event.payload.id }
11102 : {}),
11103 }
1106111104 : undefined,
1106211105 ),
1106311106 )
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+156
......@@ -1424,6 +1424,162 @@ it.effect("retries a failed workspace preparation on the same run", () => {
14241424 }).pipe(Effect.provide(harness.layer));
14251425});
14261426
1427it.effect.each(["fails", "is interrupted"] as const)(
1428 "holds a message queued during setup when the workspace preparation %s",
1429 (ending) =>
1430 Effect.gen(function* () {
1431 const fetchEntered = yield* Deferred.make<void>();
1432 const allowFetch = yield* Deferred.make<void>();
1433 let fetchFailures = 1;
1434 const harness = makeHarness({
1435 fetchRemote: () =>
1436 fetchFailures-- <= 0
1437 ? Effect.void
1438 : Deferred.succeed(fetchEntered, undefined).pipe(
1439 Effect.andThen(Deferred.await(allowFetch)),
1440 Effect.andThen(
1441 Effect.fail(
1442 new GitCommandError({
1443 operation: "GitVcsDriver.fetchRemote",
1444 command: "git",
1445 cwd: project.workspaceRoot,
1446 detail: "Git could not reach the remote.",
1447 exitCode: 128,
1448 }),
1449 ),
1450 ),
1451 ),
1452 });
1453 yield* Effect.gen(function* () {
1454 const launches = yield* ThreadLaunch.ThreadLaunchService;
1455 const threads = yield* ThreadManagement.ThreadManagementService;
1456 const launched = yield* launches.launch(
1457 launchInput({
1458 command: `command:launch:queued-setup-${ending}`,
1459 thread: `thread:launch:queued-setup-${ending}`,
1460 message: "First message",
1461 workspace: { type: "worktree", baseRef: "main", startFromOrigin: true },
1462 }),
1463 );
1464 yield* Deferred.await(fetchEntered);
1465 const queued = yield* threads.sendToThread({
1466 projectId,
1467 commandId: CommandId.make(`command:launch:queued-setup-${ending}:follow-up`),
1468 threadId: launched.threadId,
1469 messageId: MessageId.make(`message:launch:queued-setup-${ending}:follow-up`),
1470 text: "Sent during setup",
1471 attachments: [],
1472 mode: "queue",
1473 createdBy: "user",
1474 creationSource: "web",
1475 });
1476 assert.equal(queued.delivery, "queued");
1477 if (ending === "fails") {
1478 yield* Deferred.succeed(allowFetch, undefined);
1479 } else {
1480 // An agent stop does not ask to hold the queue.
1481 yield* threads.dispatch({
1482 type: "run.interrupt",
1483 commandId: CommandId.make("command:launch:queued-setup-interrupt"),
1484 threadId: launched.threadId,
1485 runId: launched.projection.runs[0]!.id,
1486 });
1487 }
1488
1489 // Without its worktree the follow-up would start in the project checkout.
1490 yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe(
1491 Stream.filter(
1492 (stored) =>
1493 stored.event.type === "run.updated" && stored.event.payload.queueHeld === true,
1494 ),
1495 Stream.runHead,
1496 );
1497 const projection = yield* threads.getThreadProjection(launched.threadId);
1498 assert.equal(projection.runs.at(-1)?.status, "queued");
1499 assert.equal(projection.thread.worktreePath, null);
1500
1501 if (ending === "fails") {
1502 // A successful retry lets the held message follow the first turn again.
1503 yield* launches.retryPreparation({
1504 commandId: CommandId.make("command:launch:queued-setup-retry"),
1505 threadId: launched.threadId,
1506 runId: launched.projection.runs[0]!.id,
1507 });
1508 const retried = yield* threads.getThreadProjection(launched.threadId);
1509 assert.equal(retried.runs.at(-1)?.queueHeld, false);
1510 }
1511 }).pipe(Effect.provide(harness.layer));
1512 }),
1513);
1514
1515it.effect.each(["failed", "interrupted"] as const)(
1516 "holds a message queued during setup that arrives after the setup %s",
1517 (ending) => {
1518 let fetchEntered: Deferred.Deferred<void> | null = null;
1519 const harness = makeHarness({
1520 fetchRemote: () =>
1521 ending === "interrupted"
1522 ? Deferred.succeed(fetchEntered!, undefined).pipe(Effect.andThen(Effect.never))
1523 : Effect.fail(
1524 new GitCommandError({
1525 operation: "GitVcsDriver.fetchRemote",
1526 command: "git",
1527 cwd: project.workspaceRoot,
1528 detail: "Git could not reach the remote.",
1529 exitCode: 128,
1530 }),
1531 ),
1532 });
1533 return Effect.gen(function* () {
1534 fetchEntered = yield* Deferred.make<void>();
1535 const launches = yield* ThreadLaunch.ThreadLaunchService;
1536 const threads = yield* ThreadManagement.ThreadManagementService;
1537 const launched = yield* launches.launch(
1538 launchInput({
1539 command: `command:launch:late-queued-follow-up-${ending}`,
1540 thread: `thread:launch:late-queued-follow-up-${ending}`,
1541 message: "First message",
1542 workspace: { type: "worktree", baseRef: "main", startFromOrigin: true },
1543 }),
1544 );
1545 if (ending === "interrupted") {
1546 yield* Deferred.await(fetchEntered);
1547 // An agent stop does not ask to hold the queue.
1548 yield* threads.dispatch({
1549 type: "run.interrupt",
1550 commandId: CommandId.make("command:launch:late-queued-follow-up:interrupt"),
1551 threadId: launched.threadId,
1552 runId: launched.projection.runs[0]!.id,
1553 });
1554 }
1555 yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe(
1556 Stream.filter(
1557 (stored) => stored.event.type === "run.updated" && stored.event.payload.status === ending,
1558 ),
1559 Stream.runHead,
1560 );
1561
1562 // Queued while setup ran, delivered only after it failed: it must not start
1563 // in the project checkout.
1564 const queued = yield* threads.sendToThread({
1565 projectId,
1566 commandId: CommandId.make(`command:launch:late-queued-follow-up-${ending}:send`),
1567 threadId: launched.threadId,
1568 messageId: MessageId.make(`message:launch:late-queued-follow-up-${ending}:send`),
1569 text: "Sent during setup",
1570 attachments: [],
1571 mode: "queue",
1572 createdBy: "user",
1573 creationSource: "web",
1574 });
1575 assert.equal(queued.delivery, "queued");
1576 const projection = yield* threads.getThreadProjection(launched.threadId);
1577 assert.equal(projection.runs.at(-1)?.status, "queued");
1578 assert.equal(projection.runs.at(-1)?.queueHeld, true);
1579 }).pipe(Effect.provide(harness.layer));
1580 },
1581);
1582
14271583it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => {
14281584 let setupFailures = 1;
14291585 const harness = makeHarness({
apps/web/src/components/BranchToolbarBranchSelector.tsx+14-1
......@@ -122,6 +122,10 @@ export function BranchToolbarBranchSelector({
122122 threadEnvironment.updateMetadata,
123123 "thread metadata update",
124124 );
125 // An automatic default loses to any launch that lands first; that rejection is expected.
126 const updateEmptyThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
127 reportFailure: false,
128 });
125129 const switchRef = useAtomCommand(vcsEnvironment.switchRef, {
126130 reportFailure: false,
127131 });
......@@ -188,12 +192,14 @@ export function BranchToolbarBranchSelector({
188192 });
189193 }
190194 if (hasServerThread) {
191 void updateThreadMetadata({
195 void (automatic ? updateEmptyThreadMetadata : updateThreadMetadata)({
192196 environmentId,
193197 input: {
194198 threadId: activeThreadId,
195199 branch,
196200 worktreePath,
201 // A launched thread records its own worktree; a late default must not reset it.
202 ...(automatic ? { expectedEmpty: true } : {}),
197203 },
198204 });
199205 }
......@@ -228,6 +234,7 @@ export function BranchToolbarBranchSelector({
228234 effectiveEnvMode,
229235 draftThread?.environmentSelection,
230236 stopThreadSession,
237 updateEmptyThreadMetadata,
231238 updateThreadMetadata,
232239 ],
233240 );
......@@ -538,8 +545,13 @@ export function BranchToolbarBranchSelector({
538545 ? null
539546 : (defaultBranchName ?? currentGitBranch);
540547
548 // A started thread's server is creating its worktree and records the branch
549 // itself; defaulting it here would reset the thread to the base checkout and
550 // detach the agent that just started.
551 const threadStarted = serverSession !== null;
541552 useEffect(() => {
542553 if (
554 threadStarted ||
543555 effectiveEnvMode !== "worktree" ||
544556 activeWorktreePath ||
545557 activeThreadBranch ||
......@@ -553,6 +565,7 @@ export function BranchToolbarBranchSelector({
553565 activeWorktreePath,
554566 effectiveEnvMode,
555567 setThreadBranch,
568 threadStarted,
556569 worktreeBaseBranchCandidate,
557570 ]);
558571
apps/web/src/components/ChatView.tsx+51-25
......@@ -3664,6 +3664,14 @@ export default function ChatView(props: ChatViewProps) {
36643664 // Server-side workspace preparation: unlike the local-dispatch flag this
36653665 // survives reloads and shows on remote viewers of the same thread.
36663666 const activeRunPreparing = activeActivityRun?.status === "preparing";
3667 // The server queues a message behind a run that is still preparing its
3668 // worktree or starting its agent, so a send during setup waits there and
3669 // starts on its own instead of being refused.
3670 const sendQueuesBehindSetup =
3671 isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting");
3672 // Such a send skips the local dispatch, so this keeps its thread's composer busy while it is
3673 // in flight.
3674 const [setupQueueSendThreadKey, setSetupQueueSendThreadKey] = useState<string | null>(null);
36673675 useEffect(() => {
36683676 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
36693677 }, [attachmentPreviewHandoffByMessageId]);
......@@ -4017,11 +4025,13 @@ export default function ChatView(props: ChatViewProps) {
40174025 // Sends wait for the agent handoff, not for the setup script: an async
40184026 // script keeps the snapshot running while the agent already works, and a
40194027 // follow-up must not be held behind a slow install. Before the first
4020 // snapshot arrives the starting session stands in for it.
4028 // snapshot arrives the starting session stands in for it. Once the server
4029 // has the thread, a send queues behind the setup run instead.
40214030 const worktreeSetupBlocksSend =
4022 worktreeSetup !== null
4031 !sendQueuesBehindSetup &&
4032 (worktreeSetup !== null
40234033 ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup)
4024 : isPreparingWorktree;
4034 : isPreparingWorktree);
40254035 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
40264036 reportFailure: false,
40274037 });
......@@ -8808,7 +8818,7 @@ export default function ChatView(props: ChatViewProps) {
88088818 if (
88098819 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
88108820 !activeThread ||
8811 isSendBusy ||
8821 (isSendBusy && !sendQueuesBehindSetup) ||
88128822 isConnecting ||
88138823 isRevertingCheckpoint ||
88148824 !clientSettingsHydrated ||
......@@ -9252,7 +9262,7 @@ export default function ChatView(props: ChatViewProps) {
92529262 return;
92539263 }
92549264 const threadIdForSend = activeThread.id;
9255 const isFirstMessage = !isServerThread || activeMessageCount === 0;
9265 const isFirstMessage = !sendQueuesBehindSetup && (!isServerThread || activeMessageCount === 0);
92569266 const baseBranchForWorktree =
92579267 isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath
92589268 ? activeThreadBranch
......@@ -9309,9 +9319,11 @@ export default function ChatView(props: ChatViewProps) {
93099319 resumeCompactionTokens !== null &&
93109320 !keepFullHistory &&
93119321 messageTextForSend.toLowerCase() !== "/compact";
9312 const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode;
9322 const turnDispatchMode = compactBeforeSend || sendQueuesBehindSetup ? "queue" : dispatchMode;
93139323 const shouldQueueBehindActiveRun =
9314 compactBeforeSend || (phase === "running" && dispatchMode === "queue");
9324 compactBeforeSend ||
9325 sendQueuesBehindSetup ||
9326 (phase === "running" && dispatchMode === "queue");
93159327 const outgoingMessageText = formatOutgoingPrompt({
93169328 provider: ctxSelectedProvider,
93179329 model: ctxSelectedModel,
......@@ -9460,21 +9472,28 @@ export default function ChatView(props: ChatViewProps) {
94609472 );
94619473 return;
94629474 }
9463 beginLocalDispatch({
9464 preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
9465 // Only a draft has a background submission to hide behind its hero.
9466 submissionIntent:
9467 submissionIntent === "background" && !isLocalDraftThread ? "foreground" : submissionIntent,
9468 });
9469 setWorktreeSetupRef(
9470 multipleModelSelections === null && baseBranchForWorktree
9471 ? {
9472 environmentId: activeThread.environmentId,
9473 threadId: threadIdForSend,
9474 ownerKey: worktreeSetupOwnerKey,
9475 }
9476 : null,
9477 );
9475 // A send queued behind setup leaves the first send's dispatch and setup card alone.
9476 if (sendQueuesBehindSetup) {
9477 setSetupQueueSendThreadKey(activeThreadKey);
9478 } else {
9479 beginLocalDispatch({
9480 preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
9481 // Only a draft has a background submission to hide behind its hero.
9482 submissionIntent:
9483 submissionIntent === "background" && !isLocalDraftThread
9484 ? "foreground"
9485 : submissionIntent,
9486 });
9487 setWorktreeSetupRef(
9488 multipleModelSelections === null && baseBranchForWorktree
9489 ? {
9490 environmentId: activeThread.environmentId,
9491 threadId: threadIdForSend,
9492 ownerKey: worktreeSetupOwnerKey,
9493 }
9494 : null,
9495 );
9496 }
94789497
94799498 const turnAttachmentsPromise = Promise.all(
94809499 composerAttachmentsSnapshot.map(async (attachment) => {
......@@ -10141,11 +10160,12 @@ export default function ChatView(props: ChatViewProps) {
1014110160 }
1014210161 }
1014310162 sendInFlightRef.current = false;
10163 setSetupQueueSendThreadKey((current) => (current === activeThreadKey ? null : current));
1014410164 if (!turnStartSucceeded) {
1014510165 setDockedDraftHeroThreadKey((currentThreadKey) =>
1014610166 currentThreadKey === activeThreadKey ? null : currentThreadKey,
1014710167 );
10148 resetLocalDispatch();
10168 if (!sendQueuesBehindSetup) resetLocalDispatch();
1014910169 }
1015010170 };
1015110171
......@@ -11652,7 +11672,13 @@ export default function ChatView(props: ChatViewProps) {
1165211672 phase={phase}
1165311673 canInterrupt={canInterruptRunningThread}
1165411674 isConnecting={isConnecting}
11655 isSendBusy={isSendBusy || isSavingQueuedEdit || isResuming}
11675 isSendBusy={
11676 (isSendBusy && !sendQueuesBehindSetup) ||
11677 (setupQueueSendThreadKey !== null &&
11678 setupQueueSendThreadKey === activeThreadKey) ||
11679 isSavingQueuedEdit ||
11680 isResuming
11681 }
1165611682 canResume={resumableRunId !== null || hasHeldQueuedRuns}
1165711683 isRevertingCheckpoint={isRevertingCheckpoint}
1165811684 sendDisabledReason={
......@@ -11670,7 +11696,7 @@ export default function ChatView(props: ChatViewProps) {
1167011696 ? "Preparing worktree"
1167111697 : projectCloneSendBlockReason
1167211698 }
11673 isPreparingWorktree={isPreparingWorktree}
11699 isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup}
1167411700 queuedRunsControl={
1167511701 isServerThread && activeThread ? (
1167611702 <QueuedRunsControl
packages/client-runtime/src/operations/commands.ts+3
......@@ -132,6 +132,8 @@ export interface UpdateThreadMetadataInput extends ThreadCommandInput {
132132 readonly regenerateTitle?: boolean;
133133 /** Link (object) or unlink (null) a pull request (#8160). */
134134 readonly linkedPullRequest?: ThreadLinkedPullRequest | null;
135 /** Apply only while no message or run has landed on the thread. */
136 readonly expectedEmpty?: boolean;
135137}
136138
137139export interface SetThreadRuntimeModeInput extends ThreadCommandInput {
......@@ -579,6 +581,7 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM
579581 ...(input.title === undefined ? {} : { title: input.title }),
580582 ...(input.branch === undefined ? {} : { branch: input.branch }),
581583 ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }),
584 ...(input.expectedEmpty === true ? { expectedEmpty: true } : {}),
582585 ...(input.regenerateTitle === undefined ? {} : { regenerateTitle: input.regenerateTitle }),
583586 ...(input.linkedPullRequest === undefined
584587 ? {}