diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a3d7a6f4e0c6a9eb3084c67c9136c9cb2f51d4c6..e4f489115f3ad53eba6af473eb9ef732244315fc 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -173,9 +173,15 @@ export interface ThreadDetailScreenProps { * The server has not created this thread yet. "preparing" runs while the * queued creation is delivered (a worktree may be checking out); "failed" * is a rejected creation whose content went back to the project draft. + * `serverOwned` marks a thread the server already created that is still + * setting up its worktree; follow-ups queue on the server behind that setup. */ readonly creationState: - | { readonly kind: "preparing"; readonly preparingWorktree: boolean } + | { + readonly kind: "preparing"; + readonly preparingWorktree: boolean; + readonly serverOwned: boolean; + } | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void } | null; readonly activePendingApproval: PendingApproval | null; @@ -1439,11 +1445,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread canStopThread={props.canStopThread} environmentId={props.environmentId} projectCwd={props.threadCwd ?? props.projectWorkspaceRoot} - // Follow-ups typed during setup wait in the draft: queueing - // them against a thread id the server may still reject - // would strand them in the outbox. + // Follow-ups typed before the server creates the thread wait in + // the draft: queueing them against a thread id the server may + // still reject would strand them in the outbox. sendBlockedReason={ - props.creationState?.kind === "preparing" ? "Starting the task…" : null + props.creationState?.kind === "preparing" && + !props.creationState.serverOwned + ? "Starting the task…" + : null } draftKey={props.composerDraftKey ?? undefined} followUpBehavior={props.followUpBehavior} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 8a579fc9a2f64d2eff2f88405a61fb40f8233050..9f27cab56e280166b961cdf459c68205a086cb01 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -889,7 +889,9 @@ function ThreadRouteContent( ]); const creationState = ((): ThreadDetailScreenProps["creationState"] => { if (selectedThreadCreation === null) { - return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null; + return awaitingBootstrapTurn + ? { kind: "preparing", preparingWorktree: true, serverOwned: true } + : null; } if (selectedThreadCreation.outcome?.kind === "failed") { return { @@ -901,6 +903,7 @@ function ThreadRouteContent( return { kind: "preparing", preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree", + serverOwned: false, }; })(); if (!environmentId || !threadId) { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 12a9999c8a0333681c25870bbdbdf9f854c3d548..172f647cff0faccea30bac760f2b8cc5fc66a603 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -677,8 +677,20 @@ export function useThreadComposerState() { alternateModifier: followUpOverride !== undefined && followUpOverride !== followUpBehavior, activeTurnDefault: followUpBehavior, }); - const followUpDispatchMode = - followUpAction === "auto" ? null : followUpAction === "queue" ? "queue" : "auto"; + // A send while the worktree is still being set up waits behind that setup, + // so the server can hold it if the setup ends without a worktree. + // Before detail loads, the shell's latest run can be a queued follow-up + // while its runtime still reports the setup. + const setupRunning = [selectedThreadActivityRun?.status, selectedThreadRuntime?.status].some( + (status) => status === "preparing" || status === "starting", + ); + const followUpDispatchMode = setupRunning + ? "queue" + : followUpAction === "auto" + ? null + : followUpAction === "queue" + ? "queue" + : "auto"; const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); @@ -735,7 +747,9 @@ export function useThreadComposerState() { saveQueuedRunEdit, selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, + selectedThreadActivityRun?.status, selectedThreadCreation, + selectedThreadRuntime?.status, selectedThreadShell, uploadThreadFeedback, ], diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 908fe09467748fdf06162163b6fac7b37697ea06..e3f5f72665b3564e6a9178743016f55ac7f912ca 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1269,7 +1269,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); }); - const startNextQueuedRun = (threadId: ThreadId, options?: { readonly failedRunId?: RunId }) => + const startNextQueuedRun = ( + threadId: ThreadId, + options?: { readonly failedRunId?: RunId; readonly endedRunId?: RunId }, + ) => Effect.gen(function* () { // Every terminal run checks the queue. Only a deliverable queued run // needs the transcript for provider handoff and legacy import context. @@ -1304,15 +1307,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // Hold the queue so the user decides when to resume it. Validation // failures (setup, unsupported handoff) belong to that message alone, // and a message queued for another provider is how users recover. + // A message queued behind a worktree that was cancelled or never created + // would run in the project checkout instead, so it waits for the user. const failedRun = latestExecutedRun(projection.runs); const failureClass = failedRun?.id === options?.failedRunId ? latestRootProviderFailure(failedRun, projection.turnItems)?.class : undefined; + const worktreeMissing = + failedRun?.id === options?.endedRunId && + failedRun?.workspacePreparation?.type === "worktree" && + projection.thread.worktreePath === null; if ( - failureClass !== undefined && - failureClass !== "validation_error" && - failedRun?.providerInstanceId === queuedRun.providerInstanceId + worktreeMissing || + (failureClass !== undefined && + failureClass !== "validation_error" && + failedRun?.providerInstanceId === queuedRun.providerInstanceId) ) { const now = yield* DateTime.now; yield* writeSystemEvents( @@ -4871,9 +4881,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (candidate) => candidate.id === projection.thread.activeProviderThreadId, ); const activeRun = projection.runs.find(isBlockingRun); + // A message queued during worktree setup can arrive after that setup failed + // without a worktree. It waits, held, like the messages queued before the + // failure, instead of starting in the project checkout. + const latestRun = latestExecutedRun(projection.runs); + const worktreeMissingRun = + activeRun === undefined && + dispatchMode.type === "queue_after_active" && + (latestRun?.status === "failed" || latestRun?.status === "interrupted") && + latestRun.workspacePreparation?.type === "worktree" && + projection.thread.worktreePath === null + ? latestRun + : undefined; + const queueBehindRun = activeRun ?? worktreeMissingRun; const pendingMergeBackTransfers = pendingMergeBackTransfersForThread(projection); const shouldQueue = - activeRun !== undefined && + queueBehindRun !== undefined && (dispatchMode.type === "defer_start" || dispatchMode.type === "start_immediately" || dispatchMode.type === "queue_after_active"); @@ -4888,13 +4911,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const queueProviderThread = activeProviderThread ?? projection.providerThreads.find( - (candidate) => candidate.id === activeRun.providerThreadId, + (candidate) => candidate.id === queueBehindRun.providerThreadId, ); if (queueProviderThread === undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Active run ${activeRun.id} has no provider thread for queued dispatch.`, + cause: `Active run ${queueBehindRun.id} has no provider thread for queued dispatch.`, }); } const now = yield* DateTime.now; @@ -4948,7 +4971,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); const rootNodeId = idAllocator.derive.rootNode({ runId }); const checkpointScope = - activeRun.status === "preparing" + queueBehindRun.status === "preparing" || worktreeMissingRun !== undefined ? null : yield* runtimePolicy.resolve({ thread: projection.thread, modelSelection }).pipe( Effect.flatMap((resolvedRuntimePolicy) => @@ -4985,7 +5008,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio rootNodeId, activeAttemptId: attemptId, status: "queued", - ...(projection.runs.some( + ...(worktreeMissingRun !== undefined || + projection.runs.some( (candidate) => candidate.status === "queued" && candidate.queueHeld === true, ) ? { queueHeld: true } @@ -8292,6 +8316,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "run.updated", payload: { ...state.run, status: "preparing", completedAt: null }, }); + // Messages held when this preparation failed follow the run again. A hold + // from a later run (a stop, say) is not this failure's to release. + const failureOwnsQueue = latestExecutedRun(projection.runs)?.id === state.run.id; + for (const run of projection.runs) { + if (!failureOwnsQueue || run.status !== "queued" || run.queueHeld !== true) continue; + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queueHeld: false }, + }); + } }); /** @@ -11056,8 +11094,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId, startNextQueuedRun( threadId, - stored.event.type === "run.updated" && stored.event.payload.status === "failed" - ? { failedRunId: stored.event.payload.id } + stored.event.type === "run.updated" + ? { + endedRunId: stored.event.payload.id, + ...(stored.event.payload.status === "failed" + ? { failedRunId: stored.event.payload.id } + : {}), + } : undefined, ), ) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 90248b0e1f6b85bed7921283bac2729cf1b491af..f7eede36b8e415aa6a149cd1daca400f911608e7 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1424,6 +1424,162 @@ it.effect("retries a failed workspace preparation on the same run", () => { }).pipe(Effect.provide(harness.layer)); }); +it.effect.each(["fails", "is interrupted"] as const)( + "holds a message queued during setup when the workspace preparation %s", + (ending) => + Effect.gen(function* () { + const fetchEntered = yield* Deferred.make(); + const allowFetch = yield* Deferred.make(); + let fetchFailures = 1; + const harness = makeHarness({ + fetchRemote: () => + fetchFailures-- <= 0 + ? Effect.void + : Deferred.succeed(fetchEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowFetch)), + Effect.andThen( + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not reach the remote.", + exitCode: 128, + }), + ), + ), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: `command:launch:queued-setup-${ending}`, + thread: `thread:launch:queued-setup-${ending}`, + message: "First message", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, + }), + ); + yield* Deferred.await(fetchEntered); + const queued = yield* threads.sendToThread({ + projectId, + commandId: CommandId.make(`command:launch:queued-setup-${ending}:follow-up`), + threadId: launched.threadId, + messageId: MessageId.make(`message:launch:queued-setup-${ending}:follow-up`), + text: "Sent during setup", + attachments: [], + mode: "queue", + createdBy: "user", + creationSource: "web", + }); + assert.equal(queued.delivery, "queued"); + if (ending === "fails") { + yield* Deferred.succeed(allowFetch, undefined); + } else { + // An agent stop does not ask to hold the queue. + yield* threads.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:launch:queued-setup-interrupt"), + threadId: launched.threadId, + runId: launched.projection.runs[0]!.id, + }); + } + + // Without its worktree the follow-up would start in the project checkout. + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.queueHeld === true, + ), + Stream.runHead, + ); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.runs.at(-1)?.status, "queued"); + assert.equal(projection.thread.worktreePath, null); + + if (ending === "fails") { + // A successful retry lets the held message follow the first turn again. + yield* launches.retryPreparation({ + commandId: CommandId.make("command:launch:queued-setup-retry"), + threadId: launched.threadId, + runId: launched.projection.runs[0]!.id, + }); + const retried = yield* threads.getThreadProjection(launched.threadId); + assert.equal(retried.runs.at(-1)?.queueHeld, false); + } + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect.each(["failed", "interrupted"] as const)( + "holds a message queued during setup that arrives after the setup %s", + (ending) => { + let fetchEntered: Deferred.Deferred | null = null; + const harness = makeHarness({ + fetchRemote: () => + ending === "interrupted" + ? Deferred.succeed(fetchEntered!, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git", + cwd: project.workspaceRoot, + detail: "Git could not reach the remote.", + exitCode: 128, + }), + ), + }); + return Effect.gen(function* () { + fetchEntered = yield* Deferred.make(); + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ + command: `command:launch:late-queued-follow-up-${ending}`, + thread: `thread:launch:late-queued-follow-up-${ending}`, + message: "First message", + workspace: { type: "worktree", baseRef: "main", startFromOrigin: true }, + }), + ); + if (ending === "interrupted") { + yield* Deferred.await(fetchEntered); + // An agent stop does not ask to hold the queue. + yield* threads.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:launch:late-queued-follow-up:interrupt"), + threadId: launched.threadId, + runId: launched.projection.runs[0]!.id, + }); + } + yield* threads.streamStoredEventsFrom({ threadId: launched.threadId }).pipe( + Stream.filter( + (stored) => stored.event.type === "run.updated" && stored.event.payload.status === ending, + ), + Stream.runHead, + ); + + // Queued while setup ran, delivered only after it failed: it must not start + // in the project checkout. + const queued = yield* threads.sendToThread({ + projectId, + commandId: CommandId.make(`command:launch:late-queued-follow-up-${ending}:send`), + threadId: launched.threadId, + messageId: MessageId.make(`message:launch:late-queued-follow-up-${ending}:send`), + text: "Sent during setup", + attachments: [], + mode: "queue", + createdBy: "user", + creationSource: "web", + }); + assert.equal(queued.delivery, "queued"); + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.runs.at(-1)?.status, "queued"); + assert.equal(projection.runs.at(-1)?.queueHeld, true); + }).pipe(Effect.provide(harness.layer)); + }, +); + it.effect("a retry reuses a recorded worktree without undoing its branch rename", () => { let setupFailures = 1; const harness = makeHarness({ diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 7af46f2d47a2cb17aa1e35ff3a496d30e0315c63..c3f7a9553edef44c332c510e1fe071e7d18a74a9 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -122,6 +122,10 @@ export function BranchToolbarBranchSelector({ threadEnvironment.updateMetadata, "thread metadata update", ); + // An automatic default loses to any launch that lands first; that rejection is expected. + const updateEmptyThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false, }); @@ -188,12 +192,14 @@ export function BranchToolbarBranchSelector({ }); } if (hasServerThread) { - void updateThreadMetadata({ + void (automatic ? updateEmptyThreadMetadata : updateThreadMetadata)({ environmentId, input: { threadId: activeThreadId, branch, worktreePath, + // A launched thread records its own worktree; a late default must not reset it. + ...(automatic ? { expectedEmpty: true } : {}), }, }); } @@ -228,6 +234,7 @@ export function BranchToolbarBranchSelector({ effectiveEnvMode, draftThread?.environmentSelection, stopThreadSession, + updateEmptyThreadMetadata, updateThreadMetadata, ], ); @@ -538,8 +545,13 @@ export function BranchToolbarBranchSelector({ ? null : (defaultBranchName ?? currentGitBranch); + // A started thread's server is creating its worktree and records the branch + // itself; defaulting it here would reset the thread to the base checkout and + // detach the agent that just started. + const threadStarted = serverSession !== null; useEffect(() => { if ( + threadStarted || effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || @@ -553,6 +565,7 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, effectiveEnvMode, setThreadBranch, + threadStarted, worktreeBaseBranchCandidate, ]); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 33dfe404e5327481615f0d54c706828e953ef58c..288769e79e5562f28a6b5d28f30bd55f21b2170c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3664,6 +3664,14 @@ export default function ChatView(props: ChatViewProps) { // Server-side workspace preparation: unlike the local-dispatch flag this // survives reloads and shows on remote viewers of the same thread. const activeRunPreparing = activeActivityRun?.status === "preparing"; + // The server queues a message behind a run that is still preparing its + // worktree or starting its agent, so a send during setup waits there and + // starts on its own instead of being refused. + const sendQueuesBehindSetup = + isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting"); + // Such a send skips the local dispatch, so this keeps its thread's composer busy while it is + // in flight. + const [setupQueueSendThreadKey, setSetupQueueSendThreadKey] = useState(null); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -4017,11 +4025,13 @@ export default function ChatView(props: ChatViewProps) { // Sends wait for the agent handoff, not for the setup script: an async // script keeps the snapshot running while the agent already works, and a // follow-up must not be held behind a slow install. Before the first - // snapshot arrives the starting session stands in for it. + // snapshot arrives the starting session stands in for it. Once the server + // has the thread, a send queues behind the setup run instead. const worktreeSetupBlocksSend = - worktreeSetup !== null + !sendQueuesBehindSetup && + (worktreeSetup !== null ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup) - : isPreparingWorktree; + : isPreparingWorktree); const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, { reportFailure: false, }); @@ -8808,7 +8818,7 @@ export default function ChatView(props: ChatViewProps) { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || !activeThread || - isSendBusy || + (isSendBusy && !sendQueuesBehindSetup) || isConnecting || isRevertingCheckpoint || !clientSettingsHydrated || @@ -9252,7 +9262,7 @@ export default function ChatView(props: ChatViewProps) { return; } const threadIdForSend = activeThread.id; - const isFirstMessage = !isServerThread || activeMessageCount === 0; + const isFirstMessage = !sendQueuesBehindSetup && (!isServerThread || activeMessageCount === 0); const baseBranchForWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath ? activeThreadBranch @@ -9309,9 +9319,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionTokens !== null && !keepFullHistory && messageTextForSend.toLowerCase() !== "/compact"; - const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode; + const turnDispatchMode = compactBeforeSend || sendQueuesBehindSetup ? "queue" : dispatchMode; const shouldQueueBehindActiveRun = - compactBeforeSend || (phase === "running" && dispatchMode === "queue"); + compactBeforeSend || + sendQueuesBehindSetup || + (phase === "running" && dispatchMode === "queue"); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -9460,21 +9472,28 @@ export default function ChatView(props: ChatViewProps) { ); return; } - beginLocalDispatch({ - preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), - // Only a draft has a background submission to hide behind its hero. - submissionIntent: - submissionIntent === "background" && !isLocalDraftThread ? "foreground" : submissionIntent, - }); - setWorktreeSetupRef( - multipleModelSelections === null && baseBranchForWorktree - ? { - environmentId: activeThread.environmentId, - threadId: threadIdForSend, - ownerKey: worktreeSetupOwnerKey, - } - : null, - ); + // A send queued behind setup leaves the first send's dispatch and setup card alone. + if (sendQueuesBehindSetup) { + setSetupQueueSendThreadKey(activeThreadKey); + } else { + beginLocalDispatch({ + preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), + // Only a draft has a background submission to hide behind its hero. + submissionIntent: + submissionIntent === "background" && !isLocalDraftThread + ? "foreground" + : submissionIntent, + }); + setWorktreeSetupRef( + multipleModelSelections === null && baseBranchForWorktree + ? { + environmentId: activeThread.environmentId, + threadId: threadIdForSend, + ownerKey: worktreeSetupOwnerKey, + } + : null, + ); + } const turnAttachmentsPromise = Promise.all( composerAttachmentsSnapshot.map(async (attachment) => { @@ -10141,11 +10160,12 @@ export default function ChatView(props: ChatViewProps) { } } sendInFlightRef.current = false; + setSetupQueueSendThreadKey((current) => (current === activeThreadKey ? null : current)); if (!turnStartSucceeded) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); - resetLocalDispatch(); + if (!sendQueuesBehindSetup) resetLocalDispatch(); } }; @@ -11652,7 +11672,13 @@ export default function ChatView(props: ChatViewProps) { phase={phase} canInterrupt={canInterruptRunningThread} isConnecting={isConnecting} - isSendBusy={isSendBusy || isSavingQueuedEdit || isResuming} + isSendBusy={ + (isSendBusy && !sendQueuesBehindSetup) || + (setupQueueSendThreadKey !== null && + setupQueueSendThreadKey === activeThreadKey) || + isSavingQueuedEdit || + isResuming + } canResume={resumableRunId !== null || hasHeldQueuedRuns} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ @@ -11670,7 +11696,7 @@ export default function ChatView(props: ChatViewProps) { ? "Preparing worktree" : projectCloneSendBlockReason } - isPreparingWorktree={isPreparingWorktree} + isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} queuedRunsControl={ isServerThread && activeThread ? (