Skip to content
Merged
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
) : showResumeAction ? (
<ComposerActionButton
size={usesLargeControls ? "large" : "regular"}
accessibilityLabel="Resume queue"
accessibilityLabel="Resume thread"
icon="play"
disabled={!props.canOperateThread || resumingQueue}
onPress={() => void handleResumeQueue()}
Expand Down Expand Up @@ -1159,7 +1159,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
) : showResumeAction && voicePresentation.showsSend ? (
<ComposerActionButton
size={usesLargeControls ? "large" : "regular"}
accessibilityLabel="Resume queue"
accessibilityLabel="Resume thread"
icon="play"
disabled={!props.canOperateThread || resumingQueue}
onPress={() => void handleResumeQueue()}
Expand Down
50 changes: 45 additions & 5 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
AuthTerminalReadScope,
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
MessageId,
ThreadId,
type ProjectScript,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -54,6 +55,10 @@ import {
} from "../../state/use-remote-environment-registry";
import { useKnownTerminalSessions } from "../../state/use-terminal-session";
import { uuidv4 } from "../../lib/uuid";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
import { useSelectedThreadDetailState } from "../../state/use-thread-detail";
import { useThreadSelection } from "../../state/use-thread-selection";
Expand Down Expand Up @@ -101,6 +106,7 @@ import {
threadRouteOpensDraft,
} from "./thread-route-hydration";
import { ThreadHeader } from "./ThreadHeader";
import { resumeThread } from "./resume-thread";
import {
useAndroidWorkspaceBack,
useWorkspaceBackNavigation,
Expand Down Expand Up @@ -385,7 +391,22 @@ function ThreadRouteContent(
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, "resume queue");
const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, {
label: "resume queue",
reportFailure: false,
});
const continueThreadRun = useAtomCommand(threadEnvironment.startTurn, {
label: "continue thread",
reportFailure: false,
});
const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {
label: "set thread runtime mode",
reportFailure: false,
});
const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, {
label: "set thread interaction mode",
reportFailure: false,
});
const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
label: "load earlier thread history",
reportFailure: false,
Expand Down Expand Up @@ -689,10 +710,27 @@ function ThreadRouteContent(

const handleResumeQueue = async () => {
if (!selectedThread) return;
await resumeThreadQueue({
environmentId: selectedThread.environmentId,
input: { threadId: selectedThread.id },
const result = await resumeThread({
thread: selectedThread,
resumableRunId: composer.resumableRunId,
queueHeld: composer.queueHeld,
runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode,
interactionMode: composer.interactionMode ?? selectedThread.interactionMode,
modelSelection: composer.modelSelection,
messageId: MessageId.make(uuidv4()),
createdAt: new Date().toISOString(),
setRuntimeMode: setThreadRuntimeMode,
setInteractionMode: setThreadInteractionMode,
startTurn: continueThreadRun,
resumeQueue: resumeThreadQueue,
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Dialog.alert(
"Could not resume thread",
error instanceof Error ? error.message : "Try again.",
);
}
};

const handleOpenTerminal = (nextTerminalId?: string | null) => {
Expand Down Expand Up @@ -1077,7 +1115,9 @@ function ThreadRouteContent(
onRemoveDraftImage={composer.onRemoveDraftImage}
serverConfig={serverConfig}
onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
onResumeQueue={composer.queueHeld ? handleResumeQueue : null}
onResumeQueue={
composer.queueHeld || composer.resumableRunId !== null ? handleResumeQueue : null
}
onSendMessage={composer.onSendMessage}
onReconnectEnvironment={handleReconnectEnvironment}
canSwitchThreadProvider={composer.canSwitchThreadProvider}
Expand Down
90 changes: 90 additions & 0 deletions apps/mobile/src/features/threads/resume-thread.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { describe, expect, it } from "vite-plus/test";

import { resumeThread } from "./resume-thread";

const runId = RunId.make("run-limited");
const failure = AsyncResult.failure(Cause.fail(new Error("not connected")));

function setup(options: { readonly startTurnFails?: boolean } = {}) {
const calls: Array<{ readonly command: string; readonly input: unknown }> = [];
const record =
(command: string, fails = false) =>
async (request: { readonly input: unknown }) => {
calls.push({ command, input: request.input });
return fails ? failure : AsyncResult.success(undefined);
};
const base = {
thread: {
environmentId: EnvironmentId.make("env"),
id: ThreadId.make("thread"),
runtimeMode: "full-access",
interactionMode: "default",
},
runtimeMode: "full-access",
interactionMode: "default",
modelSelection: null,
messageId: MessageId.make("message"),
createdAt: "2026-10-30T00:00:00.000Z",
setRuntimeMode: record("setRuntimeMode"),
setInteractionMode: record("setInteractionMode"),
startTurn: record("startTurn", options.startTurnFails),
resumeQueue: record("resumeQueue"),
} as const;
return { calls, base };
}

describe("resumeThread", () => {
it("continues the limited run before resuming its held queue", async () => {
const { calls, base } = setup();
const result = await resumeThread({ ...base, resumableRunId: runId, queueHeld: true });

expect(result._tag).toBe("Success");
expect(calls.map((call) => call.command)).toEqual(["startTurn", "resumeQueue"]);
expect(calls[0]?.input).toMatchObject({
manualContinuationOfRunId: runId,
dispatchMode: "start",
});
});

it("does not resume the queue when the continuation fails", async () => {
const { calls, base } = setup({ startTurnFails: true });
const result = await resumeThread({ ...base, resumableRunId: runId, queueHeld: true });

expect(result).toBe(failure);
expect(calls.map((call) => call.command)).toEqual(["startTurn"]);
});

it("only continues the run when nothing is queued", async () => {
const { calls, base } = setup();
await resumeThread({ ...base, resumableRunId: runId, queueHeld: false });

expect(calls.map((call) => call.command)).toEqual(["startTurn"]);
});

it("saves changed modes to the thread before continuing", async () => {
const { calls, base } = setup();
await resumeThread({
...base,
runtimeMode: "approval-required",
interactionMode: "plan",
resumableRunId: runId,
queueHeld: false,
});

expect(calls.map((call) => call.command)).toEqual([
"setRuntimeMode",
"setInteractionMode",
"startTurn",
]);
});

it("resumes a held queue directly when no run needs continuing", async () => {
const { calls, base } = setup();
await resumeThread({ ...base, resumableRunId: null, queueHeld: true });

expect(calls.map((call) => call.command)).toEqual(["resumeQueue"]);
});
});
83 changes: 83 additions & 0 deletions apps/mobile/src/features/threads/resume-thread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type {
ResumeThreadQueueInput,
SetThreadInteractionModeInput,
SetThreadRuntimeModeInput,
StartThreadTurnInput,
} from "@t3tools/client-runtime/operations";
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
import type {
EnvironmentId,
MessageId,
ModelSelection,
ProviderInteractionMode,
RunId,
RuntimeMode,
ThreadId,
} from "@t3tools/contracts";

type Command<Input, E> = (request: {
readonly environmentId: EnvironmentId;
readonly input: Input;
}) => Promise<AtomCommandResult<unknown, E>>;

/** Resolves to the first failed command's result, or the last successful one. */
export async function resumeThread<E>(input: {
readonly thread: {
readonly environmentId: EnvironmentId;
readonly id: ThreadId;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
};
readonly resumableRunId: RunId | null;
readonly queueHeld: boolean;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
readonly modelSelection: ModelSelection | null;
readonly messageId: MessageId;
readonly createdAt: string;
readonly setRuntimeMode: Command<SetThreadRuntimeModeInput, E>;
readonly setInteractionMode: Command<SetThreadInteractionModeInput, E>;
readonly startTurn: Command<StartThreadTurnInput, E>;
readonly resumeQueue: Command<ResumeThreadQueueInput, E>;
}): Promise<AtomCommandResult<unknown, E>> {
const { thread, runtimeMode, interactionMode, createdAt } = input;
const environmentId = thread.environmentId;
// The server holds the queue behind a stopped or usage-limited run until that run is continued.
if (input.resumableRunId !== null) {
// A turn on an existing thread runs with the thread's saved modes, not the turn's.
if (runtimeMode !== thread.runtimeMode) {
const saved = await input.setRuntimeMode({
environmentId,
input: { threadId: thread.id, runtimeMode, createdAt },
});
if (saved._tag === "Failure") return saved;
}
if (interactionMode !== thread.interactionMode) {
const saved = await input.setInteractionMode({
environmentId,
input: { threadId: thread.id, interactionMode, createdAt },
});
if (saved._tag === "Failure") return saved;
}
const continued = await input.startTurn({
environmentId,
input: {
creationSource: "mobile",
threadId: thread.id,
manualContinuationOfRunId: input.resumableRunId,
...(input.modelSelection ? { modelSelection: input.modelSelection } : {}),
message: {
messageId: input.messageId,
role: "user",
text: "Continue where you left off.",
attachments: [],
},
runtimeMode,
interactionMode,
dispatchMode: "start",
},
});
if (continued._tag === "Failure" || !input.queueHeld) return continued;
}
return input.resumeQueue({ environmentId, input: { threadId: thread.id } });
}
5 changes: 5 additions & 0 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thre
import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
import {
deriveProviderSubagentStatus,
deriveResumableRunId,
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
deriveThreadRuntime,
Expand Down Expand Up @@ -359,6 +360,9 @@ export function useThreadComposerState() {
// A stop or a restart holds the queue, completion wakes included, until it
// is resumed; the queue sheet lists only the user's own messages.
const queueHeld = queueWorkflow?.isHeld === true;
const resumableRunId = selectedThreadProjection
? deriveResumableRunId(selectedThreadProjection.projection, selectedThreadRuntime)
: null;

const runlessWorkStartedAt = selectedThreadProjection
? deriveRunlessWorkStartedAt(selectedThreadProjection.projection)
Expand Down Expand Up @@ -743,6 +747,7 @@ export function useThreadComposerState() {
followUpBehavior,
canSteerActiveTurn,
queueHeld,
resumableRunId,
queuedRunEdit,
isSavingQueuedEdit,
cancelQueuedRunEdit,
Expand Down
19 changes: 5 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent";
import { usePendingThreadRequests } from "./chat/usePendingThreadRequests";
import { isScratchProject } from "@t3tools/client-runtime/state/projects";
import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
import {
latestExecutedRun,
latestRootProviderFailure,
} from "@t3tools/shared/orchestrationV2ThreadError";
import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";
import { feedbackBannerItem } from "./chat/ComposerFeedback";
import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel";
Expand Down Expand Up @@ -76,6 +72,7 @@ import {
import {
deriveProviderSubagentStatus,
deriveReportedModelSelection,
deriveResumableRunId,
formatModelSelectionEffort,
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
Expand Down Expand Up @@ -814,16 +811,10 @@ export default function ChatView(props: ChatViewProps) {
isServerThread &&
serverProjection?.runs.some((run) => run.status === "queued" && run.queueHeld === true) ===
true;
const resumableRunId = (() => {
if (!isServerThread || serverProjection === null) return null;
const run = latestExecutedRun(serverProjection.runs);
if (run?.status === "interrupted") return run.id;
return run?.status === "failed" &&
serverRuntime?.lastErrorClass === "usage_limit" &&
latestRootProviderFailure(run, serverProjection.turnItems)?.class === "usage_limit"
? run.id
: null;
})();
const resumableRunId =
!isServerThread || serverProjection === null
? null
: deriveResumableRunId(serverProjection, serverRuntime);
const parentSubagentThreadId =
activeThread?.lineage.relationshipToParent === "subagent"
? activeThread.lineage.parentThreadId
Expand Down
4 changes: 2 additions & 2 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ images. **Send now** sends a message right away, into the running turn if there
also edit or remove it.

If you stop the agent or the server restarts, queued messages keep their order and are held.
Press **Resume** in an empty composer on web and desktop, or **Resume queue** on mobile, to
continue. Sending a new message also resumes the queue, unless it steers a turn that is already
Press **Resume** in an empty composer to continue. **Resume** also continues a stopped
run or one that hit a usage limit, even with nothing queued. Sending a new message also resumes the queue, unless it steers a turn that is already
running. You can edit or remove held messages without starting them.

The pencil on a queued message opens it in the composer for editing. The original message
Expand Down
5 changes: 3 additions & 2 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,9 @@ from its context menu, or **Copy thread** from its long-press menu on mobile.
**Limited** means the provider stopped on a usage or rate limit. The conversation
keeps the provider's explanation. Retry after the limit resets, or switch to
another provider instance.
On web and desktop, press **Resume** in an empty composer to continue a limited
or interrupted turn manually.
Press **Resume** in an empty composer to continue a limited or interrupted turn
manually. Do this when the provider reports no reset time, because nothing
continues the turn on its own.
Queued messages stay saved while the limit blocks the thread. They run after
the continuation finishes. If the queue was held by a restart, resume it then.

Expand Down
Loading
Loading