diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 90e5af199dec..e725b973324c 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -8,6 +8,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; +import { DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR } from "@t3tools/contracts/settings"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -530,8 +531,36 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const activeTurnMessageBehavior = AsyncResult.isSuccess(preferencesResult) + ? (preferencesResult.value.activeTurnMessageBehavior ?? DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR) + : DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR; + return ( + + Alert.alert( + "Messages while working", + "Steer adds the message to the active turn. Queue waits and sends messages one at a time after the current turn finishes.", + [ + { + text: "Steer", + onPress: () => savePreferences({ activeTurnMessageBehavior: "steer" }), + }, + { + text: "Queue", + onPress: () => savePreferences({ activeTurnMessageBehavior: "queue" }), + }, + { text: "Cancel", style: "cancel" }, + ], + ) + } + /> diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c846dca287a7..f78dffa8a5d4 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -8,6 +8,7 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; +import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings"; import { detectComposerTrigger, replaceTextRange, @@ -99,6 +100,7 @@ export interface ThreadComposerProps { readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly activeThreadBusy: boolean; + readonly activeTurnMessageBehavior: ActiveTurnMessageBehavior; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; readonly editorRef?: RefObject; @@ -319,9 +321,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "starting"; const sendLabel = - props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 + props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" - : "Send"; + : props.activeThreadBusy + ? props.activeTurnMessageBehavior === "queue" + ? "Queue" + : "Steer" + : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3d83c8375006..80d4d67e8485 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,6 +14,7 @@ import type { ServerConfig as T3ServerConfig, ThreadId, } from "@t3tools/contracts"; +import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, View, type GestureResponderEvent } from "react-native"; @@ -64,6 +65,7 @@ export interface ThreadDetailScreenProps { /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; readonly activeThreadBusy: boolean; + readonly activeTurnMessageBehavior: ActiveTurnMessageBehavior; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly threadCwd: string | null; @@ -433,6 +435,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} activeThreadBusy={props.activeThreadBusy} + activeTurnMessageBehavior={props.activeTurnMessageBehavior} environmentId={props.environmentId} projectCwd={props.projectWorkspaceRoot} bottomInset={composerBottomInset} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d7754b7d78f7..e8be8f53a73a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -786,6 +786,7 @@ function ThreadRouteContent( threadSyncStatus={selectedThreadDetailState.status} loadEarlier={loadEarlierTurns} activeThreadBusy={composer.activeThreadBusy} + activeTurnMessageBehavior={composer.activeTurnMessageBehavior} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} threadCwd={selectedThreadCwd} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index bf40acb053b7..c42fee543470 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -6,6 +6,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings"; import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; @@ -15,6 +16,7 @@ const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; export interface Preferences { + readonly activeTurnMessageBehavior?: ActiveTurnMessageBehavior; readonly liveActivitiesEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; @@ -75,6 +77,7 @@ export class MobilePreferencesStore extends Context.Service< function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { + activeTurnMessageBehavior?: ActiveTurnMessageBehavior; liveActivitiesEnabled?: boolean; baseFontSize?: number; terminalFontSize?: number | null; @@ -88,6 +91,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { legacyThreadListEnabled?: boolean; } = {}; + if ( + parsed.activeTurnMessageBehavior === "steer" || + parsed.activeTurnMessageBehavior === "queue" + ) { + preferences.activeTurnMessageBehavior = parsed.activeTurnMessageBehavior; + } if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; } diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index c53594eb2306..6bb638147ebe 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -24,6 +24,7 @@ vi.mock("../lib/runtime", async () => { import type { Preferences } from "../persistence/mobile-preferences"; import { + awaitActiveTurnMessageBehavior, createMobilePreferencesState, MobilePreferencesLoadError, MobilePreferencesSaveError, @@ -62,6 +63,32 @@ function makePreferencesState( } describe("mobile preferences state", () => { + it("waits for the persisted active-turn behavior before sending", async () => { + const pendingLoad = deferred(); + const state = makePreferencesState({ + load: Effect.promise(() => pendingLoad.promise), + savePatch: (patch) => Effect.succeed(patch), + }); + const registry = AtomRegistry.make(); + const unmount = registry.mount(state.preferencesAtom); + + let settled = false; + const behaviorPromise = awaitActiveTurnMessageBehavior(registry, state.preferencesAtom).then( + (behavior) => { + settled = true; + return behavior; + }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + + pendingLoad.resolve({ activeTurnMessageBehavior: "queue" }); + await expect(behaviorPromise).resolves.toBe("queue"); + + unmount(); + registry.dispose(); + }); + it.effect("shares one preference load across consumers", () => Effect.gen(function* () { const load = vi.fn(() => Promise.resolve({ baseFontSize: 17 })); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be5a..f303cd606779 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR } from "@t3tools/contracts/settings"; +import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings"; import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; import * as Runtime from "../lib/runtime"; @@ -122,3 +124,40 @@ export const mobilePreferencesState = createMobilePreferencesState(mobilePrefere export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; + +function settledActiveTurnMessageBehavior( + result: AsyncResult.AsyncResult, +): ActiveTurnMessageBehavior | null { + if (result.waiting) { + return null; + } + return AsyncResult.isSuccess(result) + ? (result.value.activeTurnMessageBehavior ?? DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR) + : DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR; +} + +/** + * Reads the send behavior from the settled preference snapshot. A composer can + * render before the device preference read finishes, so capturing its + * render-time fallback would steer a message that the user intended to queue. + */ +export function awaitActiveTurnMessageBehavior( + registry: AtomRegistry.AtomRegistry, + preferencesAtom: Atom.Atom>, +): Promise { + const current = settledActiveTurnMessageBehavior(registry.get(preferencesAtom)); + if (current !== null) { + return Promise.resolve(current); + } + + return new Promise((resolve) => { + const unsubscribe = registry.subscribe(preferencesAtom, (result) => { + const behavior = settledActiveTurnMessageBehavior(result); + if (behavior === null) { + return; + } + unsubscribe(); + resolve(behavior); + }); + }); +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be38720..22619959ac30 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -16,12 +16,16 @@ import { type RuntimeMode as RuntimeModeType, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { + ActiveTurnMessageBehavior, + type ActiveTurnMessageBehavior as ActiveTurnMessageBehaviorType, +} from "@t3tools/contracts/settings"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 3; +const THREAD_OUTBOX_SCHEMA_VERSION = 4; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; const QueuedThreadCreationSchema = Schema.Struct({ @@ -37,7 +41,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ }); export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]), + schemaVersion: Schema.Literals([1, 2, 3, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, @@ -47,6 +51,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), + activeTurnMessageBehavior: Schema.optional(ActiveTurnMessageBehavior), // Present when the queued item creates a brand-new thread (pending task) // instead of appending a turn to an existing one. creation: Schema.optional(QueuedThreadCreationSchema), @@ -76,6 +81,11 @@ export interface QueuedThreadMessage { readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; readonly interactionMode?: ProviderInteractionModeType; + /** + * Snapshot of the send preference at enqueue time. Older persisted mobile + * outbox entries omit this and retain the historical queue behavior. + */ + readonly activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType; readonly creation?: QueuedThreadCreation; readonly createdAt: string; } @@ -148,12 +158,29 @@ export function threadOutboxRetryDelayMs(attempt: number): number { export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; +export function shouldDeferConfirmedThreadOutboxDelivery(input: { + readonly deliveryAction: ThreadOutboxDeliveryAction; + readonly isCreation: boolean; + readonly threadBusy: boolean; + readonly threadSteerable: boolean; + readonly activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType; +}): boolean { + return ( + input.deliveryAction === "send" && + !input.isCreation && + input.threadBusy && + !(input.activeTurnMessageBehavior === "steer" && input.threadSteerable) + ); +} + export function resolveThreadOutboxDeliveryAction(input: { readonly isCreation: boolean; readonly threadExists: boolean; readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; readonly threadBusy: boolean; + readonly threadSteerable: boolean; + readonly activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType; }): ThreadOutboxDeliveryAction { if (input.isCreation) { // A pending task creates its thread on delivery. If the thread already @@ -169,7 +196,9 @@ export function resolveThreadOutboxDeliveryAction(input: { if (!input.threadExists) { return input.shellStatus === "live" ? "remove" : "wait"; } - return input.environmentConnected && !input.threadBusy ? "send" : "wait"; + const canSendWhileBusy = + !input.threadBusy || (input.activeTurnMessageBehavior === "steer" && input.threadSteerable); + return input.environmentConnected && canSendWhileBusy ? "send" : "wait"; } /** diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 89f8b26798be..833ece1bd2dc 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -18,6 +18,7 @@ import { resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, + shouldDeferConfirmedThreadOutboxDelivery, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadMessage, @@ -465,6 +466,7 @@ describe("thread outbox", () => { shellStatus: "synchronizing", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("wait"); expect( @@ -474,6 +476,7 @@ describe("thread outbox", () => { shellStatus: "live", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("remove"); expect( @@ -483,10 +486,67 @@ describe("thread outbox", () => { shellStatus: "live", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("send"); }); + it("waits behind active work in queue mode and dispatches into it in steer mode", () => { + const input = { + isCreation: false, + threadExists: true, + shellStatus: "live" as const, + environmentConnected: true, + threadBusy: true, + threadSteerable: true, + }; + + // Omitted preserves the behavior of outbox entries written by older mobile builds. + expect(resolveThreadOutboxDeliveryAction(input)).toBe("wait"); + expect( + resolveThreadOutboxDeliveryAction({ + ...input, + activeTurnMessageBehavior: "queue", + }), + ).toBe("wait"); + expect( + resolveThreadOutboxDeliveryAction({ + ...input, + activeTurnMessageBehavior: "steer", + }), + ).toBe("send"); + }); + + it("keeps steer delivery eligible only after a turn is running", () => { + const input = { + deliveryAction: "send" as const, + isCreation: false, + threadBusy: true, + threadSteerable: true, + }; + + expect(shouldDeferConfirmedThreadOutboxDelivery(input)).toBe(true); + expect( + shouldDeferConfirmedThreadOutboxDelivery({ + ...input, + activeTurnMessageBehavior: "queue", + }), + ).toBe(true); + expect( + shouldDeferConfirmedThreadOutboxDelivery({ + ...input, + activeTurnMessageBehavior: "steer", + }), + ).toBe(false); + expect( + shouldDeferConfirmedThreadOutboxDelivery({ + ...input, + threadSteerable: false, + activeTurnMessageBehavior: "steer", + }), + ).toBe(true); + }); + it("sends queued creations once connected and live, removing already-created ones", () => { expect( resolveThreadOutboxDeliveryAction({ @@ -495,6 +555,7 @@ describe("thread outbox", () => { shellStatus: "cached", environmentConnected: false, threadBusy: false, + threadSteerable: false, }), ).toBe("wait"); // Connected but not yet synchronized: a previously delivered creation may @@ -506,6 +567,7 @@ describe("thread outbox", () => { shellStatus: "synchronizing", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("wait"); expect( @@ -515,6 +577,7 @@ describe("thread outbox", () => { shellStatus: "live", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("send"); expect( @@ -524,6 +587,7 @@ describe("thread outbox", () => { shellStatus: "live", environmentConnected: true, threadBusy: true, + threadSteerable: true, }), ).toBe("remove"); }); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b09aadf7e6b7..0a28c670b076 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { CommandId, @@ -10,6 +11,7 @@ import { type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; +import { DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; @@ -41,6 +43,7 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { awaitActiveTurnMessageBehavior, mobilePreferencesAtom } from "./preferences"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -78,6 +81,10 @@ export function useThreadComposerState() { const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const activeTurnMessageBehavior = AsyncResult.isSuccess(preferencesResult) + ? (preferencesResult.value.activeTurnMessageBehavior ?? DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR) + : DEFAULT_ACTIVE_TURN_MESSAGE_BEHAVIOR; useEffect(() => { ensureComposerDraftsLoaded(); @@ -138,6 +145,10 @@ export function useThreadComposerState() { return null; } + const sendBehaviorPromise = awaitActiveTurnMessageBehavior( + appAtomRegistry, + mobilePreferencesAtom, + ); const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); const thread = selectedThreadDetail ?? selectedThreadShell; @@ -149,11 +160,22 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); + clearComposerDraftContent(threadKey); + let sendBehavior; + try { + sendBehavior = await sendBehaviorPromise; + } catch (error) { + void mergeComposerDraftContent(threadKey, { text, attachments: [] }); + appendComposerDraftAttachments(threadKey, attachments); + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to load message behavior settings.", + ); + return null; + } // Enqueue publishes the queued atom synchronously (the durable write - // happens behind it), so clearing the draft here gives send feedback on - // the tap frame instead of after file I/O. If the write fails the message - // is rolled out of the queue and the content is merged back into the - // draft, preserving anything typed since. + // happens behind it). The draft was captured and cleared before awaiting + // settings, so edits made after the tap belong to the next message. If the + // write fails, merge the captured content back without replacing them. const enqueuePromise = enqueueThreadOutboxMessage({ environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, @@ -164,9 +186,9 @@ export function useThreadComposerState() { modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: draft.interactionMode ?? thread.interactionMode, + activeTurnMessageBehavior: sendBehavior, createdAt: metadata.createdAt, }); - clearComposerDraftContent(threadKey); enqueuePromise.catch((error: unknown) => { // Restore text via merge (idempotent) but attachments via the uncapped // append: the merge path slots existing attachments first and truncates @@ -308,6 +330,7 @@ export function useThreadComposerState() { modelSelection, runtimeMode, interactionMode, + activeTurnMessageBehavior, activeThreadBusy, onChangeDraftMessage, onPickDraftImages, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index d06a4098aab2..950321610c2d 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -32,6 +32,7 @@ import { resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, + shouldDeferConfirmedThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadCreation, type QueuedThreadMessage, @@ -315,6 +316,8 @@ export function useThreadOutboxDrain(): void { shellStatus, environmentConnected: environment?.connectionState === "connected", threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", + threadSteerable: thread?.session?.status === "running", + activeTurnMessageBehavior: nextQueuedMessage.activeTurnMessageBehavior, }); if (deliveryAction === "wait") { continue; @@ -375,7 +378,15 @@ export function useThreadOutboxDrain(): void { ); const freshThreadBusy = freshThread?.session?.status === "running" || freshThread?.session?.status === "starting"; - if (deliveryAction === "send" && creation === undefined && freshThreadBusy) { + if ( + shouldDeferConfirmedThreadOutboxDelivery({ + deliveryAction, + isCreation: creation !== undefined, + threadBusy: freshThreadBusy, + threadSteerable: freshThread?.session?.status === "running", + activeTurnMessageBehavior: nextQueuedMessage.activeTurnMessageBehavior, + }) + ) { return true; } return deliveryAction === "remove" diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..d180db9d4e69 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2538,6 +2538,103 @@ it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test- assert.deepEqual(pendingRows, []); }), ); + + it.effect("reconciles a steer with the already-running turn", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-steer-reaffirmed"); + const turnId = TurnId.make("turn-steer-reaffirmed"); + const initialMessageId = MessageId.make("message-steer-initial"); + const steeredMessageId = MessageId.make("message-steer-follow-up"); + + const appendTurnStartRequested = ( + eventSuffix: string, + messageId: MessageId, + createdAt: string, + ) => + eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make(`evt-steer-${eventSuffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make(`cmd-steer-${eventSuffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-steer-${eventSuffix}`), + metadata: {}, + payload: { + threadId, + messageId, + runtimeMode: "full-access", + createdAt, + }, + }); + + const appendRunningSession = (eventSuffix: string, updatedAt: string) => + eventStore.append({ + type: "thread.session-set", + eventId: EventId.make(`evt-steer-${eventSuffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: updatedAt, + commandId: CommandId.make(`cmd-steer-${eventSuffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-steer-${eventSuffix}`), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt, + }, + }, + }); + + yield* appendTurnStartRequested( + "initial-request", + initialMessageId, + "2026-02-26T15:00:00.000Z", + ); + yield* appendRunningSession("initial-running", "2026-02-26T15:00:01.000Z"); + yield* appendTurnStartRequested( + "follow-up-request", + steeredMessageId, + "2026-02-26T15:00:02.000Z", + ); + // A successful Codex turn/steer reaffirms the same active turn id. + yield* appendRunningSession("steer-reaffirmed", "2026-02-26T15:00:03.000Z"); + + yield* projectionPipeline.bootstrap; + + const turnRows = yield* sql<{ + readonly turnId: string | null; + readonly pendingMessageId: string | null; + readonly state: string; + }>` + SELECT + turn_id AS "turnId", + pending_message_id AS "pendingMessageId", + state + FROM projection_turns + WHERE thread_id = ${threadId} + ORDER BY row_id + `; + assert.deepEqual(turnRows, [ + { + turnId, + pendingMessageId: initialMessageId, + state: "running", + }, + ]); + }), + ); }, ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7b8fbec56660..5a35ec1f7819 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -723,6 +723,62 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps a reaffirmed turn/started event for a successful steer", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-turn-steered"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "turn/started", + message: "Codex turn steered.", + turnId: asTurnId("turn-1"), + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "turn.started"); + NodeAssert.equal(firstEvent.value.turnId, "turn-1"); + }), + ); + + it.effect("maps a late failed steer reconciliation back to an error session", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-late-steer-error"), + kind: "session", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "session/error", + message: "Turn failed", + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "session.state.changed"); + if (firstEvent.value.type === "session.state.changed") { + NodeAssert.equal(firstEvent.value.payload.state, "error"); + NodeAssert.equal(firstEvent.value.payload.reason, "Turn failed"); + } + }), + ); + it.effect("maps retryable Codex error notifications to runtime.warning", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6b99bf52b1e0..41d1ecc5d749 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -903,6 +903,19 @@ function mapToRuntimeEvents( ]; } + if (event.method === "session/error") { + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "session.state.changed", + payload: { + state: "error", + ...(event.message ? { reason: event.message } : {}), + }, + }, + ]; + } + if (event.method === "session/started") { return [ { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index d7346a0e0dbe..2b662d7dec70 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -4,7 +4,7 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { describe } from "vite-plus/test"; -import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL, ThreadId, TurnId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; @@ -15,10 +15,13 @@ import { } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { + buildTurnSteerParams, buildTurnStartParams, hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, + resolveCodexSteerReconciliation, + resolveCodexSteeringTurnId, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -246,6 +249,71 @@ describe("buildTurnStartParams", () => { }); }); +describe("buildTurnSteerParams", () => { + it("reuses the active provider turn only while the session is running", () => { + const activeTurnId = TurnId.make("provider-turn-active"); + + NodeAssert.equal(resolveCodexSteeringTurnId({ status: "running", activeTurnId }), activeTurnId); + NodeAssert.equal(resolveCodexSteeringTurnId({ status: "ready", activeTurnId }), undefined); + }); + + it("targets the active turn and preserves text and image input", () => { + const activeTurnId = TurnId.make("provider-turn-active"); + + NodeAssert.deepStrictEqual( + buildTurnSteerParams({ + threadId: "provider-thread-1", + activeTurnId, + prompt: "Change direction", + attachments: [ + { + type: "image", + url: "data:image/png;base64,abc", + }, + ], + }), + { + threadId: "provider-thread-1", + expectedTurnId: activeTurnId, + input: [ + { + type: "text", + text: "Change direction", + }, + { + type: "image", + url: "data:image/png;base64,abc", + }, + ], + }, + ); + }); + + it("reconciles a steer without reviving a turn that settled during the request", () => { + const activeTurnId = TurnId.make("provider-turn-active"); + + NodeAssert.equal( + resolveCodexSteerReconciliation({ status: "running", activeTurnId }, activeTurnId), + "running", + ); + NodeAssert.equal( + resolveCodexSteerReconciliation({ status: "ready", activeTurnId: undefined }, activeTurnId), + "ready", + ); + NodeAssert.equal( + resolveCodexSteerReconciliation({ status: "error", activeTurnId: undefined }, activeTurnId), + "error", + ); + NodeAssert.equal( + resolveCodexSteerReconciliation( + { status: "running", activeTurnId: TurnId.make("provider-turn-new") }, + activeTurnId, + ), + "ignore", + ); + }); +}); + describe("buildCodexDeveloperInstructions", () => { it("appends runtime info after the mode instructions", () => { const instructions = buildCodexDeveloperInstructions("default", { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 58c012bd63ea..2bd8f40783af 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -28,6 +28,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; @@ -88,6 +89,7 @@ export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; +type CodexTurnUserInput = EffectCodexSchema.V2TurnStartParams__UserInput; type CodexServiceTier = NonNullable; type CodexThreadItem = | EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number] @@ -358,6 +360,64 @@ function buildCodexCollaborationMode(input: { }; } +function buildCodexTurnInput(input: { + readonly prompt?: string; + readonly attachments?: ReadonlyArray<{ + readonly type: "image"; + readonly url: string; + }>; +}): ReadonlyArray { + const turnInput: Array = []; + if (input.prompt) { + turnInput.push({ + type: "text", + text: input.prompt, + }); + } + for (const attachment of input.attachments ?? []) { + turnInput.push(attachment); + } + return turnInput; +} + +export function buildTurnSteerParams(input: { + readonly threadId: string; + readonly activeTurnId: TurnId; + readonly prompt?: string; + readonly attachments?: ReadonlyArray<{ + readonly type: "image"; + readonly url: string; + }>; +}): EffectCodexSchema.V2TurnSteerParams { + return { + threadId: input.threadId, + expectedTurnId: input.activeTurnId, + input: buildCodexTurnInput(input), + }; +} + +export function resolveCodexSteeringTurnId( + session: Pick, +): TurnId | undefined { + return session.status === "running" ? session.activeTurnId : undefined; +} + +export function resolveCodexSteerReconciliation( + session: Pick, + steeringTurnId: TurnId, +): "running" | "ready" | "error" | "ignore" { + if (resolveCodexSteeringTurnId(session) === steeringTurnId) { + return "running"; + } + if ( + session.activeTurnId === undefined && + (session.status === "ready" || session.status === "error") + ) { + return session.status; + } + return "ignore"; +} + export function buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; @@ -374,17 +434,6 @@ export function buildTurnStartParams(input: { CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError > { - const turnInput: Array = []; - if (input.prompt) { - turnInput.push({ - type: "text", - text: input.prompt, - }); - } - for (const attachment of input.attachments ?? []) { - turnInput.push(attachment); - } - const config = runtimeModeToThreadConfig(input.runtimeMode); const collaborationMode = buildCodexCollaborationMode({ ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), @@ -394,7 +443,7 @@ export function buildTurnStartParams(input: { return decodeCodexTurnStartParamsWithCollaborationMode({ threadId: input.threadId, - input: turnInput, + input: buildCodexTurnInput(input), approvalPolicy: config.approvalPolicy, approvalsReviewer: config.approvalsReviewer, sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), @@ -858,6 +907,7 @@ export const makeCodexSessionRuntime = ( /** Child provider-thread id → its currently running provider turn id. */ const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const closedRef = yield* Ref.make(false); + const sendTurnSemaphore = yield* Semaphore.make(1); // `~` is not shell-expanded when env vars are set via // `child_process.spawn`; `expandHomePath` lets a configured @@ -1747,58 +1797,123 @@ export const makeCodexSessionRuntime = ( start, getSession: Ref.get(sessionRef), sendTurn: (input) => - Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - if (hasConfiguredMcpServer(options.appServerArgs)) { - yield* client.request("config/mcpServer/reload", undefined).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { - cause, - }), + sendTurnSemaphore.withPermits(1)( + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + if (hasConfiguredMcpServer(options.appServerArgs)) { + yield* client.request("config/mcpServer/reload", undefined).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { + cause, + }), + ), + ); + } + const session = yield* Ref.get(sessionRef); + const steeringTurnId = resolveCodexSteeringTurnId(session); + if (steeringTurnId) { + const steerResult = yield* client + .request( + "turn/steer", + buildTurnSteerParams({ + threadId: providerThreadId, + activeTurnId: steeringTurnId, + ...(input.input ? { prompt: input.input } : {}), + ...(input.attachments ? { attachments: input.attachments } : {}), + }), + ) + .pipe( + Effect.map((value) => ({ _tag: "Steered" as const, value })), + // Request errors are explicit rejections, so the steer input + // was not accepted. Transport and process failures keep their + // ambiguous outcome and remain in the error channel. + Effect.catchTags({ + CodexAppServerRequestError: () => + Effect.succeed({ _tag: "RetryAsStart" as const }), + }), + ); + if (steerResult._tag === "Steered") { + const turnId = TurnId.make(steerResult.value.turnId); + const sessionAfterSteer = yield* Ref.get(sessionRef); + const reconciliation = resolveCodexSteerReconciliation( + sessionAfterSteer, + steeringTurnId, + ); + if (reconciliation !== "ignore") { + // Codex does not emit a second turn/started notification when + // turn/steer keeps the existing turn alive. Reaffirm the active + // turn so orchestration can reconcile the steer request with the + // provider turn instead of leaving a stale pending-turn row. + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "turn/started", + turnId, + message: "Codex turn steered.", + }); + if (reconciliation === "ready") { + // The completion notification won the race with the steer + // acknowledgement. Restore its settled lifecycle after the + // reaffirmation clears the pending message projection. + yield* emitSessionEvent( + "session/ready", + "Codex turn completed while steer was acknowledged.", + ); + } else if (reconciliation === "error") { + yield* emitSessionEvent( + "session/error", + sessionAfterSteer.lastError ?? + "Codex turn failed while steer was acknowledged.", + ); + } + } + const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + return { + threadId: options.threadId, + turnId, + ...(resumedProviderThreadId + ? { resumeCursor: { threadId: resumedProviderThreadId } } + : {}), + } satisfies ProviderTurnStartResult; + } + } + const normalizedModel = normalizeCodexModelSlug(input.model ?? session.model); + const params = yield* buildTurnStartParams({ + threadId: providerThreadId, + runtimeMode: options.runtimeMode, + ...(input.input ? { prompt: input.input } : {}), + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(normalizedModel ? { model: normalizedModel } : {}), + ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.effort ? { effort: input.effort } : {}), + ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), + }); + const rawResponse = yield* client.raw.request("turn/start", params); + const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "turn/start" }, + ), ), ); - } - const normalizedModel = normalizeCodexModelSlug( - input.model ?? (yield* Ref.get(sessionRef)).model, - ); - const params = yield* buildTurnStartParams({ - threadId: providerThreadId, - runtimeMode: options.runtimeMode, - ...(input.input ? { prompt: input.input } : {}), - ...(input.attachments ? { attachments: input.attachments } : {}), - ...(normalizedModel ? { model: normalizedModel } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), - ...(input.effort ? { effort: input.effort } : {}), - ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - }); - const rawResponse = yield* client.raw.request("turn/start", params); - const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( - "decode-response-payload", - error, - { method: "turn/start" }, - ), - ), - ); - const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, (session) => ({ - status: "running", - // Codex accepts follow-ups while the current turn is still - // running. The response contains the queued turn id, but - // turn/interrupt only accepts the id that is active now. - activeTurnId: session.activeTurnId ?? turnId, - ...(normalizedModel ? { model: normalizedModel } : {}), - })); - const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); - return { - threadId: options.threadId, - turnId, - ...(resumedProviderThreadId - ? { resumeCursor: { threadId: resumedProviderThreadId } } - : {}), - } satisfies ProviderTurnStartResult; - }), + const turnId = TurnId.make(response.turn.id); + yield* updateSession(sessionRef, { + status: "running", + activeTurnId: turnId, + ...(normalizedModel ? { model: normalizedModel } : {}), + }); + const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + return { + threadId: options.threadId, + turnId, + ...(resumedProviderThreadId + ? { resumeCursor: { threadId: resumedProviderThreadId } } + : {}), + } satisfies ProviderTurnStartResult; + }), + ), interruptTurn: (turnId) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 07f5d3b5fcc1..03385e728020 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -74,10 +74,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; -import { - collapseExpandedComposerCursor, - parseStandaloneComposerSlashCommand, -} from "../composer-logic"; +import { parseStandaloneComposerSlashCommand } from "../composer-logic"; import { derivePendingApprovals, derivePendingUserInputs, @@ -171,7 +168,7 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; @@ -180,6 +177,12 @@ import { useClientSettingsHydrated, useEnvironmentSettings, } from "../hooks/useSettings"; +import { + EMPTY_WEB_THREAD_OUTBOX_QUEUE, + shouldQueueWebThreadMessage, + useWebThreadOutboxStore, + webThreadOutboxKey, +} from "../webThreadOutbox"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -1263,6 +1266,12 @@ function ChatViewContent(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); + const activeThreadOutboxQueue = useWebThreadOutboxStore( + (state) => + state.queuesByThreadKey[webThreadOutboxKey(environmentId, props.threadId)] ?? + EMPTY_WEB_THREAD_OUTBOX_QUEUE, + ); + const pausedOutboxMessageIds = useWebThreadOutboxStore((state) => state.pausedMessageIds); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the // primary server rather than the thread's environment. @@ -1283,24 +1292,15 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); - const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, - ); - const setComposerDraftElementContexts = useComposerDraftStore( - (store) => store.setElementContexts, - ); - const setComposerDraftPreviewAnnotations = useComposerDraftStore( - (store) => store.setPreviewAnnotations, - ); - const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const restoreComposerDraftContent = useComposerDraftStore( + (store) => store.restoreComposerContent, + ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSessionByLogicalProjectKey = useComposerDraftStore( (store) => store.getDraftSessionByLogicalProjectKey, @@ -4800,9 +4800,21 @@ function ChatViewContent(props: ChatViewProps) { }), ); }; + if (!clientSettingsHydrated) { + notifyDirectAnnotationAttached(); + return; + } + const shouldQueueCurrentMessage = shouldQueueWebThreadMessage({ + activeTurnMessageBehavior: settings.activeTurnMessageBehavior, + hasQueuedMessages: activeThreadOutboxQueue.length > 0, + isSendBusy, + isServerThread, + phase, + threadStarting: activeThread?.session?.status === "starting", + }); if ( !activeThread || - isSendBusy || + (isSendBusy && !shouldQueueCurrentMessage) || isConnecting || threadDetailLoading || sendInFlightRef.current @@ -4954,23 +4966,110 @@ function ChatViewContent(props: ChatViewProps) { return; } - sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { - let resolveDockStarted: (() => void) | undefined; - const dockStarted = new Promise((resolve) => { - resolveDockStarted = resolve; + if (shouldQueueCurrentMessage) { + sendInFlightRef.current = true; + const composerImagesSnapshot = [...composerImages]; + const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; + const composerElementContextsSnapshot = [...composerElementContexts]; + const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + const composerContentSnapshot = { + prompt: promptForSend, + images: composerImagesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + elementContexts: composerElementContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, + }; + const messageTextWithContexts = appendElementContextsToPrompt( + appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + composerElementContextsSnapshot, + ); + const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( + (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), + messageTextWithContexts, + ); + const messageTextForSend = appendReviewCommentsToPrompt( + messageTextWithPreviewAnnotations, + composerReviewCommentsSnapshot, + ); + const outgoingMessageText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + const attachmentsResult = await settlePromise(() => + Promise.all( + composerImagesSnapshot.map(async (image) => ({ + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ), + ); + if (attachmentsResult._tag === "Failure") { + restoreComposerDraftContent(composerDraftTarget, composerContentSnapshot); + const error = squashAtomCommandFailure(attachmentsResult); + setThreadError( + threadIdForSend, + error instanceof Error ? error.message : "Failed to prepare the queued message.", + ); + sendInFlightRef.current = false; + return; + } + + const messageId = newMessageId(); + const createdAt = new Date().toISOString(); + const { durable } = useWebThreadOutboxStore.getState().enqueue({ + environmentId, + threadId: threadIdForSend, + messageId, + commandId: newCommandId(), + text: outgoingMessageText, + attachments: attachmentsResult.value, + modelSelection: ctxSelectedModelSelection, + runtimeMode, + interactionMode, + activeTurnMessageBehavior: settings.activeTurnMessageBehavior, + createdAt, }); - void dockTransition.catch(() => resolveDockStarted?.()); - await dockStarted; + setThreadError(threadIdForSend, null); + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "omitted", + ); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }), + ); + } + if (!durable) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Message queued for this session", + description: + "Browser storage could not save the queue, so this message will not survive a reload.", + }), + ); + } + for (const image of composerImagesSnapshot) { + revokeBlobPreviewUrl(image.previewUrl); + } + sendInFlightRef.current = false; + return; } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; @@ -5015,6 +5114,26 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); // Sending always returns to the live edge. The new row becomes the // anchored end-space target so it lands near the top while the response // streams into the reserved space below it. @@ -5057,10 +5176,6 @@ function ChatViewContent(props: ChatViewProps) { }), ); } - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { const firstComposerImage = composerImagesSnapshot[0]; @@ -5183,41 +5298,23 @@ function ChatViewContent(props: ChatViewProps) { } if (failure !== null) { - if ( - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - composerElementContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 - ) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); - promptRef.current = promptForSend; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - composerImagesRef.current = retryComposerImages; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; - composerElementContextsRef.current = composerElementContextsSnapshot; - setComposerDraftPrompt(composerDraftTarget, promptForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); - setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), - prompt: promptForSend, - detectTrigger: true, - }); - } + const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); + restoreComposerDraftContent(composerDraftTarget, { + prompt: promptForSend, + images: retryComposerImages, + terminalContexts: composerTerminalContextsSnapshot, + elementContexts: composerElementContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, + }); + setOptimisticUserMessages((existing) => { + const removed = existing.filter((message) => message.id === messageIdForSend); + for (const message of removed) { + revokeUserMessagePreviewUrls(message); + } + const next = existing.filter((message) => message.id !== messageIdForSend); + return next.length === existing.length ? existing : next; + }); if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( @@ -5235,6 +5332,18 @@ function ChatViewContent(props: ChatViewProps) { } }; + const nextQueuedMessage = activeThreadOutboxQueue[0] ?? null; + const queuedMessagePaused = + nextQueuedMessage !== null && Boolean(pausedOutboxMessageIds[nextQueuedMessage.messageId]); + + const retryQueuedMessages = useCallback(() => { + if (!nextQueuedMessage) { + return; + } + useWebThreadOutboxStore.getState().retry(nextQueuedMessage.messageId); + setThreadError(nextQueuedMessage.threadId, null); + }, [nextQueuedMessage, setThreadError]); + const onInterrupt = async () => { if (!activeThread) return; const result = await interruptThreadTurn({ @@ -6217,8 +6326,16 @@ function ChatViewContent(props: ChatViewProps) { phase={phase} isConnecting={isConnecting} isSendBusy={isSendBusy} - sendDisabledReason={threadDetailLoading ? "Messages loading" : null} + sendDisabledReason={ + !clientSettingsHydrated + ? "Settings loading" + : threadDetailLoading + ? "Messages loading" + : null + } isPreparingWorktree={isPreparingWorktree} + queuedMessageCount={activeThreadOutboxQueue.length} + queuedMessagesPaused={queuedMessagePaused} environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} @@ -6250,6 +6367,7 @@ function ChatViewContent(props: ChatViewProps) { composerTerminalContextsRef={composerTerminalContextsRef} composerElementContextsRef={composerElementContextsRef} onSend={onSend} + onRetryQueuedMessages={retryQueuedMessages} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} onRespondToApproval={onRespondToApproval} diff --git a/apps/web/src/components/WebThreadOutboxDrain.logic.test.ts b/apps/web/src/components/WebThreadOutboxDrain.logic.test.ts new file mode 100644 index 000000000000..5ea28d33e284 --- /dev/null +++ b/apps/web/src/components/WebThreadOutboxDrain.logic.test.ts @@ -0,0 +1,23 @@ +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { shouldPauseWebThreadOutboxDelivery } from "./WebThreadOutboxDrain.logic"; + +describe("shouldPauseWebThreadOutboxDelivery", () => { + it("defers interrupted commands so reconnect can retry them", () => { + expect(shouldPauseWebThreadOutboxDelivery(AsyncResult.failure(Cause.interrupt(1)))).toBe(false); + }); + + it("pauses definitive command failures", () => { + expect( + shouldPauseWebThreadOutboxDelivery( + AsyncResult.failure(Cause.fail(new Error("provider rejected the turn"))), + ), + ).toBe(true); + }); + + it("does not pause successful commands", () => { + expect(shouldPauseWebThreadOutboxDelivery(AsyncResult.success(undefined))).toBe(false); + }); +}); diff --git a/apps/web/src/components/WebThreadOutboxDrain.logic.ts b/apps/web/src/components/WebThreadOutboxDrain.logic.ts new file mode 100644 index 000000000000..79fa16f3331f --- /dev/null +++ b/apps/web/src/components/WebThreadOutboxDrain.logic.ts @@ -0,0 +1,10 @@ +import { + isAtomCommandInterrupted, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; + +export function shouldPauseWebThreadOutboxDelivery( + result: AtomCommandResult, +): 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),