,
+): boolean {
+ return result._tag === "Failure" && !isAtomCommandInterrupted(result);
+}
diff --git a/apps/web/src/components/WebThreadOutboxDrain.tsx b/apps/web/src/components/WebThreadOutboxDrain.tsx
new file mode 100644
index 000000000000..c1ab4a62873d
--- /dev/null
+++ b/apps/web/src/components/WebThreadOutboxDrain.tsx
@@ -0,0 +1,195 @@
+import { CommandId } from "@t3tools/contracts";
+import { useEffect, useMemo, useState } from "react";
+
+import { resolveThreadMetadataUpdateForNextTurn } from "./ChatView.logic";
+import { shouldPauseWebThreadOutboxDelivery } from "./WebThreadOutboxDrain.logic";
+import { stackedThreadToast, toastManager } from "./ui/toast";
+import { useEnvironments } from "../state/environments";
+import { useThreadShells } from "../state/entities";
+import { environmentPresentations } from "../state/presentation";
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { environmentThreadShells, threadEnvironment } from "../state/threads";
+import { useAtomCommand } from "../state/use-atom-command";
+import {
+ beginWebThreadOutboxDispatch,
+ finishWebThreadOutboxDispatch,
+ shouldDrainWebThreadOutbox,
+ useWebThreadOutboxStore,
+} from "../webThreadOutbox";
+
+function settingsCommandId(commandId: CommandId, setting: string): CommandId {
+ return CommandId.make(`${commandId}:${setting}`);
+}
+
+export function WebThreadOutboxDrain() {
+ const queuesByThreadKey = useWebThreadOutboxStore((state) => state.queuesByThreadKey);
+ const pausedMessageIds = useWebThreadOutboxStore((state) => state.pausedMessageIds);
+ const threads = useThreadShells();
+ const { environments } = useEnvironments();
+ const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
+ reportFailure: false,
+ });
+ const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {
+ reportFailure: false,
+ });
+ const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, {
+ reportFailure: false,
+ });
+ const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
+ const [drainTick, setDrainTick] = useState(0);
+
+ const nextDelivery = useMemo(() => {
+ const threadByKey = new Map(
+ threads.map((thread) => [`${thread.environmentId}:${thread.id}`, thread] as const),
+ );
+ const environmentById = new Map(
+ environments.map((environment) => [environment.environmentId, environment] as const),
+ );
+ const heads = Object.values(queuesByThreadKey)
+ .flatMap((queue) => (queue[0] ? [queue[0]] : []))
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
+
+ for (const message of heads) {
+ const thread = threadByKey.get(`${message.environmentId}:${message.threadId}`);
+ if (!thread) continue;
+ const environment = environmentById.get(message.environmentId);
+ if (
+ shouldDrainWebThreadOutbox({
+ sessionStatus: thread.session?.status ?? null,
+ environmentConnected: environment?.connection.phase === "connected",
+ paused: Boolean(pausedMessageIds[message.messageId]),
+ activeTurnMessageBehavior: message.activeTurnMessageBehavior,
+ })
+ ) {
+ return { message, thread };
+ }
+ }
+ return null;
+ }, [drainTick, environments, pausedMessageIds, queuesByThreadKey, threads]);
+
+ useEffect(() => {
+ if (!nextDelivery || !beginWebThreadOutboxDispatch(nextDelivery.message.messageId)) {
+ return;
+ }
+ const { message, thread } = nextDelivery;
+
+ const deliver = async () => {
+ const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({
+ currentModelSelection: thread.modelSelection,
+ nextModelSelection: message.modelSelection,
+ currentBranch: thread.branch,
+ });
+ if (metadataUpdate) {
+ const result = await updateThreadMetadata({
+ environmentId: message.environmentId,
+ input: {
+ commandId: settingsCommandId(message.commandId, "model-selection"),
+ threadId: message.threadId,
+ ...metadataUpdate,
+ },
+ });
+ if (result._tag === "Failure") return result;
+ }
+
+ if (message.runtimeMode !== thread.runtimeMode) {
+ const result = await setThreadRuntimeMode({
+ environmentId: message.environmentId,
+ input: {
+ commandId: settingsCommandId(message.commandId, "runtime-mode"),
+ threadId: message.threadId,
+ runtimeMode: message.runtimeMode,
+ createdAt: message.createdAt,
+ },
+ });
+ if (result._tag === "Failure") return result;
+ }
+
+ if (message.interactionMode !== thread.interactionMode) {
+ const result = await setThreadInteractionMode({
+ environmentId: message.environmentId,
+ input: {
+ commandId: settingsCommandId(message.commandId, "interaction-mode"),
+ threadId: message.threadId,
+ interactionMode: message.interactionMode,
+ createdAt: message.createdAt,
+ },
+ });
+ if (result._tag === "Failure") return result;
+ }
+
+ const freshThread = appAtomRegistry
+ .get(environmentThreadShells.threadShellsAtom)
+ .find(
+ (candidate) =>
+ candidate.environmentId === message.environmentId && candidate.id === message.threadId,
+ );
+ const freshEnvironment = appAtomRegistry.get(
+ environmentPresentations.presentationAtom(message.environmentId),
+ );
+ if (
+ !freshThread ||
+ !shouldDrainWebThreadOutbox({
+ sessionStatus: freshThread.session?.status ?? null,
+ environmentConnected: freshEnvironment?.connection.phase === "connected",
+ paused: Boolean(useWebThreadOutboxStore.getState().pausedMessageIds[message.messageId]),
+ activeTurnMessageBehavior: message.activeTurnMessageBehavior,
+ })
+ ) {
+ return { _tag: "Deferred" as const };
+ }
+
+ return startThreadTurn({
+ environmentId: message.environmentId,
+ input: {
+ commandId: message.commandId,
+ threadId: message.threadId,
+ message: {
+ messageId: message.messageId,
+ role: "user",
+ text: message.text,
+ attachments: message.attachments,
+ },
+ modelSelection: message.modelSelection,
+ titleSeed: thread.title,
+ runtimeMode: message.runtimeMode,
+ interactionMode: message.interactionMode,
+ createdAt: message.createdAt,
+ },
+ });
+ };
+
+ void deliver()
+ .then((result) => {
+ if (result._tag === "Deferred") return;
+ if (result._tag === "Failure") {
+ if (!shouldPauseWebThreadOutboxDelivery(result)) return;
+ useWebThreadOutboxStore.getState().pause(message.messageId);
+ toastManager.add(
+ stackedThreadToast({
+ type: "warning",
+ title: "Queued delivery paused",
+ description: "Open the thread and retry when the connection is ready.",
+ }),
+ );
+ return;
+ }
+ useWebThreadOutboxStore.getState().remove(message);
+ })
+ .catch((error: unknown) => {
+ console.error("[THREAD-OUTBOX] Queued delivery failed unexpectedly.", error);
+ useWebThreadOutboxStore.getState().pause(message.messageId);
+ })
+ .finally(() => {
+ finishWebThreadOutboxDispatch(message.messageId);
+ setDrainTick((current) => current + 1);
+ });
+ }, [
+ nextDelivery,
+ setThreadInteractionMode,
+ setThreadRuntimeMode,
+ startThreadTurn,
+ updateThreadMetadata,
+ ]);
+
+ return null;
+}
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index b8bacf4b6be2..7b83fbdbcf3c 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -406,6 +406,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isConnecting: boolean;
isEnvironmentUnavailable: boolean;
hasSendableContent: boolean;
+ activeTurnMessageBehavior: UnifiedSettings["activeTurnMessageBehavior"];
preserveComposerFocusOnPointerDown?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
@@ -434,6 +435,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isEnvironmentUnavailable={props.isEnvironmentUnavailable}
isPreparingWorktree={props.isPreparingWorktree}
hasSendableContent={props.hasSendableContent}
+ activeTurnMessageBehavior={props.activeTurnMessageBehavior}
preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
onPreviousPendingQuestion={props.onPreviousPendingQuestion}
onInterrupt={props.onInterrupt}
@@ -512,6 +514,8 @@ export interface ChatComposerProps {
isSendBusy: boolean;
sendDisabledReason: string | null;
isPreparingWorktree: boolean;
+ queuedMessageCount: number;
+ queuedMessagesPaused: boolean;
environmentUnavailable: {
readonly label: string;
readonly connection: EnvironmentConnectionPresentation;
@@ -567,6 +571,7 @@ export interface ChatComposerProps {
// Callbacks
onSend: (e?: { preventDefault: () => void }) => void;
+ onRetryQueuedMessages: () => void;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
onRespondToApproval: (
@@ -619,6 +624,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isSendBusy,
sendDisabledReason,
isPreparingWorktree,
+ queuedMessageCount,
+ queuedMessagesPaused,
environmentUnavailable,
activePendingApproval,
pendingApprovals,
@@ -649,6 +656,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerTerminalContextsRef,
composerElementContextsRef,
onSend,
+ onRetryQueuedMessages,
onInterrupt,
onImplementPlanInNewThread,
onRespondToApproval,
@@ -1143,6 +1151,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
+ const isPrimarySendBusy =
+ isSendBusy && !(phase === "running" && settings.activeTurnMessageBehavior === "queue");
const composerFooterActionLayoutKey = useMemo(() => {
if (activePendingProgress) {
return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
@@ -1232,15 +1242,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
[activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
);
const collapsedComposerPrimaryActionDisabled =
- phase === "running" ||
- isSendBusy ||
+ isPrimarySendBusy ||
isSendDisabled ||
isConnecting ||
noProviderAvailable ||
projectSelectionRequired ||
environmentUnavailable !== null ||
!composerSendState.hasSendableContent;
- const collapsedComposerPrimaryActionLabel = "Send message";
+ const collapsedComposerPrimaryActionLabel =
+ phase === "running"
+ ? settings.activeTurnMessageBehavior === "queue"
+ ? "Queue message"
+ : "Steer active turn"
+ : "Send message";
const showMobilePendingAnswerActions =
isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
@@ -2793,6 +2807,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
isPreparingWorktree={false}
hasSendableContent={false}
+ activeTurnMessageBehavior={settings.activeTurnMessageBehavior}
preserveComposerFocusOnPointerDown
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
@@ -3076,6 +3091,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
isPreparingWorktree={false}
hasSendableContent={false}
+ activeTurnMessageBehavior={settings.activeTurnMessageBehavior}
preserveComposerFocusOnPointerDown
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
@@ -3190,7 +3206,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isRunning={phase === "running"}
showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}
promptHasText={prompt.trim().length > 0}
- isSendBusy={isSendBusy}
+ isSendBusy={isPrimarySendBusy}
sendDisabledReason={sendDisabledReason}
isConnecting={isConnecting}
isEnvironmentUnavailable={
@@ -3200,6 +3216,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
isPreparingWorktree={isPreparingWorktree}
hasSendableContent={composerSendState.hasSendableContent}
+ activeTurnMessageBehavior={settings.activeTurnMessageBehavior}
preserveComposerFocusOnPointerDown={isMobileViewport}
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
@@ -3208,6 +3225,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)}
+ {queuedMessageCount > 0 ? (
+
+
+ {queuedMessageCount} queued message{queuedMessageCount === 1 ? "" : "s"} will send
+ one at a time.
+
+ {queuedMessagesPaused ? (
+
+ ) : null}
+
+ ) : null}
diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts
index ba416e9fce30..e279f75d7380 100644
--- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts
+++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts
@@ -32,6 +32,7 @@ function renderPendingActions(isRunning: boolean) {
isEnvironmentUnavailable: false,
isPreparingWorktree: false,
hasSendableContent: false,
+ activeTurnMessageBehavior: "steer",
onPreviousPendingQuestion: () => {},
onInterrupt: () => {},
onImplementPlanInNewThread: () => {},
@@ -53,6 +54,7 @@ function renderStandaloneStop() {
isEnvironmentUnavailable: false,
isPreparingWorktree: false,
hasSendableContent: false,
+ activeTurnMessageBehavior: "steer",
onPreviousPendingQuestion: () => {},
onInterrupt: () => {},
onImplementPlanInNewThread: () => {},
diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
index 52d2556bbf90..2d135e4c1481 100644
--- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx
+++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
@@ -4,6 +4,7 @@ import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { Spinner } from "../ui/spinner";
+import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings";
interface PendingActionState {
questionIndex: number;
@@ -25,6 +26,7 @@ interface ComposerPrimaryActionsProps {
isEnvironmentUnavailable: boolean;
isPreparingWorktree: boolean;
hasSendableContent: boolean;
+ activeTurnMessageBehavior: ActiveTurnMessageBehavior;
preserveComposerFocusOnPointerDown?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
@@ -65,6 +67,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
isEnvironmentUnavailable,
isPreparingWorktree,
hasSendableContent,
+ activeTurnMessageBehavior,
preserveComposerFocusOnPointerDown = false,
onPreviousPendingQuestion,
onInterrupt,
@@ -147,8 +150,62 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
);
}
+ const sendButton = (
+
+ );
+
if (isRunning) {
- return renderStopGenerationButton(false);
+ return (
+
+ {renderStopGenerationButton(false)}
+ {sendButton}
+
+ );
}
if (showPlanFollowUpPrompt) {
@@ -208,48 +265,5 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
);
}
- return (
-
- );
+ return sendButton;
});
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index c987ef64299d..53671c30cd7c 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -17,6 +17,8 @@ import {
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import {
+ type ActiveTurnMessageBehavior,
+ DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR,
DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
DEFAULT_UNIFIED_SETTINGS,
type EnvironmentIdentificationMode,
@@ -459,6 +461,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
+ ...(settings.activeTurnMessageBehavior !== DEFAULT_UNIFIED_SETTINGS.activeTurnMessageBehavior
+ ? ["Messages while working"]
+ : []),
...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
? ["Visible threads"]
: []),
@@ -514,6 +519,8 @@ export function useSettingsRestore(onRestored?: () => void) {
[
isTextGenerationModelDirty,
isBackgroundActivityDirty,
+ settings.autoOpenPlanSidebar,
+ settings.activeTurnMessageBehavior,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
@@ -605,6 +612,7 @@ export function useSettingsRestore(onRestored?: () => void) {
}
updateSettings({
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
+ activeTurnMessageBehavior: DEFAULT_UNIFIED_SETTINGS.activeTurnMessageBehavior,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
@@ -1722,6 +1730,52 @@ export function GeneralSettingsPanel() {
return (
+
+ updateSettings({
+ activeTurnMessageBehavior: DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
{
it("matches normalized title substrings", () => {
expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
- expect(searchSettings("work").map((item) => item.id)).toEqual(["project-new-thread-workspace"]);
- expect(searchSettings("xyzzy")).toEqual([]);
+ expect(searchSettings("work").map((item) => item.id)).toEqual(["messages-while-working"]);
});
it("keeps catalog order for multiple title matches", () => {
@@ -66,6 +65,10 @@ describe("searchSettings", () => {
});
it("serves anchor props to panels from the catalog", () => {
+ expect(searchableSetting("messages-while-working")).toEqual({
+ id: "messages-while-working",
+ title: "Messages while working",
+ });
expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" });
expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" });
});
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index f2cd5ec34195..d33017e33203 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -95,6 +95,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Word wrap",
to: "/settings/appearance",
},
+ {
+ id: "messages-while-working",
+ title: "Messages while working",
+ to: "/settings/general",
+ },
{
id: "project-grouping",
title: "Project grouping",
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts
index c127dfba175e..de640301658d 100644
--- a/apps/web/src/composerDraftStore.test.ts
+++ b/apps/web/src/composerDraftStore.test.ts
@@ -287,6 +287,30 @@ describe("composerDraftStore clearComposerContent", () => {
expect(draft).toBeUndefined();
expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic");
});
+
+ it("restores a failed send ahead of content typed after the send started", () => {
+ const sentImage = makeImage({ id: "img-sent", previewUrl: "blob:sent" });
+ const nextImage = makeImage({ id: "img-next", previewUrl: "blob:next" });
+ const store = useComposerDraftStore.getState();
+
+ store.setPrompt(threadRef, "first message");
+ store.addImage(threadRef, sentImage);
+ store.clearComposerContent(threadRef);
+ store.setPrompt(threadRef, "next message");
+ store.addImage(threadRef, nextImage);
+ store.restoreComposerContent(threadRef, {
+ prompt: "first message",
+ images: [sentImage],
+ terminalContexts: [],
+ elementContexts: [],
+ previewAnnotations: [],
+ reviewComments: [],
+ });
+
+ const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
+ expect(draft?.prompt).toBe("first message\n\nnext message");
+ expect(draft?.images.map((image) => image.id)).toEqual(["img-sent", "img-next"]);
+ });
});
describe("composerDraftStore syncPersistedAttachments", () => {
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index ebafd3b04d29..1a0e269f1ee9 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -277,6 +277,16 @@ export interface ComposerThreadDraftState {
interactionMode: ProviderInteractionMode | null;
}
+export type ComposerDraftContentSnapshot = Pick<
+ ComposerThreadDraftState,
+ | "prompt"
+ | "images"
+ | "terminalContexts"
+ | "elementContexts"
+ | "previewAnnotations"
+ | "reviewComments"
+>;
+
/**
* True when the user has invested real content in the draft: typed text or
* any attachment/context. Model selection and mode choices alone do not
@@ -520,6 +530,10 @@ interface ComposerDraftStoreState {
attachments: PersistedComposerImageAttachment[],
) => void;
clearComposerContent: (threadRef: ComposerThreadTarget) => void;
+ restoreComposerContent: (
+ threadRef: ComposerThreadTarget,
+ snapshot: ComposerDraftContentSnapshot,
+ ) => void;
/**
* Clears only the prompt text and image attachments, preserving terminal /
* element contexts, preview annotations, and review comments. Used by the
@@ -3445,6 +3459,47 @@ const composerDraftStore = create()(
return { draftsByThreadKey: nextDraftsByThreadKey };
});
},
+ restoreComposerContent: (threadRef, snapshot) => {
+ const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
+ if (threadKey.length === 0) {
+ return;
+ }
+ set((state) => {
+ const current = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
+ const promptSeparator =
+ snapshot.prompt.length > 0 && current.prompt.length > 0 ? "\n\n" : "";
+ const mergeById = (
+ first: ReadonlyArray,
+ second: ReadonlyArray,
+ ): T[] => {
+ const seen = new Set();
+ return [...first, ...second].filter((entry) => {
+ if (seen.has(entry.id)) return false;
+ seen.add(entry.id);
+ return true;
+ });
+ };
+ const images = mergeById(snapshot.images, current.images);
+ const nextDraft: ComposerThreadDraftState = {
+ ...current,
+ prompt: `${snapshot.prompt}${promptSeparator}${current.prompt}`,
+ images,
+ terminalContexts: mergeById(snapshot.terminalContexts, current.terminalContexts),
+ elementContexts: mergeById(snapshot.elementContexts, current.elementContexts),
+ previewAnnotations: mergeById(
+ snapshot.previewAnnotations,
+ current.previewAnnotations,
+ ),
+ reviewComments: mergeById(snapshot.reviewComments, current.reviewComments),
+ };
+ return {
+ draftsByThreadKey: {
+ ...state.draftsByThreadKey,
+ [threadKey]: nextDraft,
+ },
+ };
+ });
+ },
clearComposerPromptAndImages: (threadRef) => {
const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
if (threadKey.length === 0) {
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index cff930539044..71f8ac012d9a 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 { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
+import { WebThreadOutboxDrain } from "../components/WebThreadOutboxDrain";
import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
import { Button } from "../components/ui/button";
@@ -137,6 +138,7 @@ function RootRouteView() {
+
{primaryEnvironmentAuthenticated ? : null}
{primaryEnvironmentAuthenticated ? : null}
{appShell}
diff --git a/apps/web/src/webThreadOutbox.test.ts b/apps/web/src/webThreadOutbox.test.ts
new file mode 100644
index 000000000000..7a2d69f09af0
--- /dev/null
+++ b/apps/web/src/webThreadOutbox.test.ts
@@ -0,0 +1,252 @@
+import {
+ CommandId,
+ EnvironmentId,
+ MessageId,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import {
+ beginWebThreadOutboxDispatch,
+ finishWebThreadOutboxDispatch,
+ shouldDrainWebThreadOutbox,
+ shouldQueueWebThreadMessage,
+ useWebThreadOutboxStore,
+ webThreadOutboxKey,
+ writeWebThreadOutboxEntryForTest,
+ writeWebThreadOutboxStorageForTest,
+ type QueuedWebThreadMessage,
+} from "./webThreadOutbox";
+
+const environmentId = EnvironmentId.make("environment-test");
+const threadId = ThreadId.make("thread-test");
+
+function message(index: number): QueuedWebThreadMessage {
+ return {
+ environmentId,
+ threadId,
+ messageId: MessageId.make(`message-${index}`),
+ commandId: CommandId.make(`command-${index}`),
+ text: `Message ${index}`,
+ attachments: [],
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5",
+ },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ activeTurnMessageBehavior: "queue",
+ createdAt: new Date(1_700_000_000_000 + index).toISOString(),
+ };
+}
+
+function resetOutbox(): void {
+ writeWebThreadOutboxStorageForTest("");
+}
+
+function persistedOutbox(messages: ReadonlyArray): string {
+ return JSON.stringify({
+ version: 1,
+ state: {
+ queuesByThreadKey: {
+ [webThreadOutboxKey(environmentId, threadId)]: messages,
+ },
+ },
+ });
+}
+
+afterEach(resetOutbox);
+
+describe("web thread outbox", () => {
+ it("keeps an unbounded FIFO per thread", () => {
+ const store = useWebThreadOutboxStore.getState();
+ for (let index = 0; index < 100; index += 1) {
+ store.enqueue(message(index));
+ }
+
+ const queue =
+ useWebThreadOutboxStore.getState().queuesByThreadKey[
+ webThreadOutboxKey(environmentId, threadId)
+ ];
+ expect(queue).toHaveLength(100);
+ expect(queue?.map((entry) => entry.messageId)).toEqual(
+ Array.from({ length: 100 }, (_, index) => MessageId.make(`message-${index}`)),
+ );
+ });
+
+ it("hydrates the legacy full-key snapshot", () => {
+ const queued = message(1);
+ writeWebThreadOutboxStorageForTest(persistedOutbox([queued]));
+
+ expect(
+ useWebThreadOutboxStore.getState().queuesByThreadKey[
+ webThreadOutboxKey(environmentId, threadId)
+ ],
+ ).toEqual([queued]);
+ });
+
+ it("deduplicates stable message ids and removes only the delivered head", () => {
+ const first = message(1);
+ const second = message(2);
+ const store = useWebThreadOutboxStore.getState();
+ store.enqueue(first);
+ store.enqueue(second);
+ store.enqueue({ ...first, text: "Updated" });
+ store.remove(first);
+
+ const queue =
+ useWebThreadOutboxStore.getState().queuesByThreadKey[
+ webThreadOutboxKey(environmentId, threadId)
+ ];
+ expect(queue?.map((entry) => entry.messageId)).toEqual([second.messageId]);
+ });
+
+ it("keeps another tab's independently persisted message when enqueueing", () => {
+ const first = message(1);
+ const second = message(2);
+ const third = message(3);
+ const store = useWebThreadOutboxStore.getState();
+ store.enqueue(first);
+
+ writeWebThreadOutboxEntryForTest(second, {
+ syncStore: false,
+ });
+ store.enqueue(third);
+
+ const queue =
+ useWebThreadOutboxStore.getState().queuesByThreadKey[
+ webThreadOutboxKey(environmentId, threadId)
+ ];
+ expect(queue?.map((entry) => entry.messageId)).toEqual([
+ first.messageId,
+ second.messageId,
+ third.messageId,
+ ]);
+ });
+
+ it("preserves another tab's independently persisted message when removing", () => {
+ const first = message(1);
+ const second = message(2);
+ const store = useWebThreadOutboxStore.getState();
+ store.enqueue(first);
+
+ writeWebThreadOutboxEntryForTest(second, {
+ syncStore: false,
+ });
+ store.remove(first);
+
+ const queue =
+ useWebThreadOutboxStore.getState().queuesByThreadKey[
+ webThreadOutboxKey(environmentId, threadId)
+ ];
+ expect(queue?.map((entry) => entry.messageId)).toEqual([second.messageId]);
+ });
+
+ it("permits only one dispatcher for a stable message id", () => {
+ const queued = message(3);
+ expect(beginWebThreadOutboxDispatch(queued.messageId)).toBe(true);
+ expect(beginWebThreadOutboxDispatch(queued.messageId)).toBe(false);
+ finishWebThreadOutboxDispatch(queued.messageId);
+ expect(beginWebThreadOutboxDispatch(queued.messageId)).toBe(true);
+ finishWebThreadOutboxDispatch(queued.messageId);
+ });
+
+ it("drains ready work and running steer work, but never starting work", () => {
+ expect(
+ shouldDrainWebThreadOutbox({
+ sessionStatus: "ready",
+ environmentConnected: true,
+ paused: false,
+ activeTurnMessageBehavior: "queue",
+ }),
+ ).toBe(true);
+ expect(
+ shouldDrainWebThreadOutbox({
+ sessionStatus: "running",
+ environmentConnected: true,
+ paused: false,
+ activeTurnMessageBehavior: "queue",
+ }),
+ ).toBe(false);
+ expect(
+ shouldDrainWebThreadOutbox({
+ sessionStatus: "running",
+ environmentConnected: true,
+ paused: false,
+ activeTurnMessageBehavior: "steer",
+ }),
+ ).toBe(true);
+ expect(
+ shouldDrainWebThreadOutbox({
+ sessionStatus: "starting",
+ environmentConnected: true,
+ paused: false,
+ activeTurnMessageBehavior: "steer",
+ }),
+ ).toBe(false);
+ expect(
+ shouldDrainWebThreadOutbox({
+ sessionStatus: "ready",
+ environmentConnected: true,
+ paused: true,
+ activeTurnMessageBehavior: "queue",
+ }),
+ ).toBe(false);
+ });
+
+ it("persists a paused message across store hydration", () => {
+ const queued = message(4);
+ useWebThreadOutboxStore.getState().enqueue(queued);
+ useWebThreadOutboxStore.getState().pause(queued.messageId);
+
+ writeWebThreadOutboxEntryForTest(queued, { paused: true });
+
+ expect(useWebThreadOutboxStore.getState().pausedMessageIds[queued.messageId]).toBe(true);
+ });
+
+ it("queues active-turn messages only when queue mode or an existing FIFO requires it", () => {
+ const activeThread = {
+ isServerThread: true,
+ phase: "running" as const,
+ isSendBusy: false,
+ hasQueuedMessages: false,
+ threadStarting: false,
+ };
+
+ expect(
+ shouldQueueWebThreadMessage({
+ ...activeThread,
+ activeTurnMessageBehavior: "queue",
+ }),
+ ).toBe(true);
+ expect(
+ shouldQueueWebThreadMessage({
+ ...activeThread,
+ activeTurnMessageBehavior: "steer",
+ }),
+ ).toBe(false);
+ expect(
+ shouldQueueWebThreadMessage({
+ ...activeThread,
+ phase: "connecting",
+ threadStarting: true,
+ activeTurnMessageBehavior: "steer",
+ }),
+ ).toBe(true);
+ expect(
+ shouldQueueWebThreadMessage({
+ ...activeThread,
+ activeTurnMessageBehavior: "steer",
+ hasQueuedMessages: true,
+ }),
+ ).toBe(true);
+ expect(
+ shouldQueueWebThreadMessage({
+ ...activeThread,
+ activeTurnMessageBehavior: "queue",
+ isServerThread: false,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/web/src/webThreadOutbox.ts b/apps/web/src/webThreadOutbox.ts
new file mode 100644
index 000000000000..46b2cfa3ffcc
--- /dev/null
+++ b/apps/web/src/webThreadOutbox.ts
@@ -0,0 +1,408 @@
+import {
+ CommandId,
+ EnvironmentId,
+ MessageId,
+ ModelSelection,
+ ProviderInteractionMode,
+ RuntimeMode,
+ ThreadId,
+ type UploadChatAttachment,
+} from "@t3tools/contracts";
+import {
+ ActiveTurnMessageBehavior,
+ type ActiveTurnMessageBehavior as ActiveTurnMessageBehaviorType,
+} from "@t3tools/contracts/settings";
+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import * as Schema from "effect/Schema";
+import { create } from "zustand";
+
+export const WEB_THREAD_OUTBOX_STORAGE_KEY = "t3code:thread-outbox:v1";
+export const WEB_THREAD_OUTBOX_ENTRY_STORAGE_PREFIX = "t3code:thread-outbox:v2:";
+const WEB_THREAD_OUTBOX_STORAGE_VERSION = 2;
+
+interface EnumerableStorage {
+ readonly length: number;
+ getItem(name: string): string | null;
+ setItem(name: string, value: string): void;
+ removeItem(name: string): void;
+ key(index: number): string | null;
+}
+
+function createEnumerableMemoryStorage(): EnumerableStorage {
+ const entries = new Map();
+ return {
+ get length() {
+ return entries.size;
+ },
+ getItem: (name) => entries.get(name) ?? null,
+ setItem: (name, value) => entries.set(name, value),
+ removeItem: (name) => entries.delete(name),
+ key: (index) => [...entries.keys()][index] ?? null,
+ };
+}
+
+const QueuedWebImageAttachment = Schema.Struct({
+ type: Schema.Literal("image"),
+ name: Schema.String,
+ mimeType: Schema.String,
+ sizeBytes: Schema.Number,
+ dataUrl: Schema.String,
+});
+
+const QueuedWebThreadMessageSchema = Schema.Struct({
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ messageId: MessageId,
+ commandId: CommandId,
+ text: Schema.String,
+ attachments: Schema.Array(QueuedWebImageAttachment),
+ modelSelection: ModelSelection,
+ runtimeMode: RuntimeMode,
+ interactionMode: ProviderInteractionMode,
+ activeTurnMessageBehavior: Schema.optional(ActiveTurnMessageBehavior),
+ createdAt: Schema.String,
+});
+
+export interface QueuedWebThreadMessage {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly messageId: MessageId;
+ readonly commandId: CommandId;
+ readonly text: string;
+ readonly attachments: ReadonlyArray;
+ readonly modelSelection: ModelSelection;
+ readonly runtimeMode: RuntimeMode;
+ readonly interactionMode: ProviderInteractionMode;
+ readonly activeTurnMessageBehavior: ActiveTurnMessageBehaviorType;
+ readonly createdAt: string;
+}
+
+const PersistedWebThreadOutboxState = Schema.Struct({
+ queuesByThreadKey: Schema.Record(Schema.String, Schema.Array(QueuedWebThreadMessageSchema)),
+});
+const PersistedWebThreadOutboxEntry = Schema.Struct({
+ message: QueuedWebThreadMessageSchema,
+ paused: Schema.Boolean,
+});
+const decodePersistedState = Schema.decodeUnknownSync(PersistedWebThreadOutboxState);
+const decodePersistedEntry = Schema.decodeUnknownSync(PersistedWebThreadOutboxEntry);
+
+function normalizeMessage(
+ message: typeof QueuedWebThreadMessageSchema.Type,
+): QueuedWebThreadMessage {
+ return {
+ ...message,
+ activeTurnMessageBehavior: message.activeTurnMessageBehavior ?? "queue",
+ };
+}
+
+export function webThreadOutboxKey(environmentId: EnvironmentId, threadId: ThreadId): string {
+ return scopedThreadKey(scopeThreadRef(environmentId, threadId));
+}
+
+function storageKey(messageId: MessageId): string {
+ return `${WEB_THREAD_OUTBOX_ENTRY_STORAGE_PREFIX}${encodeURIComponent(String(messageId))}`;
+}
+
+function groupMessages(
+ messages: Iterable,
+): Record> {
+ const byId = new Map();
+ for (const message of messages) {
+ byId.set(message.messageId, message);
+ }
+ const grouped: Record> = {};
+ for (const message of byId.values()) {
+ const threadKey = webThreadOutboxKey(message.environmentId, message.threadId);
+ (grouped[threadKey] ??= []).push(message);
+ }
+ for (const queue of Object.values(grouped)) {
+ queue.sort(
+ (left, right) =>
+ left.createdAt.localeCompare(right.createdAt) ||
+ String(left.messageId).localeCompare(String(right.messageId)),
+ );
+ }
+ return grouped;
+}
+
+function flattenQueues(
+ queues: Record>,
+): ReadonlyArray {
+ return Object.values(queues).flat();
+}
+
+function resolveBaseStorage(): { storage: EnumerableStorage; durable: boolean } {
+ try {
+ if (typeof localStorage !== "undefined") {
+ return { storage: localStorage, durable: true };
+ }
+ } catch {
+ // Sandboxed browsers can reject access to the localStorage property itself.
+ }
+ return { storage: createEnumerableMemoryStorage(), durable: false };
+}
+
+const { storage: baseOutboxStorage, durable: storageIsDurable } = resolveBaseStorage();
+
+interface PersistedSnapshot {
+ readonly queuesByThreadKey: Record>;
+ readonly pausedMessageIds: Readonly>;
+}
+
+function readPersistedSnapshot(): PersistedSnapshot {
+ const messages: QueuedWebThreadMessage[] = [];
+ const pausedMessageIds: Record = {};
+ for (let index = 0; index < baseOutboxStorage.length; index += 1) {
+ const key = baseOutboxStorage.key(index);
+ if (!key?.startsWith(WEB_THREAD_OUTBOX_ENTRY_STORAGE_PREFIX)) {
+ continue;
+ }
+ try {
+ const raw = baseOutboxStorage.getItem(key);
+ if (!raw) continue;
+ const parsed: unknown = JSON.parse(raw);
+ const state = (parsed as { state?: unknown } | null)?.state;
+ if (!state) continue;
+ const entry = decodePersistedEntry(state);
+ const message = normalizeMessage(entry.message);
+ messages.push(message);
+ if (entry.paused) pausedMessageIds[message.messageId] = true;
+ } catch {
+ // A corrupt per-message entry must not hide the other queued messages.
+ }
+ }
+
+ // Read the old full-key snapshot until startup migration removes it.
+ try {
+ const legacyRaw = baseOutboxStorage.getItem(WEB_THREAD_OUTBOX_STORAGE_KEY);
+ if (legacyRaw) {
+ const parsed: unknown = JSON.parse(legacyRaw);
+ const state = (parsed as { state?: unknown } | null)?.state;
+ if (state) {
+ for (const queue of Object.values(decodePersistedState(state).queuesByThreadKey)) {
+ for (const message of queue) messages.push(normalizeMessage(message));
+ }
+ }
+ }
+ } catch {}
+ return { queuesByThreadKey: groupMessages(messages), pausedMessageIds };
+}
+
+function persistEntry(message: QueuedWebThreadMessage, paused: boolean): boolean {
+ try {
+ baseOutboxStorage.setItem(
+ storageKey(message.messageId),
+ JSON.stringify({
+ version: WEB_THREAD_OUTBOX_STORAGE_VERSION,
+ state: { message, paused },
+ }),
+ );
+ return true;
+ } catch (error) {
+ console.error("[THREAD-OUTBOX] Could not persist queued message.", error);
+ return false;
+ }
+}
+
+function removePersistedEntry(messageId: MessageId): boolean {
+ try {
+ baseOutboxStorage.removeItem(storageKey(messageId));
+ return true;
+ } catch (error) {
+ console.error("[THREAD-OUTBOX] Could not remove queued message.", error);
+ return false;
+ }
+}
+
+function mergedSnapshot(
+ state: Pick,
+) {
+ const persisted = readPersistedSnapshot();
+ const messages = [
+ ...flattenQueues(state.queuesByThreadKey),
+ ...flattenQueues(persisted.queuesByThreadKey),
+ ];
+ return {
+ queuesByThreadKey: groupMessages(messages),
+ pausedMessageIds: { ...state.pausedMessageIds, ...persisted.pausedMessageIds },
+ };
+}
+
+interface WebThreadOutboxState {
+ readonly queuesByThreadKey: Record>;
+ readonly pausedMessageIds: Readonly>;
+ readonly enqueue: (message: QueuedWebThreadMessage) => { durable: boolean };
+ readonly remove: (message: QueuedWebThreadMessage) => { durable: boolean };
+ readonly pause: (messageId: MessageId) => void;
+ readonly retry: (messageId: MessageId) => void;
+}
+
+export const useWebThreadOutboxStore = create()((set, get) => ({
+ queuesByThreadKey: {},
+ pausedMessageIds: {},
+ enqueue: (message) => {
+ const snapshot = mergedSnapshot(get());
+ const queuesByThreadKey = groupMessages([
+ ...flattenQueues(snapshot.queuesByThreadKey).filter(
+ (candidate) => candidate.messageId !== message.messageId,
+ ),
+ message,
+ ]);
+ const written = persistEntry(message, false);
+ const pausedMessageIds = { ...snapshot.pausedMessageIds };
+ delete pausedMessageIds[message.messageId];
+ set({ queuesByThreadKey, pausedMessageIds });
+ return { durable: written && storageIsDurable };
+ },
+ remove: (message) => {
+ const removed = removePersistedEntry(message.messageId);
+ const snapshot = mergedSnapshot(get());
+ const queuesByThreadKey = groupMessages(
+ flattenQueues(snapshot.queuesByThreadKey).filter(
+ (candidate) => candidate.messageId !== message.messageId,
+ ),
+ );
+ const pausedMessageIds = { ...snapshot.pausedMessageIds };
+ delete pausedMessageIds[message.messageId];
+ set({ queuesByThreadKey, pausedMessageIds });
+ return { durable: removed && storageIsDurable };
+ },
+ pause: (messageId) => {
+ const snapshot = mergedSnapshot(get());
+ const message = flattenQueues(snapshot.queuesByThreadKey).find(
+ (candidate) => candidate.messageId === messageId,
+ );
+ if (!message) return;
+ persistEntry(message, true);
+ set({
+ ...snapshot,
+ pausedMessageIds: { ...snapshot.pausedMessageIds, [messageId]: true },
+ });
+ },
+ retry: (messageId) => {
+ const snapshot = mergedSnapshot(get());
+ const message = flattenQueues(snapshot.queuesByThreadKey).find(
+ (candidate) => candidate.messageId === messageId,
+ );
+ if (!message || !snapshot.pausedMessageIds[messageId]) return;
+ persistEntry(message, false);
+ const pausedMessageIds = { ...snapshot.pausedMessageIds };
+ delete pausedMessageIds[messageId];
+ set({ queuesByThreadKey: snapshot.queuesByThreadKey, pausedMessageIds });
+ },
+}));
+
+export const EMPTY_WEB_THREAD_OUTBOX_QUEUE: ReadonlyArray = [];
+
+{
+ const initial = readPersistedSnapshot();
+ useWebThreadOutboxStore.setState(initial);
+ try {
+ const legacyMessages = flattenQueues(initial.queuesByThreadKey).filter(
+ (message) => baseOutboxStorage.getItem(storageKey(message.messageId)) === null,
+ );
+ if (legacyMessages.every((message) => persistEntry(message, false))) {
+ baseOutboxStorage.removeItem(WEB_THREAD_OUTBOX_STORAGE_KEY);
+ }
+ } catch {
+ // Sandboxed browsers can expose localStorage while rejecting method calls.
+ }
+}
+
+if (storageIsDurable && typeof window !== "undefined") {
+ window.addEventListener("storage", (event) => {
+ if (
+ event.key !== null &&
+ event.key !== WEB_THREAD_OUTBOX_STORAGE_KEY &&
+ !event.key.startsWith(WEB_THREAD_OUTBOX_ENTRY_STORAGE_PREFIX)
+ ) {
+ return;
+ }
+ useWebThreadOutboxStore.setState(readPersistedSnapshot());
+ });
+}
+
+const dispatchingMessageIds = new Set();
+
+export function beginWebThreadOutboxDispatch(messageId: MessageId): boolean {
+ if (dispatchingMessageIds.has(messageId)) return false;
+ dispatchingMessageIds.add(messageId);
+ return true;
+}
+
+export function finishWebThreadOutboxDispatch(messageId: MessageId): void {
+ dispatchingMessageIds.delete(messageId);
+}
+
+export function shouldDrainWebThreadOutbox(input: {
+ readonly sessionStatus:
+ | "error"
+ | "idle"
+ | "interrupted"
+ | "ready"
+ | "running"
+ | "starting"
+ | "stopped"
+ | null;
+ readonly environmentConnected: boolean;
+ readonly paused: boolean;
+ readonly activeTurnMessageBehavior: ActiveTurnMessageBehaviorType;
+}): boolean {
+ if (!input.environmentConnected || input.paused || input.sessionStatus === "starting") {
+ return false;
+ }
+ return (
+ input.sessionStatus === null ||
+ input.sessionStatus === "ready" ||
+ (input.sessionStatus === "running" && input.activeTurnMessageBehavior === "steer")
+ );
+}
+
+export function shouldQueueWebThreadMessage(input: {
+ readonly activeTurnMessageBehavior: ActiveTurnMessageBehaviorType;
+ readonly hasQueuedMessages: boolean;
+ readonly isSendBusy: boolean;
+ readonly isServerThread: boolean;
+ readonly phase: "disconnected" | "connecting" | "ready" | "running";
+ readonly threadStarting: boolean;
+}): boolean {
+ return (
+ input.isServerThread &&
+ (input.hasQueuedMessages ||
+ input.threadStarting ||
+ (input.activeTurnMessageBehavior === "queue" &&
+ (input.phase === "running" || input.isSendBusy)))
+ );
+}
+
+function clearStorageForTest(): void {
+ const keys: string[] = [];
+ for (let index = 0; index < baseOutboxStorage.length; index += 1) {
+ const key = baseOutboxStorage.key(index);
+ if (
+ key === WEB_THREAD_OUTBOX_STORAGE_KEY ||
+ key?.startsWith(WEB_THREAD_OUTBOX_ENTRY_STORAGE_PREFIX)
+ ) {
+ keys.push(key);
+ }
+ }
+ for (const key of keys) baseOutboxStorage.removeItem(key);
+}
+
+export function writeWebThreadOutboxStorageForTest(raw: string): void {
+ clearStorageForTest();
+ if (raw) baseOutboxStorage.setItem(WEB_THREAD_OUTBOX_STORAGE_KEY, raw);
+ useWebThreadOutboxStore.setState(readPersistedSnapshot());
+ dispatchingMessageIds.clear();
+}
+
+export function writeWebThreadOutboxEntryForTest(
+ message: QueuedWebThreadMessage,
+ options?: { readonly paused?: boolean; readonly syncStore?: boolean },
+): void {
+ persistEntry(message, options?.paused ?? false);
+ if (options?.syncStore !== false) {
+ useWebThreadOutboxStore.setState(readPersistedSnapshot());
+ }
+}
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 46705837afa4..37d79652f5bc 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -67,6 +67,22 @@ describe("ClientSettings environment identification", () => {
});
});
+describe("ClientSettings messages while working", () => {
+ it("defaults to steering and accepts both delivery behaviors", () => {
+ expect(decodeClientSettings({}).activeTurnMessageBehavior).toBe("steer");
+ expect(
+ decodeClientSettingsPatch({ activeTurnMessageBehavior: "steer" }).activeTurnMessageBehavior,
+ ).toBe("steer");
+ expect(
+ decodeClientSettingsPatch({ activeTurnMessageBehavior: "queue" }).activeTurnMessageBehavior,
+ ).toBe("queue");
+ });
+
+ it("rejects unsupported delivery behaviors", () => {
+ expect(() => decodeClientSettingsPatch({ activeTurnMessageBehavior: "send-later" })).toThrow();
+ });
+});
+
describe("ClientSettings sidebar", () => {
it("defaults to the current sidebar with a three-day auto-settle threshold", () => {
const settings = decodeClientSettings({});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 388205649c85..57415d76d59f 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -103,6 +103,9 @@ export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12;
export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]);
export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;
export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";
+export const ActiveTurnMessageBehavior = Schema.Literals(["steer", "queue"]);
+export type ActiveTurnMessageBehavior = typeof ActiveTurnMessageBehavior.Type;
+export const DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR: ActiveTurnMessageBehavior = "steer";
/**
* A user-chosen font family (a single name or a comma-separated list). Empty
@@ -112,6 +115,10 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200))
export type FontFamilyPreference = typeof FontFamilyPreference.Type;
export const ClientSettingsSchema = Schema.Struct({
+ activeTurnMessageBehavior: ActiveTurnMessageBehavior.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR)),
+ ),
+ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
@@ -755,6 +762,8 @@ export const ServerSettingsPatch = Schema.Struct({
export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
export const ClientSettingsPatch = Schema.Struct({
+ activeTurnMessageBehavior: Schema.optionalKey(ActiveTurnMessageBehavior),
+ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean),
confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),