From 1349ce10123cd45231af69720a2ac8f2875a25c9 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 09:13:09 -0600 Subject: [PATCH 1/4] fix(client): hide Resume while a pending request blocks continuing the run The server refuses a manual continuation while any runtime request in the thread is pending, but web and mobile still offered Resume, and its click failed with a generic error. A blocked continuation also must not fall back to releasing the held queue alone. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadRouteScreen.tsx | 4 +- .../src/state/use-thread-composer-state.ts | 5 ++ apps/web/src/components/ChatView.tsx | 7 +- .../chat/useComposerResume.browser.test.tsx | 1 + .../src/components/chat/useComposerResume.ts | 4 +- .../src/state/threadExecution.test.ts | 76 +++++++++++++++++++ .../src/state/threadExecution.ts | 14 ++++ 7 files changed, 106 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index a07aedfabf7ed..952c0b4c542c2 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1109,9 +1109,7 @@ function ThreadRouteContent( onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} - onResumeQueue={ - composer.queueHeld || composer.resumableRunId !== null ? handleResumeQueue : null - } + onResumeQueue={composer.canResume ? handleResumeQueue : null} onSendMessage={composer.onSendMessage} onReconnectEnvironment={handleReconnectEnvironment} canSwitchThreadProvider={composer.canSwitchThreadProvider} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 288d37d9f169e..a4f383dcc99a1 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -7,6 +7,7 @@ import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; import { + canResumeThread, deriveProviderSubagentStatus, deriveResumableRunId, deriveRunlessWorkStartedAt, @@ -365,6 +366,9 @@ export function useThreadComposerState() { const resumableRunId = selectedThreadProjection ? deriveResumableRunId(selectedThreadProjection.projection, selectedThreadRuntime) : null; + const canResume = selectedThreadProjection + ? canResumeThread(selectedThreadProjection.projection, resumableRunId, queueHeld) + : queueHeld; const runlessWorkStartedAt = selectedThreadProjection ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) @@ -754,6 +758,7 @@ export function useThreadComposerState() { canSteerActiveTurn, queueHeld, resumableRunId, + canResume, queuedRunEdit, isSavingQueuedEdit, cancelQueuedRunEdit, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55d951d1bdf80..ecf3f6078055e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -72,6 +72,7 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { + canResumeThread, deriveProviderSubagentStatus, deriveReportedModelSelection, deriveResumableRunId, @@ -817,6 +818,9 @@ export default function ChatView(props: ChatViewProps) { !isServerThread || serverProjection === null ? null : deriveResumableRunId(serverProjection, serverRuntime); + const canResume = + serverProjection !== null && + canResumeThread(serverProjection, resumableRunId, hasHeldQueuedRuns); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId @@ -2856,6 +2860,7 @@ export default function ChatView(props: ChatViewProps) { activeThread, resumableRunId, hasHeldQueuedRuns, + canResume, isSendBusy, isRevertingCheckpoint, threadDetailLoading, @@ -3603,7 +3608,7 @@ export default function ChatView(props: ChatViewProps) { queuedRunEdit.isSaving || isResuming } - canResume={resumableRunId !== null || hasHeldQueuedRuns} + canResume={canResume} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ !canOperateThread diff --git a/apps/web/src/components/chat/useComposerResume.browser.test.tsx b/apps/web/src/components/chat/useComposerResume.browser.test.tsx index 959166792e13c..c544e7c046859 100644 --- a/apps/web/src/components/chat/useComposerResume.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerResume.browser.test.tsx @@ -52,6 +52,7 @@ function ResumeButton(props: { activeThread: { id: threadA } as never, resumableRunId: RunId.make("run:interrupted"), hasHeldQueuedRuns: false, + canResume: true, isSendBusy: false, isRevertingCheckpoint: false, threadDetailLoading: false, diff --git a/apps/web/src/components/chat/useComposerResume.ts b/apps/web/src/components/chat/useComposerResume.ts index f81dce9517c96..4d0055a62777b 100644 --- a/apps/web/src/components/chat/useComposerResume.ts +++ b/apps/web/src/components/chat/useComposerResume.ts @@ -27,6 +27,7 @@ interface ComposerResumeInput { readonly activeThread: EnvironmentThreadShell | undefined; readonly resumableRunId: RunId | null; readonly hasHeldQueuedRuns: boolean; + readonly canResume: boolean; readonly isSendBusy: boolean; readonly isRevertingCheckpoint: boolean; readonly threadDetailLoading: boolean; @@ -59,6 +60,7 @@ export function useComposerResume(input: ComposerResumeInput) { activeThread, resumableRunId, hasHeldQueuedRuns, + canResume, isSendBusy, isRevertingCheckpoint, threadDetailLoading, @@ -87,7 +89,7 @@ export function useComposerResume(input: ComposerResumeInput) { const onResume = async () => { if ( !activeThread || - (resumableRunId === null && !hasHeldQueuedRuns) || + !canResume || isSendBusy || isResuming || isRevertingCheckpoint || diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 9daa166e0b63d..111a06f46d2c5 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -9,9 +9,11 @@ import { ProviderTurnId, RunAttemptId, RunId, + RuntimeRequestId, ThreadId, type OrchestrationV2ExecutionNode, type OrchestrationV2RunStatus, + type OrchestrationV2RuntimeRequest, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { usageLimitBlockedRun } from "@t3tools/shared/orchestrationV2ThreadError"; @@ -19,6 +21,7 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { + canResumeThread, deriveReportedModelSelection, deriveLatestThreadRun, deriveProviderSubagentStatus, @@ -34,6 +37,24 @@ import { const now = DateTime.makeUnsafe("2026-07-28T10:00:00.000Z"); +function request( + id: string, + status: OrchestrationV2RuntimeRequest["status"], + kind: OrchestrationV2RuntimeRequest["kind"] = "command", +): OrchestrationV2RuntimeRequest { + return { + id: RuntimeRequestId.make(id), + nodeId: NodeId.make(`node-${id}`), + providerTurnId: null, + nativeRequestRef: null, + kind, + status, + responseCapability: { type: "not_resumable", reason: "Session ended." }, + createdAt: now, + resolvedAt: status === "pending" ? null : now, + }; +} + function run(id: string, ordinal: number, status: OrchestrationV2RunStatus) { return { id: RunId.make(id), @@ -251,6 +272,61 @@ describe("thread execution presentation", () => { expect(deriveThreadRuntime(onlyHeld)).toBeNull(); }); + it.each([ + { name: "no requests", requests: [], runId: "run-stopped", held: false, expected: true }, + { + name: "a pending request", + requests: [request("approval", "pending")], + runId: "run-stopped", + held: false, + expected: false, + }, + ...(["resolved", "cancelled", "expired"] as const).map((status) => ({ + name: `only a request now ${status}`, + requests: [request("approval", status)], + runId: "run-stopped", + held: false, + expected: true, + })), + { + name: "a pending request no card shows", + requests: [request("auth", "pending", "auth_refresh")], + runId: "run-stopped", + held: false, + expected: false, + }, + { + name: "an older pending request among settled ones", + requests: [request("older", "pending"), request("newer", "resolved")], + runId: "run-stopped", + held: false, + expected: false, + }, + { + name: "a held queue behind a blocked continuation", + requests: [request("approval", "pending")], + runId: "run-stopped", + held: true, + expected: false, + }, + { + name: "a held queue with no run to continue", + requests: [request("approval", "pending")], + runId: null, + held: true, + expected: true, + }, + { name: "nothing to resume", requests: [], runId: null, held: false, expected: false }, + ])("decides whether Resume can start with $name", ({ requests, runId, held, expected }) => { + expect( + canResumeThread( + { runtimeRequests: requests }, + runId === null ? null : RunId.make(runId), + held, + ), + ).toBe(expected); + }); + it("does not expose a queued-only run as interruptible", () => { const queuedRun = run("run-queued", 1, "queued"); const projection = { ...v2Projection, runs: [queuedRun], updatedAt: now }; diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 3420c25de6cf2..d58f528114307 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -37,6 +37,7 @@ import { } from "./models.ts"; import { commandDisplayText } from "../work-log/commandLabel.ts"; import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; +import { pendingRuntimeRequestIds } from "./threadRequests.ts"; const ACTIVITY_RUN_STATUSES = new Set(["preparing", "starting", "running", "waiting"]); const INTERRUPTIBLE_RUN_STATUSES = new Set(["preparing", "starting", "running"]); @@ -226,6 +227,19 @@ export function deriveResumableRunId( : null; } +/** + * Whether Resume can start: the run's continuation, else the held queue. The server refuses that + * continuation while any request is pending, so the queue must not be released in its place. + */ +export function canResumeThread( + projection: Pick, + resumableRunId: RunId | null, + queueHeld: boolean, +): boolean { + if (resumableRunId === null) return queueHeld; + return pendingRuntimeRequestIds(projection).length === 0; +} + export function deriveThreadRuntime( projection: ThreadStructure & Pick, ): ThreadRuntimeSummary | null { From 8eef7b8049b3aa009cedd6254a5b3a0e85ca6ae5 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 09:59:41 -0600 Subject: [PATCH 2/4] fix(client): say when a pending request blocks Resume Requests with no approval or question card left Resume hidden with no reason; the composer placeholder now names the blocker on web and mobile. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/NewTaskDraftScreen.tsx | 1 + .../features/threads/ThreadDetailScreen.tsx | 5 ++++- .../src/features/threads/ThreadRouteScreen.tsx | 1 + .../src/state/use-thread-composer-state.ts | 2 ++ apps/web/src/components/ChatView.tsx | 2 ++ apps/web/src/components/chat/ChatComposer.tsx | 18 +++++++++++------- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index f3488ebd3bfff..842c7e130d9fd 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -983,6 +983,7 @@ export function NewTaskDraftScreen(props: { onRemoveDraftImage={flow.removeAttachment} onStopThread={() => {}} onResumeQueue={null} + resumeBlocked={false} onSendMessage={handleStart} onReconnectEnvironment={() => onReconnectEnvironment(selectedProject.environmentId)} canSwitchThreadProvider diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a71920c199f17..e6c1bf2b18dc3 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -281,6 +281,7 @@ export interface ThreadDetailScreenProps { readonly onStopThread: () => void; /** Present while the thread's queue is held. */ readonly onResumeQueue: (() => Promise) | null; + readonly resumeBlocked: boolean; readonly onSendMessage: ( followUp?: ActiveTurnComposerAction, beforeDispatch?: ( @@ -1693,7 +1694,9 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { ? props.activePendingApproval.supportsFeedback ? "Send feedback to decline this request" : "Send a message to decline and redirect the agent" - : "Ask anything" + : props.resumeBlocked + ? "A pending request blocks Resume. Send a message instead." + : "Ask anything" } contentMaxWidth={contentMaxWidth} draftControls={props.draftMode?.controls} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 952c0b4c542c2..8220cf2743ac8 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1110,6 +1110,7 @@ function ThreadRouteContent( serverConfig={serverConfig} onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} onResumeQueue={composer.canResume ? handleResumeQueue : null} + resumeBlocked={composer.resumeBlocked} onSendMessage={composer.onSendMessage} onReconnectEnvironment={handleReconnectEnvironment} canSwitchThreadProvider={composer.canSwitchThreadProvider} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index a4f383dcc99a1..22531548c7e25 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -369,6 +369,7 @@ export function useThreadComposerState() { const canResume = selectedThreadProjection ? canResumeThread(selectedThreadProjection.projection, resumableRunId, queueHeld) : queueHeld; + const resumeBlocked = resumableRunId !== null && !canResume; const runlessWorkStartedAt = selectedThreadProjection ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) @@ -759,6 +760,7 @@ export function useThreadComposerState() { queueHeld, resumableRunId, canResume, + resumeBlocked, queuedRunEdit, isSavingQueuedEdit, cancelQueuedRunEdit, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ecf3f6078055e..336bf4af2e60f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -821,6 +821,7 @@ export default function ChatView(props: ChatViewProps) { const canResume = serverProjection !== null && canResumeThread(serverProjection, resumableRunId, hasHeldQueuedRuns); + const resumeBlocked = resumableRunId !== null && !canResume; const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId @@ -3609,6 +3610,7 @@ export default function ChatView(props: ChatViewProps) { isResuming } canResume={canResume} + resumeBlocked={resumeBlocked} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ !canOperateThread diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 82b6b9617c1b0..d4e9b4e1f6513 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -375,6 +375,7 @@ export interface ChatComposerProps { canInterrupt: boolean; isSendBusy: boolean; canResume: boolean; + resumeBlocked: boolean; isRevertingCheckpoint?: boolean; sendDisabledReason: string | null; isPreparingWorktree: boolean; @@ -527,6 +528,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) canInterrupt, isSendBusy, canResume, + resumeBlocked, isRevertingCheckpoint = false, sendDisabledReason: externalSendDisabledReason, isPreparingWorktree, @@ -3377,13 +3379,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? isChoiceOnlyPendingQuestion ? "Choose an option above" : "Type your own answer, or leave this blank to use the selected option" - : showPlanFollowUpPrompt && activeProposedPlan - ? "Add feedback to refine the plan, or leave this blank to implement it" - : projectSelectionRequired - ? "Choose a project above to start a thread" - : showProviderUnavailable - ? "Enable a provider in Settings to send a message" - : DEFAULT_COMPOSER_PLACEHOLDER + : resumeBlocked + ? "A pending request blocks Resume. Send a message instead." + : showPlanFollowUpPrompt && activeProposedPlan + ? "Add feedback to refine the plan, or leave this blank to implement it" + : projectSelectionRequired + ? "Choose a project above to start a thread" + : showProviderUnavailable + ? "Enable a provider in Settings to send a message" + : DEFAULT_COMPOSER_PLACEHOLDER } disabled={ projectSelectionRequired || From 7d9aebc5cde8de4b49b3ffee0e4a1591536d7d6d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 08:34:04 +0000 Subject: [PATCH 3/4] fix(web): show the Resume blocker in the collapsed mobile composer --- apps/web/src/components/chat/ChatComposer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f1de65ddc4c17..61acdc8d5edae 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3371,7 +3371,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) aria-label="Expand composer" > {prompt.trim() || - (showProviderUnavailable ? "Enable a provider in Settings" : "Ask anything...")} + (showProviderUnavailable + ? "Enable a provider in Settings" + : resumeBlocked + ? "A pending request blocks Resume. Send a message instead." + : "Ask anything...")} {collapsedComposerImagePreviews}