diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index ab1256cddb3..c7286853866 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -158,7 +158,10 @@ import {
nextProjectScriptId,
projectScriptIdFromCommand,
} from "~/projectScripts";
-import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
+import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
+import { resolveComposerSendLabel } from "~/outbox/composerSendLabel.logic";
+import { enqueueThreadOutboxMessage, useThreadOutboxQueue } from "~/outbox/threadOutbox";
+import { ThreadOutboxQueueList } from "./chat/ThreadOutboxQueueList";
import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";
@@ -2012,6 +2015,21 @@ function ChatViewContent(props: ChatViewProps) {
threadError,
});
const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
+ // Thread outbox ("queue"): submitting while the thread is busy/disconnected
+ // enqueues instead of erroring, and the drain sends the head once settled.
+ const outboxQueue = useThreadOutboxQueue(activeThread?.environmentId ?? null, activeThreadId);
+ const composerActiveThreadBusy = phase === "running" || isSendBusy || isRevertingCheckpoint;
+ const composerSendLabel = resolveComposerSendLabel({
+ connectionState: activeEnvironmentConnectionPhase,
+ activeThreadBusy: composerActiveThreadBusy,
+ queueCount: outboxQueue.length,
+ });
+ const composerQueueMode =
+ isServerThread &&
+ !isLocalDraftThread &&
+ !activePendingProgress &&
+ !showPlanFollowUpPrompt &&
+ composerSendLabel === "Queue";
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
activeThread?.session ?? null,
@@ -4434,8 +4452,135 @@ function ChatViewContent(props: ChatViewProps) {
],
);
+ // Enqueue the current composer content into the thread outbox. Text-only:
+ // terminal/element/preview/review contexts are folded into the prompt like a
+ // normal send, but image attachments cannot survive a reload through
+ // localStorage and are not carried on a queued message.
+ const handleQueueComposerSubmission = useCallback(async () => {
+ if (!activeThread || !activeProject) return;
+ const sendCtx = composerRef.current?.getSendContext();
+ if (!sendCtx?.providerAvailable) return;
+ const {
+ images: composerImages,
+ terminalContexts: composerTerminalContexts,
+ elementContexts: composerElementContexts,
+ previewAnnotations: composerPreviewAnnotations,
+ reviewComments: composerReviewComments,
+ selectedProvider: ctxSelectedProvider,
+ selectedModel: ctxSelectedModel,
+ selectedProviderModels: ctxSelectedProviderModels,
+ selectedPromptEffort: ctxSelectedPromptEffort,
+ selectedModelSelection: ctxSelectedModelSelection,
+ } = sendCtx;
+ const promptForSend = promptRef.current;
+ const { sendableTerminalContexts, hasSendableContent } = deriveComposerSendState({
+ prompt: promptForSend,
+ imageCount: composerImages.length,
+ terminalContexts: composerTerminalContexts,
+ elementContextCount:
+ composerElementContexts.length +
+ composerPreviewAnnotations.length +
+ composerReviewComments.length,
+ });
+ if (!hasSendableContent) return;
+
+ const messageTextWithContexts = appendElementContextsToPrompt(
+ appendTerminalContextsToPrompt(promptForSend, sendableTerminalContexts),
+ composerElementContexts,
+ );
+ const messageTextWithPreviewAnnotations = composerPreviewAnnotations.reduce(
+ (text, annotation) => appendPreviewAnnotationPrompt(text, annotation),
+ messageTextWithContexts,
+ );
+ const messageTextForSend = appendReviewCommentsToPrompt(
+ messageTextWithPreviewAnnotations,
+ composerReviewComments,
+ );
+ // The queue is text-only. If the composer holds nothing but image
+ // attachments (no text and no non-image context survived), there is nothing
+ // we can faithfully queue: enqueuing the image-only bootstrap prompt would
+ // deliver a message claiming images are attached while carrying none, and
+ // clearing the draft would destroy the image. Warn and leave the composer
+ // intact so the user can add text, or wait for the thread to idle and send.
+ if (messageTextForSend.trim().length === 0) {
+ if (composerImages.length > 0) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "warning",
+ title: "Images can't be queued",
+ description:
+ "Queued messages are text-only. Add a message to queue, or wait until the chat is idle to send with the image.",
+ }),
+ );
+ }
+ return;
+ }
+
+ const queuedText = formatOutgoingPrompt({
+ provider: ctxSelectedProvider,
+ model: ctxSelectedModel,
+ models: ctxSelectedProviderModels,
+ effort: ctxSelectedPromptEffort,
+ text: messageTextForSend,
+ });
+
+ try {
+ await enqueueThreadOutboxMessage({
+ environmentId: activeThread.environmentId,
+ threadId: activeThread.id,
+ messageId: newMessageId(),
+ commandId: newCommandId(),
+ text: queuedText,
+ modelSelection: ctxSelectedModelSelection,
+ runtimeMode,
+ interactionMode,
+ createdAt: new Date().toISOString(),
+ });
+ } catch (error) {
+ setThreadError(
+ activeThread.id,
+ error instanceof Error ? error.message : "Failed to queue message.",
+ );
+ return;
+ }
+
+ // Warn about dropped images only after the text message actually entered the
+ // queue, so a failed enqueue never misinforms the user that images were
+ // "not added" when nothing was queued at all.
+ if (composerImages.length > 0) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "warning",
+ title: "Images aren't queued",
+ description:
+ "Queued messages are text-only; attached images were not added to the queued message.",
+ }),
+ );
+ }
+
+ promptRef.current = "";
+ clearComposerDraftContent(composerDraftTarget);
+ composerRef.current?.resetCursorState();
+ }, [
+ activeProject,
+ activeThread,
+ clearComposerDraftContent,
+ composerDraftTarget,
+ composerRef,
+ interactionMode,
+ promptRef,
+ runtimeMode,
+ setThreadError,
+ ]);
+
const onSend = async (e?: { preventDefault: () => void }) => {
e?.preventDefault();
+ // Queue mode routes the submit through the thread outbox instead of the
+ // immediate-send path (which is gated on an idle, connected thread).
+ if (composerQueueMode) {
+ await handleQueueComposerSubmission();
+ return;
+ }
if (
!activeThread ||
isSendBusy ||
@@ -5782,6 +5927,10 @@ function ChatViewContent(props: ChatViewProps) {
>
+
void;
onInterrupt: () => void;
@@ -445,6 +447,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isEnvironmentUnavailable={props.isEnvironmentUnavailable}
isPreparingWorktree={props.isPreparingWorktree}
hasSendableContent={props.hasSendableContent}
+ sendLabel={props.sendLabel}
+ canQueue={props.canQueue}
preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
onPreviousPendingQuestion={props.onPreviousPendingQuestion}
onInterrupt={props.onInterrupt}
@@ -507,6 +511,8 @@ export interface ChatComposerProps {
routeKind: "server" | "draft";
routeThreadRef: ScopedThreadRef;
draftId: DraftId | null;
+ /** Primary-action label: "Queue" when a submit will enqueue, else "Send". */
+ sendLabel: "Send" | "Queue";
// Thread context
activeThreadId: ThreadId | null;
@@ -622,11 +628,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
routeKind,
routeThreadRef,
draftId,
+ sendLabel,
activeThreadId,
activeThreadEnvironmentId: _activeThreadEnvironmentId,
activeThread,
- isServerThread: _isServerThread,
- isLocalDraftThread: _isLocalDraftThread,
+ isServerThread,
+ isLocalDraftThread,
forceExpandedOnMobile,
projectSelectionRequired,
phase,
@@ -841,6 +848,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
[providerInstanceEntries, selectedInstanceId],
);
const noProviderAvailable = selectedProviderEntry === undefined;
+ // Queuing is only supported for existing server threads with a usable
+ // provider and project; the primary action shows "Queue" and moves Stop to a
+ // secondary control only under these conditions.
+ const canQueueSubmit =
+ isServerThread && !isLocalDraftThread && !noProviderAvailable && !projectSelectionRequired;
// The driver kind follows the instance that will actually run the turn,
// which can differ from the persisted selection when that selection is
// disabled.
@@ -2732,6 +2744,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
isPreparingWorktree={isPreparingWorktree}
hasSendableContent={composerSendState.hasSendableContent}
+ sendLabel={canQueueSubmit ? sendLabel : "Send"}
+ canQueue={canQueueSubmit}
preserveComposerFocusOnPointerDown={isMobileViewport}
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
index 3e664e226b7..4855def635b 100644
--- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx
+++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
@@ -25,6 +25,14 @@ interface ComposerPrimaryActionsProps {
isEnvironmentUnavailable: boolean;
isPreparingWorktree: boolean;
hasSendableContent: boolean;
+ /** "Queue" when a submit will enqueue to the thread outbox, else "Send". */
+ sendLabel?: "Send" | "Queue";
+ /**
+ * Whether queuing is available for this thread. When true and the thread is
+ * running, the primary action becomes a "Queue" submit and Stop moves to a
+ * secondary control instead of being the sole primary action.
+ */
+ canQueue?: boolean;
preserveComposerFocusOnPointerDown?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
@@ -64,6 +72,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
isEnvironmentUnavailable,
isPreparingWorktree,
hasSendableContent,
+ sendLabel = "Send",
+ canQueue = false,
preserveComposerFocusOnPointerDown = false,
onPreviousPendingQuestion,
onInterrupt,
@@ -74,6 +84,32 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
: undefined;
const stageBackdropVariant = useSidebarStageBackdropVariant();
+ const stopButton = (
+
+ );
+
+ const queueButton = (
+
+ );
+
if (pendingAction) {
return (
@@ -126,19 +162,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
}
if (isRunning) {
- return (
-
- );
+ // While the thread is busy, queuing (when available) becomes the primary
+ // action and Stop moves beside it as a secondary control.
+ if (canQueue) {
+ return (
+
+ {stopButton}
+ {queueButton}
+
+ );
+ }
+ return stopButton;
}
if (showPlanFollowUpPrompt) {
@@ -195,6 +229,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
);
}
+ // Idle but the submit will still enqueue (outbox non-empty, or disconnected):
+ // surface an explicit "Queue" control so Return/click reads as queuing.
+ if (sendLabel === "Queue" && canQueue) {
+ return queueButton;
+ }
+
return (
+ );
+}
+
+function QueuedMessageRow({ message }: { message: QueuedThreadMessage }) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [draft, setDraft] = useState(message.text);
+ const [busy, setBusy] = useState(false);
+ const messageIdRef = useRef(message.messageId);
+
+ // Keep the local draft aligned with external updates while not editing.
+ useEffect(() => {
+ if (!isEditing) {
+ setDraft(message.text);
+ }
+ }, [isEditing, message.text]);
+
+ // Release the editing hold if the row unmounts mid-edit (e.g. delivered).
+ useEffect(() => {
+ const id = messageIdRef.current;
+ return () => {
+ releaseEditingThreadOutboxMessage(id);
+ };
+ }, []);
+
+ const beginEditing = useCallback(() => {
+ holdEditingThreadOutboxMessage(message.messageId);
+ setDraft(message.text);
+ setIsEditing(true);
+ }, [message.messageId, message.text]);
+
+ const cancelEditing = useCallback(() => {
+ setIsEditing(false);
+ setDraft(message.text);
+ releaseEditingThreadOutboxMessage(message.messageId);
+ }, [message.messageId, message.text]);
+
+ const saveEditing = useCallback(async () => {
+ const nextText = draft.trim();
+ if (nextText.length === 0 || nextText === message.text) {
+ cancelEditing();
+ return;
+ }
+ setBusy(true);
+ try {
+ await updateThreadOutboxMessage({ ...message, text: nextText });
+ } finally {
+ setBusy(false);
+ setIsEditing(false);
+ releaseEditingThreadOutboxMessage(message.messageId);
+ }
+ }, [cancelEditing, draft, message]);
+
+ const remove = useCallback(async () => {
+ setBusy(true);
+ try {
+ await removeThreadOutboxMessage(message);
+ } finally {
+ setBusy(false);
+ }
+ }, [message]);
+
+ if (isEditing) {
+ return (
+
+
+ );
+ }
+
+ return (
+
+
+ {message.text}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts
index 56d25fa142e..1ab9ca47559 100644
--- a/apps/web/src/connection/platform.ts
+++ b/apps/web/src/connection/platform.ts
@@ -577,8 +577,14 @@ const environmentOwnedDataCleanupLayer = Layer.succeed(
EnvironmentOwnedDataCleanup,
EnvironmentOwnedDataCleanup.of({
clear: (environmentId) =>
- Effect.sync(() => {
+ Effect.promise(async () => {
clearComposerDraftsEnvironment(environmentId);
+ // Imported lazily: a static import here would form a module-init cycle
+ // (platform -> outbox/threadOutbox -> state/shell -> connection catalog)
+ // that leaves catalog atoms undefined at collection time. This callback
+ // only runs on environment removal, long after init.
+ const { clearThreadOutboxEnvironment } = await import("../outbox/threadOutbox");
+ await clearThreadOutboxEnvironment(environmentId);
}),
}),
);
diff --git a/apps/web/src/outbox/composerSendLabel.logic.test.ts b/apps/web/src/outbox/composerSendLabel.logic.test.ts
new file mode 100644
index 00000000000..b598fb3ba11
--- /dev/null
+++ b/apps/web/src/outbox/composerSendLabel.logic.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveComposerSendLabel } from "./composerSendLabel.logic";
+
+describe("resolveComposerSendLabel", () => {
+ it("labels Send when connected, idle, and the outbox is empty", () => {
+ expect(
+ resolveComposerSendLabel({
+ connectionState: "connected",
+ activeThreadBusy: false,
+ queueCount: 0,
+ }),
+ ).toBe("Send");
+ });
+
+ it("labels Queue while the active thread is busy", () => {
+ expect(
+ resolveComposerSendLabel({
+ connectionState: "connected",
+ activeThreadBusy: true,
+ queueCount: 0,
+ }),
+ ).toBe("Queue");
+ });
+
+ it("labels Queue when messages are already queued to send later", () => {
+ expect(
+ resolveComposerSendLabel({
+ connectionState: "connected",
+ activeThreadBusy: false,
+ queueCount: 2,
+ }),
+ ).toBe("Queue");
+ });
+
+ it("labels Queue while the environment is not connected", () => {
+ expect(
+ resolveComposerSendLabel({
+ connectionState: "reconnecting",
+ activeThreadBusy: false,
+ queueCount: 0,
+ }),
+ ).toBe("Queue");
+ });
+});
diff --git a/apps/web/src/outbox/composerSendLabel.logic.ts b/apps/web/src/outbox/composerSendLabel.logic.ts
new file mode 100644
index 00000000000..7171c76d7b5
--- /dev/null
+++ b/apps/web/src/outbox/composerSendLabel.logic.ts
@@ -0,0 +1,24 @@
+import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+
+/**
+ * Ported from apps/mobile/src/features/threads/composerSendLabel.ts.
+ *
+ * The composer's send affordance doubles as a queue affordance: every "send"
+ * enqueues to the thread outbox, which sends-when-idle and holds-when-busy.
+ * The button label mirrors that decision so the user knows whether pressing it
+ * (or hitting Return) will dispatch now or queue for later.
+ *
+ * Returns "Queue" when the message cannot be delivered immediately — the
+ * environment is not connected, the active thread is still working, or the
+ * outbox already holds messages — and "Send" only when it will go out now.
+ */
+export function resolveComposerSendLabel(input: {
+ readonly connectionState: EnvironmentConnectionPhase;
+ readonly activeThreadBusy: boolean;
+ readonly queueCount: number;
+}): "Send" | "Queue" {
+ if (input.connectionState !== "connected" || input.activeThreadBusy || input.queueCount > 0) {
+ return "Queue";
+ }
+ return "Send";
+}
diff --git a/apps/web/src/outbox/threadOutbox.logic.test.ts b/apps/web/src/outbox/threadOutbox.logic.test.ts
new file mode 100644
index 00000000000..4683f97c3d5
--- /dev/null
+++ b/apps/web/src/outbox/threadOutbox.logic.test.ts
@@ -0,0 +1,375 @@
+import {
+ CommandId,
+ EnvironmentId,
+ MessageId,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
+import { AtomRegistry } from "effect/unstable/reactivity";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ decodeQueuedThreadMessage,
+ encodeQueuedThreadMessage,
+ groupQueuedThreadMessages,
+ modelSelectionsEqual,
+ parseQueuedThreadMessage,
+ resolveQueuedThreadSettings,
+ resolveThreadOutboxDeliveryAction,
+ resolveThreadOutboxFailureAction,
+ serializeQueuedThreadMessage,
+ shouldRetryThreadOutboxDelivery,
+ threadOutboxRetryDelayMs,
+ type QueuedThreadMessage,
+} from "./threadOutbox.logic";
+import { createThreadOutboxManager, ThreadOutboxManagerError } from "./threadOutboxManager";
+import type { ThreadOutboxStorage } from "./threadOutboxStorage";
+
+function queuedMessage(input: {
+ readonly environmentId?: string;
+ readonly threadId?: string;
+ readonly messageId: string;
+ readonly createdAt: string;
+}): QueuedThreadMessage {
+ return {
+ environmentId: EnvironmentId.make(input.environmentId ?? "environment-1"),
+ threadId: ThreadId.make(input.threadId ?? "thread-1"),
+ messageId: MessageId.make(input.messageId),
+ commandId: CommandId.make(`command-${input.messageId}`),
+ text: input.messageId,
+ createdAt: input.createdAt,
+ };
+}
+
+describe("thread outbox model", () => {
+ it("groups messages by scoped thread and preserves creation order", () => {
+ const later = queuedMessage({ messageId: "message-2", createdAt: "2026-06-08T10:00:02.000Z" });
+ const earlier = queuedMessage({
+ messageId: "message-1",
+ createdAt: "2026-06-08T10:00:01.000Z",
+ });
+
+ expect(groupQueuedThreadMessages([later, earlier])).toEqual({
+ "environment-1:thread-1": [earlier, later],
+ });
+ });
+
+ it("dedupes by message id, keeping the last occurrence", () => {
+ const first = queuedMessage({ messageId: "message-1", createdAt: "2026-06-08T10:00:01.000Z" });
+ const replacement = { ...first, text: "edited" };
+
+ expect(groupQueuedThreadMessages([first, replacement])).toEqual({
+ "environment-1:thread-1": [replacement],
+ });
+ });
+
+ it("decodes the persisted schema and rejects incomplete messages", () => {
+ const message = queuedMessage({
+ messageId: "message-1",
+ createdAt: "2026-06-08T10:00:01.000Z",
+ });
+
+ expect(decodeQueuedThreadMessage({ schemaVersion: 1, ...message })).toEqual(message);
+ expect(() =>
+ decodeQueuedThreadMessage({ schemaVersion: 1, environmentId: "environment-1" }),
+ ).toThrow();
+ });
+
+ it("round-trips a message with a settings snapshot through the JSON store form", () => {
+ const selectedMessage = {
+ ...queuedMessage({ messageId: "message-1", createdAt: "2026-06-08T10:00:01.000Z" }),
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.4",
+ options: [{ id: "reasoningEffort", value: "xhigh" }],
+ },
+ runtimeMode: "approval-required",
+ interactionMode: "plan",
+ } satisfies QueuedThreadMessage;
+
+ expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual(
+ selectedMessage,
+ );
+ expect(parseQueuedThreadMessage(serializeQueuedThreadMessage(selectedMessage))).toEqual(
+ selectedMessage,
+ );
+ });
+
+ it("falls back to the thread settings for fields the queued message omits", () => {
+ const legacyMessage = queuedMessage({
+ messageId: "message-1",
+ createdAt: "2026-06-08T10:00:01.000Z",
+ });
+ const threadSettings = {
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.4",
+ options: [{ id: "reasoningEffort", value: "xhigh" }],
+ },
+ runtimeMode: "approval-required",
+ interactionMode: "plan",
+ } as const;
+
+ expect(resolveQueuedThreadSettings(legacyMessage, threadSettings)).toEqual(threadSettings);
+ });
+
+ it("compares model options as part of the queued settings change", () => {
+ const base = {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.4",
+ options: [{ id: "reasoningEffort", value: "medium" }],
+ } as const;
+
+ expect(modelSelectionsEqual(base, base)).toBe(true);
+ expect(
+ modelSelectionsEqual(base, { ...base, options: [{ id: "reasoningEffort", value: "xhigh" }] }),
+ ).toBe(false);
+ expect(modelSelectionsEqual(base, { ...base, model: "gpt-5.5" })).toBe(false);
+ });
+
+ it("backs off queued delivery retries and caps them at sixteen seconds", () => {
+ expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([
+ 1_000, 2_000, 4_000, 8_000, 16_000, 16_000,
+ ]);
+ });
+
+ it("only removes a missing-thread message after shell synchronization is live", () => {
+ expect(
+ resolveThreadOutboxDeliveryAction({
+ threadExists: false,
+ shellStatus: "synchronizing",
+ environmentConnected: true,
+ threadBusy: false,
+ }),
+ ).toBe("wait");
+ expect(
+ resolveThreadOutboxDeliveryAction({
+ threadExists: false,
+ shellStatus: "live",
+ environmentConnected: true,
+ threadBusy: false,
+ }),
+ ).toBe("remove");
+ });
+
+ it("sends only when connected and the thread is idle", () => {
+ expect(
+ resolveThreadOutboxDeliveryAction({
+ threadExists: true,
+ shellStatus: "live",
+ environmentConnected: true,
+ threadBusy: false,
+ }),
+ ).toBe("send");
+ expect(
+ resolveThreadOutboxDeliveryAction({
+ threadExists: true,
+ shellStatus: "live",
+ environmentConnected: true,
+ threadBusy: true,
+ }),
+ ).toBe("wait");
+ expect(
+ resolveThreadOutboxDeliveryAction({
+ threadExists: true,
+ shellStatus: "live",
+ environmentConnected: false,
+ threadBusy: false,
+ }),
+ ).toBe("wait");
+ });
+
+ it("retries transport failures but drops deterministic command failures", () => {
+ expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true);
+ expect(
+ shouldRetryThreadOutboxDelivery({
+ _tag: "ConnectionTransientError",
+ message: "temporarily unavailable",
+ }),
+ ).toBe(true);
+ expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false);
+ });
+
+ it("retains queued messages when settings sync fails but discards deterministic start-turn failures", () => {
+ const deterministicFailure = new Error("Thread no longer exists");
+
+ expect(
+ resolveThreadOutboxFailureAction({
+ stage: "settings-sync",
+ error: deterministicFailure,
+ interrupted: false,
+ }),
+ ).toBe("retry");
+ expect(
+ resolveThreadOutboxFailureAction({
+ stage: "start-turn",
+ error: deterministicFailure,
+ interrupted: false,
+ }),
+ ).toBe("discard");
+ expect(
+ resolveThreadOutboxFailureAction({
+ stage: "start-turn",
+ error: deterministicFailure,
+ interrupted: true,
+ }),
+ ).toBe("retry");
+ });
+});
+
+describe("thread outbox manager", () => {
+ it("serializes mutations even when an earlier mutation is slower", async () => {
+ const registry = AtomRegistry.make();
+ const manager = createThreadOutboxManager({
+ registry,
+ storage: {
+ load: async () => [],
+ write: async () => undefined,
+ remove: async () => undefined,
+ },
+ });
+ const order: string[] = [];
+ let releaseFirst!: () => void;
+ const firstBlocked = new Promise((resolve) => {
+ releaseFirst = resolve;
+ });
+
+ const first = manager.serialize(async () => {
+ order.push("first:start");
+ await firstBlocked;
+ order.push("first:end");
+ });
+ const second = manager.serialize(async () => {
+ order.push("second");
+ });
+
+ await Promise.resolve();
+ expect(order).toEqual(["first:start"]);
+ releaseFirst();
+ await Promise.all([first, second]);
+ expect(order).toEqual(["first:start", "first:end", "second"]);
+ registry.dispose();
+ });
+
+ it("keeps atom state aligned with durable writes and removals", async () => {
+ const registry = AtomRegistry.make();
+ const stored = new Map();
+ const removalCause = new Error("remove failed");
+ let failRemoval = true;
+ const storage: ThreadOutboxStorage = {
+ load: async () => [...stored.values()],
+ write: async (message) => {
+ stored.set(message.messageId, message);
+ },
+ remove: async (message) => {
+ if (failRemoval) {
+ throw removalCause;
+ }
+ stored.delete(message.messageId);
+ },
+ };
+ const manager = createThreadOutboxManager({ registry, storage });
+ const message = queuedMessage({
+ messageId: "message-1",
+ createdAt: "2026-06-08T10:00:01.000Z",
+ });
+
+ await manager.enqueue(message);
+ expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
+ "environment-1:thread-1": [message],
+ });
+
+ await expect(manager.remove(message)).rejects.toEqual(
+ new ThreadOutboxManagerError({
+ operation: "remove",
+ environmentId: message.environmentId,
+ threadId: message.threadId,
+ messageId: message.messageId,
+ cause: removalCause,
+ }),
+ );
+ expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
+ "environment-1:thread-1": [message],
+ });
+
+ failRemoval = false;
+ await manager.remove(message);
+ expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({});
+ registry.dispose();
+ });
+
+ it("updates a queued message in place but never resurrects a removed one", async () => {
+ const registry = AtomRegistry.make();
+ const stored = new Map();
+ const storage: ThreadOutboxStorage = {
+ load: async () => [...stored.values()],
+ write: async (message) => {
+ stored.set(message.messageId, message);
+ },
+ remove: async (message) => {
+ stored.delete(message.messageId);
+ },
+ };
+ const manager = createThreadOutboxManager({ registry, storage });
+ const message = queuedMessage({
+ messageId: "message-1",
+ createdAt: "2026-06-08T10:00:01.000Z",
+ });
+
+ await manager.enqueue(message);
+ const edited = { ...message, text: "edited" };
+ await expect(manager.update(edited)).resolves.toBe(true);
+ expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
+ "environment-1:thread-1": [edited],
+ });
+ expect(stored.get(message.messageId)).toEqual(edited);
+
+ await manager.remove(edited);
+ await expect(manager.update({ ...message, text: "stale flush" })).resolves.toBe(false);
+ expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({});
+ expect(stored.size).toBe(0);
+ registry.dispose();
+ });
+
+ it("clears queued messages for a removed environment, including persisted-only zombies", async () => {
+ const registry = AtomRegistry.make();
+ const stored = new Map();
+ const storage: ThreadOutboxStorage = {
+ load: async () => [...stored.values()],
+ write: async (message) => {
+ stored.set(message.messageId, message);
+ },
+ remove: async (message) => {
+ stored.delete(message.messageId);
+ },
+ };
+ const manager = createThreadOutboxManager({ registry, storage });
+
+ // A message that survived a prior reload only on disk (never re-loaded into
+ // the atom) — the exact "zombie" that must not resurrect on reconnect.
+ const persistedZombie = queuedMessage({
+ messageId: "message-0",
+ createdAt: "2026-06-08T10:00:00.000Z",
+ });
+ stored.set(persistedZombie.messageId, persistedZombie);
+
+ const doomed = queuedMessage({ messageId: "message-1", createdAt: "2026-06-08T10:00:01.000Z" });
+ const survivor = queuedMessage({
+ environmentId: "environment-2",
+ threadId: "thread-2",
+ messageId: "message-2",
+ createdAt: "2026-06-08T10:00:02.000Z",
+ });
+
+ await manager.enqueue(doomed);
+ await manager.enqueue(survivor);
+
+ await manager.clearEnvironment(EnvironmentId.make("environment-1"));
+
+ expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({
+ "environment-2:thread-2": [survivor],
+ });
+ expect([...stored.values()]).toEqual([survivor]);
+ registry.dispose();
+ });
+});
diff --git a/apps/web/src/outbox/threadOutbox.logic.ts b/apps/web/src/outbox/threadOutbox.logic.ts
new file mode 100644
index 00000000000..92096297a21
--- /dev/null
+++ b/apps/web/src/outbox/threadOutbox.logic.ts
@@ -0,0 +1,199 @@
+import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
+import {
+ CommandId,
+ EnvironmentId,
+ IsoDateTime,
+ MessageId,
+ ModelSelection,
+ ProviderInteractionMode,
+ RuntimeMode,
+ ThreadId,
+ type MessageId as MessageIdType,
+ type ModelSelection as ModelSelectionType,
+ type ProviderInteractionMode as ProviderInteractionModeType,
+ type RuntimeMode as RuntimeModeType,
+} from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+
+/**
+ * Web thread outbox model, ported from apps/mobile/src/state/thread-outbox-model.ts.
+ *
+ * Scoped to appending turns to already-existing threads: the mobile
+ * "pending new-thread task" (offline thread creation) shape is intentionally
+ * omitted, so there is no `creation` payload here. Image attachments are also
+ * omitted because their blob-backed previews cannot survive a reload through
+ * localStorage; queued web messages carry text plus a settings snapshot only.
+ */
+
+const THREAD_OUTBOX_SCHEMA_VERSION = 1;
+const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
+
+export const QueuedThreadMessageSchema = Schema.Struct({
+ schemaVersion: Schema.Literals([THREAD_OUTBOX_SCHEMA_VERSION]),
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ messageId: MessageId,
+ commandId: CommandId,
+ text: Schema.String,
+ modelSelection: Schema.optional(ModelSelection),
+ runtimeMode: Schema.optional(RuntimeMode),
+ interactionMode: Schema.optional(ProviderInteractionMode),
+ createdAt: IsoDateTime,
+});
+
+const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema);
+const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema);
+const QueuedThreadMessageJson = Schema.fromJsonString(QueuedThreadMessageSchema);
+const decodeQueuedThreadMessageJson = Schema.decodeSync(QueuedThreadMessageJson);
+const encodeQueuedThreadMessageJson = Schema.encodeSync(QueuedThreadMessageJson);
+
+/**
+ * Derived from the persisted schema (minus the on-disk `schemaVersion`) so the
+ * in-memory shape and the decoded/encoded shapes stay exactly in sync under
+ * `exactOptionalPropertyTypes`.
+ */
+export type QueuedThreadMessage = Omit;
+
+export interface ThreadSettingsSnapshot {
+ readonly modelSelection: ModelSelectionType;
+ readonly runtimeMode: RuntimeModeType;
+ readonly interactionMode: ProviderInteractionModeType;
+}
+
+export function resolveQueuedThreadSettings(
+ message: QueuedThreadMessage,
+ thread: ThreadSettingsSnapshot,
+): ThreadSettingsSnapshot {
+ return {
+ modelSelection: message.modelSelection ?? thread.modelSelection,
+ runtimeMode: message.runtimeMode ?? thread.runtimeMode,
+ interactionMode: message.interactionMode ?? thread.interactionMode,
+ };
+}
+
+export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean {
+ if (left.instanceId !== right.instanceId || left.model !== right.model) {
+ return false;
+ }
+ const leftOptions = left.options ?? [];
+ const rightOptions = right.options ?? [];
+ if (leftOptions.length !== rightOptions.length) {
+ return false;
+ }
+ return leftOptions.every((option, index) => {
+ const other = rightOptions[index];
+ return other !== undefined && option.id === other.id && option.value === other.value;
+ });
+}
+
+export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown {
+ return encodeStoredQueuedThreadMessage({
+ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION,
+ ...message,
+ });
+}
+
+export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage {
+ const { schemaVersion: _schemaVersion, ...message } = decodeStoredQueuedThreadMessage(value);
+ return message;
+}
+
+export function serializeQueuedThreadMessage(message: QueuedThreadMessage): string {
+ return encodeQueuedThreadMessageJson({
+ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION,
+ ...message,
+ });
+}
+
+export function parseQueuedThreadMessage(value: string): QueuedThreadMessage {
+ const { schemaVersion: _schemaVersion, ...message } = decodeQueuedThreadMessageJson(value);
+ return message;
+}
+
+export function groupQueuedThreadMessages(
+ messages: ReadonlyArray,
+): Record> {
+ const deduplicated = new Map();
+ for (const message of messages) {
+ deduplicated.set(message.messageId, message);
+ }
+
+ const grouped: Record> = {};
+ for (const message of deduplicated.values()) {
+ const threadKey = scopedThreadKey(scopeThreadRef(message.environmentId, message.threadId));
+ (grouped[threadKey] ??= []).push(message);
+ }
+ for (const queue of Object.values(grouped)) {
+ queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
+ }
+ return grouped;
+}
+
+export function flattenQueuedThreadMessages(
+ queues: Record>,
+): ReadonlyArray {
+ return Object.values(queues).flat();
+}
+
+export function threadOutboxRetryDelayMs(attempt: number): number {
+ return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS);
+}
+
+export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send";
+
+export function resolveThreadOutboxDeliveryAction(input: {
+ readonly threadExists: boolean;
+ readonly shellStatus: EnvironmentShellStatus;
+ readonly environmentConnected: boolean;
+ readonly threadBusy: boolean;
+}): ThreadOutboxDeliveryAction {
+ if (!input.threadExists) {
+ // Wait for shell synchronization to complete before discarding: until the
+ // thread list is live a still-syncing environment could simply not have
+ // surfaced the thread yet.
+ return input.shellStatus === "live" ? "remove" : "wait";
+ }
+ return input.environmentConnected && !input.threadBusy ? "send" : "wait";
+}
+
+function errorMessage(error: unknown): string | null {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ if (typeof error === "object" && error !== null && "message" in error) {
+ return typeof error.message === "string" ? error.message : null;
+ }
+ return typeof error === "string" ? error : null;
+}
+
+export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "_tag" in error &&
+ error._tag === "ConnectionTransientError"
+ ) {
+ return true;
+ }
+ return isTransportConnectionErrorMessage(errorMessage(error));
+}
+
+export type ThreadOutboxCommandStage = "settings-sync" | "start-turn";
+export type ThreadOutboxFailureAction = "retry" | "discard";
+
+export function resolveThreadOutboxFailureAction(input: {
+ readonly stage: ThreadOutboxCommandStage;
+ readonly error: unknown;
+ readonly interrupted: boolean;
+}): ThreadOutboxFailureAction {
+ if (
+ input.stage === "settings-sync" ||
+ input.interrupted ||
+ shouldRetryThreadOutboxDelivery(input.error)
+ ) {
+ return "retry";
+ }
+ return "discard";
+}
diff --git a/apps/web/src/outbox/threadOutbox.ts b/apps/web/src/outbox/threadOutbox.ts
new file mode 100644
index 00000000000..81d93a420d3
--- /dev/null
+++ b/apps/web/src/outbox/threadOutbox.ts
@@ -0,0 +1,119 @@
+import { useAtomValue } from "@effect/atom-react";
+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
+import type { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
+import { useMemo } from "react";
+import { Atom } from "effect/unstable/reactivity";
+
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { environmentShell } from "../state/shell";
+import { createThreadOutboxManager } from "./threadOutboxManager";
+import type { QueuedThreadMessage } from "./threadOutbox.logic";
+import { localThreadOutboxStorage } from "./threadOutboxStorage";
+
+const EMPTY_QUEUE: ReadonlyArray = Object.freeze([]);
+
+export const threadOutboxManager = createThreadOutboxManager({
+ registry: appAtomRegistry,
+ storage: localThreadOutboxStorage,
+});
+
+/**
+ * Loads the persisted queue exactly once. The manager caches the load promise,
+ * so repeated calls (e.g. the drain remounting) are cheap.
+ */
+export function ensureThreadOutboxLoaded(): Promise {
+ return threadOutboxManager.load();
+}
+
+export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise {
+ return threadOutboxManager.enqueue(message);
+}
+
+export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise {
+ return threadOutboxManager.update(message);
+}
+
+export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise {
+ return threadOutboxManager.remove(message);
+}
+
+/**
+ * Discards every queued message belonging to an environment. Wired into the
+ * environment-removal cleanup so forgetting a connection also drops its queue.
+ */
+export function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise {
+ return threadOutboxManager.clearEnvironment(environmentId);
+}
+
+/**
+ * Shell status per environment that currently holds queued messages, so the
+ * drain can decide whether a message for a not-yet-visible thread should keep
+ * waiting or be discarded. Mirrors the mobile use-thread-outbox atom.
+ */
+const threadOutboxShellStatusesAtom = Atom.make(
+ (get): ReadonlyMap => {
+ const statuses = new Map();
+ for (const queue of Object.values(get(threadOutboxManager.queuedMessagesByThreadKeyAtom))) {
+ const environmentId = queue[0]?.environmentId;
+ if (environmentId !== undefined && !statuses.has(environmentId)) {
+ statuses.set(environmentId, get(environmentShell.stateValueAtom(environmentId)).status);
+ }
+ }
+ return statuses;
+ },
+).pipe(Atom.withLabel("web:thread-outbox:shell-statuses"));
+
+/**
+ * Queued messages the drain must not deliver right now: any whose inline editor
+ * is open. Editing sessions hold their message id here and release it once the
+ * queued payload is saved back, so the drain never sends stale content.
+ */
+export const editingThreadOutboxMessageIdsAtom = Atom.make>>(
+ {},
+).pipe(Atom.keepAlive, Atom.withLabel("web:thread-outbox:editing-message-ids"));
+
+export function holdEditingThreadOutboxMessage(messageId: MessageId): void {
+ const current = appAtomRegistry.get(editingThreadOutboxMessageIdsAtom);
+ if (current[messageId]) {
+ return;
+ }
+ appAtomRegistry.set(editingThreadOutboxMessageIdsAtom, { ...current, [messageId]: true });
+}
+
+export function releaseEditingThreadOutboxMessage(messageId: MessageId): void {
+ const current = appAtomRegistry.get(editingThreadOutboxMessageIdsAtom);
+ if (!current[messageId]) {
+ return;
+ }
+ const next = { ...current };
+ delete next[messageId];
+ appAtomRegistry.set(editingThreadOutboxMessageIdsAtom, next);
+}
+
+export function useThreadOutboxMessages(): Record> {
+ return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom);
+}
+
+export function useThreadOutboxShellStatuses(): ReadonlyMap {
+ return useAtomValue(threadOutboxShellStatusesAtom);
+}
+
+export function useThreadOutboxEditingIds(): Readonly> {
+ return useAtomValue(editingThreadOutboxMessageIdsAtom);
+}
+
+/** The FIFO-ordered queued messages for a single thread (empty when none). */
+export function useThreadOutboxQueue(
+ environmentId: EnvironmentId | null,
+ threadId: ThreadId | null,
+): ReadonlyArray {
+ const grouped = useThreadOutboxMessages();
+ return useMemo(() => {
+ if (environmentId === null || threadId === null) {
+ return EMPTY_QUEUE;
+ }
+ const key = scopedThreadKey(scopeThreadRef(environmentId, threadId));
+ return grouped[key] ?? EMPTY_QUEUE;
+ }, [grouped, environmentId, threadId]);
+}
diff --git a/apps/web/src/outbox/threadOutboxManager.ts b/apps/web/src/outbox/threadOutboxManager.ts
new file mode 100644
index 00000000000..ae1324a26d6
--- /dev/null
+++ b/apps/web/src/outbox/threadOutboxManager.ts
@@ -0,0 +1,227 @@
+import {
+ EnvironmentId,
+ MessageId,
+ ThreadId,
+ type MessageId as MessageIdType,
+} from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
+
+import {
+ flattenQueuedThreadMessages,
+ groupQueuedThreadMessages,
+ type QueuedThreadMessage,
+} from "./threadOutbox.logic";
+import type { ThreadOutboxStorage } from "./threadOutboxStorage";
+
+/**
+ * Serialized (FIFO promise chain) mutations with storage write-through,
+ * ported from apps/mobile/src/state/thread-outbox-manager.ts.
+ */
+export class ThreadOutboxManagerError extends Schema.TaggedErrorClass()(
+ "ThreadOutboxManagerError",
+ {
+ operation: Schema.Literals([
+ "load",
+ "enqueue",
+ "update",
+ "remove",
+ "clear-environment-load",
+ "clear-environment-remove",
+ ]),
+ environmentId: Schema.NullOr(EnvironmentId),
+ threadId: Schema.NullOr(ThreadId),
+ messageId: Schema.NullOr(MessageId),
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Thread outbox operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}.`;
+ }
+}
+
+export interface ThreadOutboxManagerOptions {
+ readonly registry: AtomRegistry.AtomRegistry;
+ readonly storage: ThreadOutboxStorage;
+ readonly warn?: (message: string, error: unknown) => void;
+}
+
+export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
+ const queuedMessagesByThreadKeyAtom = Atom.make<
+ Record>
+ >({}).pipe(Atom.keepAlive, Atom.withLabel("web:thread-outbox:queued-messages"));
+ const warn =
+ options.warn ??
+ ((message: string, error: unknown) => {
+ console.warn(message, error);
+ });
+ let loadPromise: Promise | null = null;
+ let mutationQueue: Promise = Promise.resolve();
+
+ const serialize = (mutation: () => Promise): Promise => {
+ const result = mutationQueue.then(mutation, mutation);
+ mutationQueue = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+ };
+
+ const currentMessages = (): ReadonlyArray =>
+ flattenQueuedThreadMessages(options.registry.get(queuedMessagesByThreadKeyAtom));
+
+ const setMessages = (messages: ReadonlyArray): void => {
+ options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages));
+ };
+
+ const load = (): Promise => {
+ if (loadPromise !== null) {
+ return loadPromise;
+ }
+ loadPromise = serialize(async () => {
+ const persistedMessages = await options.storage.load();
+ setMessages([...persistedMessages, ...currentMessages()]);
+ }).catch((cause) => {
+ loadPromise = null;
+ warn(
+ "[thread-outbox] failed to load persisted messages",
+ new ThreadOutboxManagerError({
+ operation: "load",
+ environmentId: null,
+ threadId: null,
+ messageId: null,
+ cause,
+ }),
+ );
+ });
+ return loadPromise;
+ };
+
+ const enqueue = (message: QueuedThreadMessage): Promise =>
+ serialize(async () => {
+ try {
+ await options.storage.write(message);
+ } catch (cause) {
+ throw new ThreadOutboxManagerError({
+ operation: "enqueue",
+ environmentId: message.environmentId,
+ threadId: message.threadId,
+ messageId: message.messageId,
+ cause,
+ });
+ }
+ setMessages([
+ ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
+ message,
+ ]);
+ });
+
+ // Rewrites an already-queued message. A no-op when the message has been
+ // removed in the meantime (e.g. deleted or delivered), so a trailing editor
+ // flush can never resurrect it. Returns whether the message was updated.
+ const update = (message: QueuedThreadMessage): Promise =>
+ serialize(async () => {
+ const exists = currentMessages().some(
+ (candidate) => candidate.messageId === message.messageId,
+ );
+ if (!exists) {
+ return false;
+ }
+ try {
+ await options.storage.write(message);
+ } catch (cause) {
+ throw new ThreadOutboxManagerError({
+ operation: "update",
+ environmentId: message.environmentId,
+ threadId: message.threadId,
+ messageId: message.messageId,
+ cause,
+ });
+ }
+ setMessages([
+ ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
+ message,
+ ]);
+ return true;
+ });
+
+ const remove = (message: QueuedThreadMessage): Promise =>
+ serialize(async () => {
+ try {
+ await options.storage.remove(message);
+ } catch (cause) {
+ throw new ThreadOutboxManagerError({
+ operation: "remove",
+ environmentId: message.environmentId,
+ threadId: message.threadId,
+ messageId: message.messageId,
+ cause,
+ });
+ }
+ setMessages(
+ currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
+ );
+ });
+
+ // Discards every queued message for an environment, in memory and on disk.
+ // Called when an environment is removed/forgotten so its queued messages can
+ // never be resurrected (and silently delivered) if the same server — which
+ // reuses the same server-issued environmentId — is later re-added. Mirrors
+ // apps/mobile/src/state/thread-outbox-manager.ts clearEnvironment.
+ const clearEnvironment = (environmentId: EnvironmentId): Promise =>
+ serialize(async () => {
+ const persisted = await options.storage.load().catch((cause) => {
+ warn(
+ "[thread-outbox] failed to load messages while clearing environment",
+ new ThreadOutboxManagerError({
+ operation: "clear-environment-load",
+ environmentId,
+ threadId: null,
+ messageId: null,
+ cause,
+ }),
+ );
+ return [];
+ });
+ const allMessages = flattenQueuedThreadMessages(
+ groupQueuedThreadMessages([...persisted, ...currentMessages()]),
+ );
+ const removedMessageIds = new Set();
+
+ await Promise.all(
+ allMessages
+ .filter((message) => message.environmentId === environmentId)
+ .map(async (message) => {
+ try {
+ await options.storage.remove(message);
+ removedMessageIds.add(message.messageId);
+ } catch (cause) {
+ warn(
+ "[thread-outbox] failed to clear persisted message",
+ new ThreadOutboxManagerError({
+ operation: "clear-environment-remove",
+ environmentId: message.environmentId,
+ threadId: message.threadId,
+ messageId: message.messageId,
+ cause,
+ }),
+ );
+ }
+ }),
+ );
+
+ setMessages(allMessages.filter((message) => !removedMessageIds.has(message.messageId)));
+ });
+
+ return {
+ queuedMessagesByThreadKeyAtom,
+ serialize,
+ load,
+ enqueue,
+ update,
+ remove,
+ clearEnvironment,
+ };
+}
+
+export type ThreadOutboxManager = ReturnType;
diff --git a/apps/web/src/outbox/threadOutboxStorage.ts b/apps/web/src/outbox/threadOutboxStorage.ts
new file mode 100644
index 00000000000..f9dc585973d
--- /dev/null
+++ b/apps/web/src/outbox/threadOutboxStorage.ts
@@ -0,0 +1,136 @@
+import type { MessageId } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+
+import {
+ parseQueuedThreadMessage,
+ serializeQueuedThreadMessage,
+ type QueuedThreadMessage,
+} from "./threadOutbox.logic";
+
+/**
+ * localStorage-backed persistence for the thread outbox, mirroring the mobile
+ * expo-file-system store (apps/mobile/src/state/thread-outbox-storage.ts) but
+ * writing one localStorage key per queued message so a single corrupt entry is
+ * skipped rather than dropping the whole queue ("skip invalid, never fatal").
+ */
+
+const THREAD_OUTBOX_KEY_PREFIX = "t3code:thread-outbox:v1:";
+
+export class ThreadOutboxStorageError extends Schema.TaggedErrorClass()(
+ "ThreadOutboxStorageError",
+ {
+ operation: Schema.Literals(["load", "read-message", "write", "remove"]),
+ environmentId: Schema.NullOr(Schema.String),
+ threadId: Schema.NullOr(Schema.String),
+ messageId: Schema.NullOr(Schema.String),
+ storageKey: Schema.NullOr(Schema.String),
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Thread outbox storage operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}, key ${this.storageKey ?? "unknown"}.`;
+ }
+}
+
+export interface ThreadOutboxStorage {
+ readonly load: () => Promise>;
+ readonly write: (message: QueuedThreadMessage) => Promise;
+ readonly remove: (message: QueuedThreadMessage) => Promise;
+}
+
+function storageKeyFor(messageId: MessageId): string {
+ return `${THREAD_OUTBOX_KEY_PREFIX}${encodeURIComponent(messageId)}`;
+}
+
+function browserStorage(): Storage | null {
+ return typeof window !== "undefined" ? window.localStorage : null;
+}
+
+export const localThreadOutboxStorage: ThreadOutboxStorage = {
+ load: async () => {
+ const storage = browserStorage();
+ if (storage === null) {
+ return [];
+ }
+ const messages: QueuedThreadMessage[] = [];
+ let keys: string[];
+ try {
+ keys = [];
+ for (let index = 0; index < storage.length; index += 1) {
+ const key = storage.key(index);
+ if (key !== null && key.startsWith(THREAD_OUTBOX_KEY_PREFIX)) {
+ keys.push(key);
+ }
+ }
+ } catch (cause) {
+ throw new ThreadOutboxStorageError({
+ operation: "load",
+ environmentId: null,
+ threadId: null,
+ messageId: null,
+ storageKey: null,
+ cause,
+ });
+ }
+ for (const key of keys) {
+ try {
+ const raw = storage.getItem(key);
+ if (raw === null) {
+ continue;
+ }
+ messages.push(parseQueuedThreadMessage(raw));
+ } catch (cause) {
+ console.warn(
+ "[thread-outbox] ignored invalid persisted message",
+ new ThreadOutboxStorageError({
+ operation: "read-message",
+ environmentId: null,
+ threadId: null,
+ messageId: null,
+ storageKey: key,
+ cause,
+ }),
+ );
+ }
+ }
+ return messages;
+ },
+ write: async (message) => {
+ const storage = browserStorage();
+ if (storage === null) {
+ return;
+ }
+ const key = storageKeyFor(message.messageId);
+ try {
+ storage.setItem(key, serializeQueuedThreadMessage(message));
+ } catch (cause) {
+ throw new ThreadOutboxStorageError({
+ operation: "write",
+ environmentId: message.environmentId,
+ threadId: message.threadId,
+ messageId: message.messageId,
+ storageKey: key,
+ cause,
+ });
+ }
+ },
+ remove: async (message) => {
+ const storage = browserStorage();
+ if (storage === null) {
+ return;
+ }
+ const key = storageKeyFor(message.messageId);
+ try {
+ storage.removeItem(key);
+ } catch (cause) {
+ throw new ThreadOutboxStorageError({
+ operation: "remove",
+ environmentId: message.environmentId,
+ threadId: message.threadId,
+ messageId: message.messageId,
+ storageKey: key,
+ cause,
+ });
+ }
+ },
+};
diff --git a/apps/web/src/outbox/useThreadOutboxDrain.ts b/apps/web/src/outbox/useThreadOutboxDrain.ts
new file mode 100644
index 00000000000..e5a78b9b1a9
--- /dev/null
+++ b/apps/web/src/outbox/useThreadOutboxDrain.ts
@@ -0,0 +1,366 @@
+import { useAtomValue } from "@effect/atom-react";
+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
+import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
+import { CommandId, type EnvironmentId, type MessageId } from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { useThreadShells } from "../state/entities";
+import { useEnvironments } from "../state/environments";
+import { threadEnvironment } from "../state/threads";
+import { useAtomCommand } from "../state/use-atom-command";
+import {
+ ensureThreadOutboxLoaded,
+ removeThreadOutboxMessage,
+ useThreadOutboxEditingIds,
+ useThreadOutboxMessages,
+ useThreadOutboxShellStatuses,
+} from "./threadOutbox";
+import {
+ modelSelectionsEqual,
+ resolveQueuedThreadSettings,
+ resolveThreadOutboxDeliveryAction,
+ resolveThreadOutboxFailureAction,
+ threadOutboxRetryDelayMs,
+ type QueuedThreadMessage,
+ type ThreadOutboxCommandStage,
+} from "./threadOutbox.logic";
+
+/**
+ * While messages are queued, re-run the drain on this cadence so the time-based
+ * idle-gate (`canSettle`, whose queued-turn-start grace window opens purely by
+ * elapsed time with no atom change to signal it) is re-evaluated instead of a
+ * head stalling until some unrelated re-render happens to fire.
+ */
+const THREAD_OUTBOX_DRAIN_HEARTBEAT_MS = 5_000;
+
+/**
+ * The drain engine, ported from apps/mobile/src/state/use-thread-outbox-drain.ts.
+ *
+ * A single global in-flight lock, picks the first eligible head per thread,
+ * sends one-at-a-time, removes on success, retries transient failures with
+ * backoff and discards deterministic ones. The "idle" gate reuses the
+ * client-runtime `canSettle` predicate so a just-adopted turn (still invisible
+ * to session status) can never trigger a double send.
+ */
+export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe(
+ Atom.keepAlive,
+ Atom.withLabel("web:thread-outbox:dispatching-message-id"),
+);
+
+function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
+ appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
+}
+
+function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void {
+ const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom);
+ appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current);
+}
+
+function findThread(
+ threads: ReadonlyArray,
+ message: QueuedThreadMessage,
+): EnvironmentThreadShell | undefined {
+ return threads.find(
+ (candidate) =>
+ candidate.environmentId === message.environmentId && candidate.id === message.threadId,
+ );
+}
+
+function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId {
+ return CommandId.make(`${message.commandId}:${setting}`);
+}
+
+export function useThreadOutboxDrain(): void {
+ const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
+ const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
+ reportFailure: false,
+ });
+ const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {
+ reportFailure: false,
+ });
+ const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, {
+ reportFailure: false,
+ });
+ const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
+ const editingQueuedMessageIds = useThreadOutboxEditingIds();
+ const queuedMessagesByThreadKey = useThreadOutboxMessages();
+ const shellStatuses = useThreadOutboxShellStatuses();
+ const threads = useThreadShells();
+ const { environments } = useEnvironments();
+ const [retryTick, setRetryTick] = useState(0);
+ const retryAttemptRef = useRef(new Map());
+ const retryNotBeforeRef = useRef(new Map());
+ const retryTimersRef = useRef(new Map>());
+
+ useEffect(() => {
+ void ensureThreadOutboxLoaded();
+ const timers = retryTimersRef.current;
+ return () => {
+ for (const timer of timers.values()) {
+ clearTimeout(timer);
+ }
+ timers.clear();
+ };
+ }, []);
+
+ const makeDeliveryHelpers = useCallback((queuedMessage: QueuedThreadMessage) => {
+ const reportFailure = (
+ commandResult: AtomCommandResult,
+ stage: ThreadOutboxCommandStage,
+ ): boolean => {
+ if (!AsyncResult.isFailure(commandResult)) {
+ return false;
+ }
+ const action = resolveThreadOutboxFailureAction({
+ stage,
+ error: Cause.squash(commandResult.cause),
+ interrupted: Cause.hasInterruptsOnly(commandResult.cause),
+ });
+ const retry = action === "retry";
+ console.warn("[thread-outbox] queued message delivery failed", {
+ environmentId: queuedMessage.environmentId,
+ threadId: queuedMessage.threadId,
+ messageId: queuedMessage.messageId,
+ stage,
+ cause: commandResult.cause,
+ retry,
+ });
+ return retry;
+ };
+ const completeDelivery = async (
+ deliveryResult: AtomCommandResult,
+ ): Promise => {
+ if (reportFailure(deliveryResult, "start-turn")) {
+ return false;
+ }
+
+ try {
+ await removeThreadOutboxMessage(queuedMessage);
+ return true;
+ } catch (error) {
+ console.warn("[thread-outbox] failed to remove delivered queued message", {
+ environmentId: queuedMessage.environmentId,
+ threadId: queuedMessage.threadId,
+ messageId: queuedMessage.messageId,
+ error,
+ });
+ return false;
+ }
+ };
+ return { reportFailure, completeDelivery };
+ }, []);
+
+ const sendQueuedMessage = useCallback(
+ async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
+ const settings = resolveQueuedThreadSettings(queuedMessage, thread);
+ const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage);
+
+ if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
+ const updateResult = await updateThreadMetadata({
+ environmentId: queuedMessage.environmentId,
+ input: {
+ commandId: settingsCommandId(queuedMessage, "model-selection"),
+ threadId: queuedMessage.threadId,
+ modelSelection: settings.modelSelection,
+ },
+ });
+ if (AsyncResult.isFailure(updateResult)) {
+ reportFailure(updateResult, "settings-sync");
+ return false;
+ }
+ }
+
+ if (settings.runtimeMode !== thread.runtimeMode) {
+ const runtimeResult = await setThreadRuntimeMode({
+ environmentId: queuedMessage.environmentId,
+ input: {
+ commandId: settingsCommandId(queuedMessage, "runtime-mode"),
+ threadId: queuedMessage.threadId,
+ runtimeMode: settings.runtimeMode,
+ createdAt: queuedMessage.createdAt,
+ },
+ });
+ if (AsyncResult.isFailure(runtimeResult)) {
+ reportFailure(runtimeResult, "settings-sync");
+ return false;
+ }
+ }
+
+ if (settings.interactionMode !== thread.interactionMode) {
+ const interactionResult = await setThreadInteractionMode({
+ environmentId: queuedMessage.environmentId,
+ input: {
+ commandId: settingsCommandId(queuedMessage, "interaction-mode"),
+ threadId: queuedMessage.threadId,
+ interactionMode: settings.interactionMode,
+ createdAt: queuedMessage.createdAt,
+ },
+ });
+ if (AsyncResult.isFailure(interactionResult)) {
+ reportFailure(interactionResult, "settings-sync");
+ return false;
+ }
+ }
+
+ const deliveryResult = await startTurn({
+ environmentId: queuedMessage.environmentId,
+ input: {
+ commandId: queuedMessage.commandId,
+ threadId: queuedMessage.threadId,
+ message: {
+ messageId: queuedMessage.messageId,
+ role: "user",
+ text: queuedMessage.text,
+ attachments: [],
+ },
+ modelSelection: settings.modelSelection,
+ runtimeMode: settings.runtimeMode,
+ interactionMode: settings.interactionMode,
+ createdAt: queuedMessage.createdAt,
+ },
+ });
+ return completeDelivery(deliveryResult);
+ },
+ [
+ makeDeliveryHelpers,
+ setThreadInteractionMode,
+ setThreadRuntimeMode,
+ startTurn,
+ updateThreadMetadata,
+ ],
+ );
+
+ // Heartbeat: the `canSettle` gate can hinge on elapsed time alone, which no
+ // dependency below changes. While anything is queued, tick periodically so a
+ // head held back only by that time-gate is delivered promptly once its grace
+ // window elapses, rather than waiting for an incidental state change.
+ useEffect(() => {
+ const hasQueued = Object.values(queuedMessagesByThreadKey).some((queue) => queue.length > 0);
+ if (!hasQueued) {
+ return;
+ }
+ const interval = setInterval(() => {
+ setRetryTick((current) => current + 1);
+ }, THREAD_OUTBOX_DRAIN_HEARTBEAT_MS);
+ return () => {
+ clearInterval(interval);
+ };
+ }, [queuedMessagesByThreadKey]);
+
+ useEffect(() => {
+ if (dispatchingQueuedMessageId !== null) {
+ return;
+ }
+
+ const connectedEnvironmentIds = new Set(
+ environments
+ .filter((environment) => environment.connection.phase === "connected")
+ .map((environment) => environment.environmentId),
+ );
+ const nowIso = new Date().toISOString();
+
+ for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) {
+ const nextQueuedMessage = queuedMessages[0];
+ if (!nextQueuedMessage) {
+ continue;
+ }
+ if (editingQueuedMessageIds[nextQueuedMessage.messageId]) {
+ continue;
+ }
+ if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) {
+ continue;
+ }
+
+ const thread = findThread(threads, nextQueuedMessage);
+ if (
+ thread &&
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) !== threadKey
+ ) {
+ continue;
+ }
+
+ const shellStatus = shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty";
+ const deliveryAction = resolveThreadOutboxDeliveryAction({
+ threadExists: thread !== undefined,
+ shellStatus,
+ environmentConnected: connectedEnvironmentIds.has(nextQueuedMessage.environmentId),
+ threadBusy: thread !== undefined ? !canSettle(thread, { now: nowIso }) : true,
+ });
+ if (deliveryAction === "wait") {
+ continue;
+ }
+
+ beginDispatchingQueuedMessage(nextQueuedMessage.messageId);
+ const removeQueuedMessage = (warning: string) =>
+ removeThreadOutboxMessage(nextQueuedMessage).then(
+ () => true,
+ (error) => {
+ console.warn(warning, {
+ environmentId: nextQueuedMessage.environmentId,
+ threadId: nextQueuedMessage.threadId,
+ messageId: nextQueuedMessage.messageId,
+ error,
+ });
+ return false;
+ },
+ );
+ const delivery =
+ deliveryAction === "remove"
+ ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
+ : thread !== undefined
+ ? sendQueuedMessage(nextQueuedMessage, thread)
+ : Promise.resolve(false);
+ void delivery
+ .then((sent) => {
+ if (sent) {
+ retryAttemptRef.current.delete(nextQueuedMessage.messageId);
+ retryNotBeforeRef.current.delete(nextQueuedMessage.messageId);
+ const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId);
+ if (pendingTimer !== undefined) {
+ clearTimeout(pendingTimer);
+ retryTimersRef.current.delete(nextQueuedMessage.messageId);
+ }
+ return;
+ }
+
+ const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1;
+ retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt);
+ const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt);
+ retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs);
+ const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId);
+ if (pendingTimer !== undefined) {
+ clearTimeout(pendingTimer);
+ }
+ const retryTimer = setTimeout(() => {
+ retryTimersRef.current.delete(nextQueuedMessage.messageId);
+ setRetryTick((current) => current + 1);
+ }, retryDelayMs);
+ retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer);
+ })
+ .finally(() => {
+ finishDispatchingQueuedMessage(nextQueuedMessage.messageId);
+ });
+ return;
+ }
+ }, [
+ dispatchingQueuedMessageId,
+ editingQueuedMessageIds,
+ environments,
+ queuedMessagesByThreadKey,
+ retryTick,
+ sendQueuedMessage,
+ shellStatuses,
+ threads,
+ ]);
+}
+
+/** Mounted once (near NotificationCoordinator) to run the drain for the app. */
+export function ThreadOutboxDrain(): null {
+ useThreadOutboxDrain();
+ return null;
+}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index b35a4794078..13f35707a12 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -18,6 +18,7 @@ import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDi
import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
import { NotificationCoordinator } from "../components/NotificationCoordinator";
+import { ThreadOutboxDrain } from "../outbox/useThreadOutboxDrain";
import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
import { Button } from "../components/ui/button";
@@ -138,6 +139,7 @@ function RootRouteView() {
{primaryEnvironmentAuthenticated ? : null}
{primaryEnvironmentAuthenticated ? : null}
+ {primaryEnvironmentAuthenticated ? : null}
{appShell}