From 98fcf1051fe1a2f9884f7d5861449901a4c664eb Mon Sep 17 00:00:00 2001 From: ClapFy Date: Wed, 5 Aug 2026 12:32:59 +0300 Subject: [PATCH 1/7] feat: add active-turn message controls --- .../features/settings/SettingsRouteScreen.tsx | 29 +++ .../src/features/threads/ThreadComposer.tsx | 10 +- .../features/threads/ThreadDetailScreen.tsx | 3 + .../features/threads/ThreadRouteScreen.tsx | 1 + .../src/persistence/mobile-preferences.ts | 9 + apps/mobile/src/state/thread-outbox-model.ts | 18 +- apps/mobile/src/state/thread-outbox.test.ts | 25 ++ .../src/state/use-thread-composer-state.ts | 11 +- .../src/state/use-thread-outbox-drain.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 97 +++++++ .../src/provider/Layers/CodexAdapter.test.ts | 27 ++ .../Layers/CodexSessionRuntime.test.ts | 45 +++- .../provider/Layers/CodexSessionRuntime.ts | 97 +++++-- apps/web/src/components/ChatView.tsx | 227 +++++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 48 +++- .../chat/ComposerPrimaryActions.tsx | 111 ++++---- .../components/settings/SettingsPanels.tsx | 54 ++++ .../settings/settingsSearch.test.ts | 6 +- .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/webThreadOutbox.test.ts | 155 +++++++++++ apps/web/src/webThreadOutbox.ts | 242 ++++++++++++++++++ packages/contracts/src/settings.test.ts | 16 ++ packages/contracts/src/settings.ts | 9 + 23 files changed, 1172 insertions(+), 74 deletions(-) create mode 100644 apps/web/src/webThreadOutbox.test.ts create mode 100644 apps/web/src/webThreadOutbox.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 8bfff6a87478..db0f77e2c9eb 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/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be38720..7ec8b77dd7bc 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; } @@ -154,6 +164,7 @@ export function resolveThreadOutboxDeliveryAction(input: { readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; readonly threadBusy: boolean; + readonly activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType; }): ThreadOutboxDeliveryAction { if (input.isCreation) { // A pending task creates its thread on delivery. If the thread already @@ -169,7 +180,8 @@ export function resolveThreadOutboxDeliveryAction(input: { if (!input.threadExists) { return input.shellStatus === "live" ? "remove" : "wait"; } - return input.environmentConnected && !input.threadBusy ? "send" : "wait"; + const canSendWhileBusy = input.activeTurnMessageBehavior === "steer" || !input.threadBusy; + 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..f0d42d82c518 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -487,6 +487,31 @@ describe("thread outbox", () => { ).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, + }; + + // 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("sends queued creations once connected and live, removing already-created ones", () => { expect( resolveThreadOutboxDeliveryAction({ diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b09aadf7e6b7..938cd29d479b 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 { 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(); @@ -164,6 +171,7 @@ export function useThreadComposerState() { modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: draft.interactionMode ?? thread.interactionMode, + activeTurnMessageBehavior, createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); @@ -179,7 +187,7 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [activeTurnMessageBehavior, selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { @@ -308,6 +316,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..8a7f01875aeb 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -315,6 +315,7 @@ export function useThreadOutboxDrain(): void { shellStatus, environmentConnected: environment?.connectionState === "connected", threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", + activeTurnMessageBehavior: nextQueuedMessage.activeTurnMessageBehavior, }); if (deliveryAction === "wait") { continue; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 8e65295b1bad..1cdbddb83c18 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..5c7f336f26ef 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -723,6 +723,33 @@ 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 retryable Codex error notifications to runtime.warning", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index d7346a0e0dbe..8ae374c8b052 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,12 @@ import { } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { + buildTurnSteerParams, buildTurnStartParams, hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, + resolveCodexSteeringTurnId, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -246,6 +248,47 @@ 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", + }, + ], + }, + ); + }); +}); + 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 57a1162dd08e..00e26ea9c8c6 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -88,6 +88,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 +359,48 @@ 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 buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; @@ -374,17 +417,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 +426,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), @@ -1758,9 +1790,44 @@ export const makeCodexSessionRuntime = ( ), ); } - const normalizedModel = normalizeCodexModelSlug( - input.model ?? (yield* Ref.get(sessionRef)).model, - ); + const session = yield* Ref.get(sessionRef); + const steeringTurnId = resolveCodexSteeringTurnId(session); + if (steeringTurnId) { + const response = yield* client.request( + "turn/steer", + buildTurnSteerParams({ + threadId: providerThreadId, + activeTurnId: steeringTurnId, + ...(input.input ? { prompt: input.input } : {}), + ...(input.attachments ? { attachments: input.attachments } : {}), + }), + ); + const turnId = TurnId.make(response.turnId); + yield* updateSession(sessionRef, { + status: "running", + activeTurnId: turnId, + }); + // 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 + // running turn instead of leaving a stale pending-turn row. + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "turn/started", + turnId, + message: "Codex turn steered.", + }); + 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, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8b510d457fda..057ef52f675c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -171,7 +171,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 +180,15 @@ import { useClientSettingsHydrated, useEnvironmentSettings, } from "../hooks/useSettings"; +import { + beginWebThreadOutboxDispatch, + EMPTY_WEB_THREAD_OUTBOX_QUEUE, + finishWebThreadOutboxDispatch, + shouldDrainWebThreadOutbox, + shouldQueueWebThreadMessage, + useWebThreadOutboxStore, + webThreadOutboxKey, +} from "../webThreadOutbox"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -1263,6 +1272,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. @@ -4800,9 +4815,16 @@ function ChatViewContent(props: ChatViewProps) { }), ); }; + const shouldQueueCurrentMessage = shouldQueueWebThreadMessage({ + activeTurnMessageBehavior: settings.activeTurnMessageBehavior, + hasQueuedMessages: activeThreadOutboxQueue.length > 0, + isSendBusy, + isServerThread, + phase, + }); if ( !activeThread || - isSendBusy || + (isSendBusy && !shouldQueueCurrentMessage) || isConnecting || threadDetailLoading || sendInFlightRef.current @@ -4954,6 +4976,98 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (shouldQueueCurrentMessage) { + sendInFlightRef.current = true; + const composerImagesSnapshot = [...composerImages]; + const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; + const composerElementContextsSnapshot = [...composerElementContexts]; + const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + 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 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") { + 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, + createdAt, + }); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + 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.", + }), + ); + } + sendInFlightRef.current = false; + return; + } + sendInFlightRef.current = true; if (isDraftHeroState && activeThreadKey) { let resolveDockStarted: (() => void) | undefined; @@ -5235,6 +5349,112 @@ function ChatViewContent(props: ChatViewProps) { } }; + const nextQueuedMessage = activeThreadOutboxQueue[0] ?? null; + const queuedMessagePaused = + nextQueuedMessage !== null && Boolean(pausedOutboxMessageIds[nextQueuedMessage.messageId]); + + useEffect(() => { + if ( + !nextQueuedMessage || + !activeThread || + nextQueuedMessage.environmentId !== environmentId || + nextQueuedMessage.threadId !== activeThread.id || + !shouldDrainWebThreadOutbox({ + phase, + isSendBusy, + isConnecting, + environmentUnavailable: activeEnvironmentUnavailable, + paused: queuedMessagePaused, + }) || + !beginWebThreadOutboxDispatch(nextQueuedMessage.messageId) + ) { + return; + } + + const deliver = async () => { + beginLocalDispatch({ preparingWorktree: false }); + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId: nextQueuedMessage.threadId, + createdAt: nextQueuedMessage.createdAt, + modelSelection: nextQueuedMessage.modelSelection, + runtimeMode: nextQueuedMessage.runtimeMode, + interactionMode: nextQueuedMessage.interactionMode, + }); + const startResult = + settingsResult._tag === "Failure" + ? settingsResult + : await startThreadTurn({ + environmentId: nextQueuedMessage.environmentId, + input: { + commandId: nextQueuedMessage.commandId, + threadId: nextQueuedMessage.threadId, + message: { + messageId: nextQueuedMessage.messageId, + role: "user", + text: nextQueuedMessage.text, + attachments: nextQueuedMessage.attachments, + }, + modelSelection: nextQueuedMessage.modelSelection, + titleSeed: activeThread.title, + runtimeMode: nextQueuedMessage.runtimeMode, + interactionMode: nextQueuedMessage.interactionMode, + createdAt: nextQueuedMessage.createdAt, + }, + }); + + if (startResult._tag === "Failure") { + useWebThreadOutboxStore.getState().pause(nextQueuedMessage.messageId); + resetLocalDispatch(); + if (!isAtomCommandInterrupted(startResult)) { + const error = squashAtomCommandFailure(startResult); + setThreadError( + nextQueuedMessage.threadId, + error instanceof Error ? error.message : "Failed to send the queued message.", + ); + } + return; + } + + const { durable } = useWebThreadOutboxStore.getState().remove(nextQueuedMessage); + if (!durable) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Queued message sent", + description: + "Browser storage could not save the queue update. Its stable command ID prevents a duplicate turn if it reappears after reload.", + }), + ); + } + }; + + void deliver().finally(() => { + finishWebThreadOutboxDispatch(nextQueuedMessage.messageId); + }); + }, [ + activeEnvironmentUnavailable, + activeThread, + beginLocalDispatch, + environmentId, + isConnecting, + isSendBusy, + nextQueuedMessage, + persistThreadSettingsForNextTurn, + phase, + queuedMessagePaused, + resetLocalDispatch, + setThreadError, + startThreadTurn, + ]); + + 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({ @@ -6213,6 +6433,8 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy={isSendBusy} sendDisabledReason={threadDetailLoading ? "Messages loading" : null} isPreparingWorktree={isPreparingWorktree} + queuedMessageCount={activeThreadOutboxQueue.length} + queuedMessagesPaused={queuedMessagePaused} environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} @@ -6244,6 +6466,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/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b8bacf4b6be2..4d308cd4c0c8 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,9 @@ 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 showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; @@ -1232,15 +1243,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 +2808,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } isPreparingWorktree={false} hasSendableContent={false} + activeTurnMessageBehavior={settings.activeTurnMessageBehavior} preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} @@ -3076,6 +3092,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } isPreparingWorktree={false} hasSendableContent={false} + activeTurnMessageBehavior={settings.activeTurnMessageBehavior} preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} @@ -3190,7 +3207,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 +3217,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 +3226,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.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 52d2556bbf90..ecf35f9c84bb 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,69 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } + const sendButton = ( + + ); + if (isRunning) { - return renderStopGenerationButton(false); + return ( +
+ {renderStopGenerationButton(false)} + {sendButton} +
+ ); } if (showPlanFollowUpPrompt) { @@ -208,48 +272,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 f9046f441064..58ce3ef84f88 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")).toEqual([]); + expect(searchSettings("work").map((item) => item.id)).toEqual(["messages-while-working"]); }); it("keeps catalog order for multiple title matches", () => { @@ -65,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 34fd4602f784..1991378b66fd 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -93,6 +93,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/webThreadOutbox.test.ts b/apps/web/src/webThreadOutbox.test.ts new file mode 100644 index 000000000000..efdee665fbcf --- /dev/null +++ b/apps/web/src/webThreadOutbox.test.ts @@ -0,0 +1,155 @@ +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, + 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", + createdAt: new Date(1_700_000_000_000 + index).toISOString(), + }; +} + +function resetOutbox(): void { + writeWebThreadOutboxStorageForTest(""); +} + +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("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("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 only from a ready, connected, unpaused thread", () => { + expect( + shouldDrainWebThreadOutbox({ + phase: "ready", + isSendBusy: false, + isConnecting: false, + environmentUnavailable: false, + paused: false, + }), + ).toBe(true); + expect( + shouldDrainWebThreadOutbox({ + phase: "running", + isSendBusy: false, + isConnecting: false, + environmentUnavailable: false, + paused: false, + }), + ).toBe(false); + expect( + shouldDrainWebThreadOutbox({ + phase: "ready", + isSendBusy: false, + isConnecting: false, + environmentUnavailable: false, + paused: true, + }), + ).toBe(false); + }); + + 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, + }; + + expect( + shouldQueueWebThreadMessage({ + ...activeThread, + activeTurnMessageBehavior: "queue", + }), + ).toBe(true); + expect( + shouldQueueWebThreadMessage({ + ...activeThread, + activeTurnMessageBehavior: "steer", + }), + ).toBe(false); + 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..f823d9edb1fa --- /dev/null +++ b/apps/web/src/webThreadOutbox.ts @@ -0,0 +1,242 @@ +import { + CommandId, + EnvironmentId, + MessageId, + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + type UploadChatAttachment, +} from "@t3tools/contracts"; +import type { ActiveTurnMessageBehavior } from "@t3tools/contracts/settings"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import * as Schema from "effect/Schema"; +import { create } from "zustand"; + +import { createMemoryStorage, type StateStorage } from "./lib/storage"; + +export const WEB_THREAD_OUTBOX_STORAGE_KEY = "t3code:thread-outbox:v1"; +const WEB_THREAD_OUTBOX_STORAGE_VERSION = 1; + +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, + 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 createdAt: string; +} + +const PersistedWebThreadOutboxState = Schema.Struct({ + queuesByThreadKey: Schema.Record(Schema.String, Schema.Array(QueuedWebThreadMessageSchema)), +}); +const decodePersistedState = Schema.decodeUnknownSync(PersistedWebThreadOutboxState); + +export function webThreadOutboxKey(environmentId: EnvironmentId, threadId: ThreadId): string { + return scopedThreadKey(scopeThreadRef(environmentId, threadId)); +} + +function readQueue( + queues: Record>, + threadKey: string, +): ReadonlyArray { + return Object.hasOwn(queues, threadKey) ? (queues[threadKey] ?? []) : []; +} + +function resolveBaseStorage(): { storage: StateStorage; 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: createMemoryStorage(), durable: false }; +} + +const { storage: baseOutboxStorage, durable: storageIsDurable } = resolveBaseStorage(); + +function persistQueues(queues: Record>): { + written: boolean; + durable: boolean; +} { + try { + baseOutboxStorage.setItem( + WEB_THREAD_OUTBOX_STORAGE_KEY, + JSON.stringify({ + version: WEB_THREAD_OUTBOX_STORAGE_VERSION, + state: { queuesByThreadKey: queues }, + }), + ); + return { written: true, durable: storageIsDurable }; + } catch (error) { + console.error("[THREAD-OUTBOX] Could not persist queued messages.", error); + return { written: false, durable: false }; + } +} + +function readPersistedQueues(): Record> | null { + try { + const raw = baseOutboxStorage.getItem(WEB_THREAD_OUTBOX_STORAGE_KEY); + if (typeof raw !== "string" || raw.length === 0) { + return null; + } + const parsed: unknown = JSON.parse(raw); + const state = (parsed as { state?: unknown } | null)?.state; + return state ? decodePersistedState(state).queuesByThreadKey : null; + } catch { + return null; + } +} + +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 threadKey = webThreadOutboxKey(message.environmentId, message.threadId); + const queues = get().queuesByThreadKey; + const queue = readQueue(queues, threadKey); + const nextQueue = [ + ...queue.filter((candidate) => candidate.messageId !== message.messageId), + message, + ]; + const next = { ...queues, [threadKey]: nextQueue }; + const persisted = persistQueues(next); + // Even when browser storage is blocked or full, retain the message for the + // current session. The caller reports that it is not reload-safe. + set({ queuesByThreadKey: next }); + return { durable: persisted.written && persisted.durable }; + }, + remove: (message) => { + const threadKey = webThreadOutboxKey(message.environmentId, message.threadId); + const queues = get().queuesByThreadKey; + const nextQueue = readQueue(queues, threadKey).filter( + (candidate) => candidate.messageId !== message.messageId, + ); + const next = { ...queues }; + if (nextQueue.length === 0) { + delete next[threadKey]; + } else { + next[threadKey] = nextQueue; + } + const persisted = persistQueues(next); + const pausedMessageIds = { ...get().pausedMessageIds }; + delete pausedMessageIds[message.messageId]; + // The command id is stable, so a removal that fails to persist can only + // cause an idempotent acknowledgement after reload, never a second turn. + set({ queuesByThreadKey: next, pausedMessageIds }); + return { durable: persisted.written && persisted.durable }; + }, + pause: (messageId) => { + set((state) => ({ + pausedMessageIds: { ...state.pausedMessageIds, [messageId]: true }, + })); + }, + retry: (messageId) => { + set((state) => { + if (!state.pausedMessageIds[messageId]) { + return state; + } + const pausedMessageIds = { ...state.pausedMessageIds }; + delete pausedMessageIds[messageId]; + return { pausedMessageIds }; + }); + }, +})); + +export const EMPTY_WEB_THREAD_OUTBOX_QUEUE: ReadonlyArray = []; + +{ + const persisted = readPersistedQueues(); + if (persisted) { + useWebThreadOutboxStore.setState({ queuesByThreadKey: persisted }); + } +} + +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 phase: "disconnected" | "connecting" | "ready" | "running"; + readonly isSendBusy: boolean; + readonly isConnecting: boolean; + readonly environmentUnavailable: boolean; + readonly paused: boolean; +}): boolean { + return ( + input.phase === "ready" && + !input.isSendBusy && + !input.isConnecting && + !input.environmentUnavailable && + !input.paused + ); +} + +export function shouldQueueWebThreadMessage(input: { + readonly activeTurnMessageBehavior: ActiveTurnMessageBehavior; + readonly hasQueuedMessages: boolean; + readonly isSendBusy: boolean; + readonly isServerThread: boolean; + readonly phase: "disconnected" | "connecting" | "ready" | "running"; +}): boolean { + return ( + input.isServerThread && + (input.hasQueuedMessages || + (input.activeTurnMessageBehavior === "queue" && + (input.phase === "running" || input.isSendBusy))) + ); +} + +export function writeWebThreadOutboxStorageForTest(raw: string): void { + baseOutboxStorage.setItem(WEB_THREAD_OUTBOX_STORAGE_KEY, raw); + useWebThreadOutboxStore.setState({ + queuesByThreadKey: readPersistedQueues() ?? {}, + pausedMessageIds: {}, + }); + dispatchingMessageIds.clear(); +} 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 17ae0e08683c..d0b0e680ed5e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -102,6 +102,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 @@ -111,6 +114,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( @@ -753,6 +760,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), From cbd462f766e3c63b286f37ac41bac89dae53e531 Mon Sep 17 00:00:00 2001 From: ClapFy Date: Wed, 5 Aug 2026 13:06:49 +0300 Subject: [PATCH 2/7] fix: address active-turn message review feedback --- apps/mobile/src/state/preferences.test.ts | 27 +++++++++ apps/mobile/src/state/preferences.ts | 41 +++++++++++++- apps/mobile/src/state/thread-outbox-model.ts | 14 +++++ apps/mobile/src/state/thread-outbox.test.ts | 23 ++++++++ .../src/state/use-thread-composer-state.ts | 10 +++- .../src/state/use-thread-outbox-drain.ts | 10 +++- apps/web/src/webThreadOutbox.test.ts | 52 ++++++++++++++++++ apps/web/src/webThreadOutbox.ts | 55 ++++++++++++++++++- 8 files changed, 224 insertions(+), 8 deletions(-) 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 7ec8b77dd7bc..35343ca79982 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -158,6 +158,20 @@ 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 activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType; +}): boolean { + return ( + input.deliveryAction === "send" && + !input.isCreation && + input.threadBusy && + input.activeTurnMessageBehavior !== "steer" + ); +} + export function resolveThreadOutboxDeliveryAction(input: { readonly isCreation: boolean; readonly threadExists: boolean; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index f0d42d82c518..8dd8aaad3568 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, @@ -512,6 +513,28 @@ describe("thread outbox", () => { ).toBe("send"); }); + it("keeps steer delivery eligible when a thread becomes busy during persistence", () => { + const input = { + deliveryAction: "send" as const, + isCreation: false, + threadBusy: true, + }; + + expect(shouldDeferConfirmedThreadOutboxDelivery(input)).toBe(true); + expect( + shouldDeferConfirmedThreadOutboxDelivery({ + ...input, + activeTurnMessageBehavior: "queue", + }), + ).toBe(true); + expect( + shouldDeferConfirmedThreadOutboxDelivery({ + ...input, + activeTurnMessageBehavior: "steer", + }), + ).toBe(false); + }); + it("sends queued creations once connected and live, removing already-created ones", () => { expect( resolveThreadOutboxDeliveryAction({ diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 938cd29d479b..8308773806be 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -43,7 +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 { mobilePreferencesAtom } from "./preferences"; +import { awaitActiveTurnMessageBehavior, mobilePreferencesAtom } from "./preferences"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -145,6 +145,10 @@ export function useThreadComposerState() { return null; } + const sendBehavior = await awaitActiveTurnMessageBehavior( + appAtomRegistry, + mobilePreferencesAtom, + ); const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); const thread = selectedThreadDetail ?? selectedThreadShell; @@ -171,7 +175,7 @@ export function useThreadComposerState() { modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: draft.interactionMode ?? thread.interactionMode, - activeTurnMessageBehavior, + activeTurnMessageBehavior: sendBehavior, createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); @@ -187,7 +191,7 @@ export function useThreadComposerState() { ); }); return messageId; - }, [activeTurnMessageBehavior, selectedThreadDetail, selectedThreadShell]); + }, [selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 8a7f01875aeb..eab20a63df55 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, @@ -376,7 +377,14 @@ 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, + activeTurnMessageBehavior: nextQueuedMessage.activeTurnMessageBehavior, + }) + ) { return true; } return deliveryAction === "remove" diff --git a/apps/web/src/webThreadOutbox.test.ts b/apps/web/src/webThreadOutbox.test.ts index efdee665fbcf..568d09cf4645 100644 --- a/apps/web/src/webThreadOutbox.test.ts +++ b/apps/web/src/webThreadOutbox.test.ts @@ -43,6 +43,17 @@ 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", () => { @@ -78,6 +89,47 @@ describe("web thread outbox", () => { expect(queue?.map((entry) => entry.messageId)).toEqual([second.messageId]); }); + it("merges another tab's durable messages before enqueueing", () => { + const first = message(1); + const second = message(2); + const third = message(3); + const store = useWebThreadOutboxStore.getState(); + store.enqueue(first); + + writeWebThreadOutboxStorageForTest(persistedOutbox([first, 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 messages when removing a delivered message", () => { + const first = message(1); + const second = message(2); + const store = useWebThreadOutboxStore.getState(); + store.enqueue(first); + + writeWebThreadOutboxStorageForTest(persistedOutbox([first, 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); diff --git a/apps/web/src/webThreadOutbox.ts b/apps/web/src/webThreadOutbox.ts index f823d9edb1fa..c0752bc7feeb 100644 --- a/apps/web/src/webThreadOutbox.ts +++ b/apps/web/src/webThreadOutbox.ts @@ -68,6 +68,30 @@ function readQueue( return Object.hasOwn(queues, threadKey) ? (queues[threadKey] ?? []) : []; } +function mergeQueues( + ...sources: ReadonlyArray>> +): Record> { + const merged: Record> = {}; + const threadKeys = new Set(sources.flatMap((source) => Object.keys(source))); + for (const threadKey of threadKeys) { + const messagesById = new Map(); + for (const source of sources) { + for (const message of readQueue(source, threadKey)) { + messagesById.set(message.messageId, message); + } + } + const queue = [...messagesById.values()].sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + String(left.messageId).localeCompare(String(right.messageId)), + ); + if (queue.length > 0) { + merged[threadKey] = queue; + } + } + return merged; +} + function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { try { if (typeof localStorage !== "undefined") { @@ -114,6 +138,13 @@ function readPersistedQueues(): Record>, +): Record> { + const persisted = readPersistedQueues(); + return persisted === null ? queues : mergeQueues(queues, persisted); +} + interface WebThreadOutboxState { readonly queuesByThreadKey: Record>; readonly pausedMessageIds: Readonly>; @@ -128,7 +159,10 @@ export const useWebThreadOutboxStore = create()((set, get) pausedMessageIds: {}, enqueue: (message) => { const threadKey = webThreadOutboxKey(message.environmentId, message.threadId); - const queues = get().queuesByThreadKey; + // Another tab may have updated the shared outbox since this store last + // rendered. Merge its durable snapshot before applying this mutation so a + // full-key localStorage write cannot discard the other tab's messages. + const queues = mergeWithPersistedQueues(get().queuesByThreadKey); const queue = readQueue(queues, threadKey); const nextQueue = [ ...queue.filter((candidate) => candidate.messageId !== message.messageId), @@ -143,7 +177,7 @@ export const useWebThreadOutboxStore = create()((set, get) }, remove: (message) => { const threadKey = webThreadOutboxKey(message.environmentId, message.threadId); - const queues = get().queuesByThreadKey; + const queues = mergeWithPersistedQueues(get().queuesByThreadKey); const nextQueue = readQueue(queues, threadKey).filter( (candidate) => candidate.messageId !== message.messageId, ); @@ -187,6 +221,15 @@ export const EMPTY_WEB_THREAD_OUTBOX_QUEUE: ReadonlyArray { + if (event.key !== WEB_THREAD_OUTBOX_STORAGE_KEY) { + return; + } + useWebThreadOutboxStore.setState({ queuesByThreadKey: readPersistedQueues() ?? {} }); + }); +} + const dispatchingMessageIds = new Set(); export function beginWebThreadOutboxDispatch(messageId: MessageId): boolean { @@ -232,8 +275,14 @@ export function shouldQueueWebThreadMessage(input: { ); } -export function writeWebThreadOutboxStorageForTest(raw: string): void { +export function writeWebThreadOutboxStorageForTest( + raw: string, + options?: { readonly syncStore?: boolean }, +): void { baseOutboxStorage.setItem(WEB_THREAD_OUTBOX_STORAGE_KEY, raw); + if (options?.syncStore === false) { + return; + } useWebThreadOutboxStore.setState({ queuesByThreadKey: readPersistedQueues() ?? {}, pausedMessageIds: {}, From 508098f4eda9c791089d56a0968c09eb8773d9be Mon Sep 17 00:00:00 2001 From: ClapFy Date: Sat, 8 Aug 2026 15:05:41 +0300 Subject: [PATCH 3/7] fix(chat): harden queued message delivery --- apps/mobile/src/state/thread-outbox-model.ts | 7 +- apps/mobile/src/state/thread-outbox.test.ts | 18 +- .../src/state/use-thread-composer-state.ts | 22 +- .../src/state/use-thread-outbox-drain.ts | 2 + .../Layers/CodexSessionRuntime.test.ts | 22 ++ .../provider/Layers/CodexSessionRuntime.ts | 162 ++++---- apps/web/src/components/ChatView.tsx | 240 ++++-------- .../src/components/WebThreadOutboxDrain.tsx | 189 ++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 1 - .../chat/ComposerPrimaryActions.test.ts | 2 + .../chat/ComposerPrimaryActions.tsx | 11 +- apps/web/src/composerDraftStore.test.ts | 24 ++ apps/web/src/composerDraftStore.ts | 55 +++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/webThreadOutbox.test.ts | 79 +++- apps/web/src/webThreadOutbox.ts | 357 ++++++++++++------ 16 files changed, 791 insertions(+), 402 deletions(-) create mode 100644 apps/web/src/components/WebThreadOutboxDrain.tsx diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 35343ca79982..22619959ac30 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -162,13 +162,14 @@ 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.activeTurnMessageBehavior === "steer" && input.threadSteerable) ); } @@ -178,6 +179,7 @@ export function resolveThreadOutboxDeliveryAction(input: { readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; readonly threadBusy: boolean; + readonly threadSteerable: boolean; readonly activeTurnMessageBehavior?: ActiveTurnMessageBehaviorType; }): ThreadOutboxDeliveryAction { if (input.isCreation) { @@ -194,7 +196,8 @@ export function resolveThreadOutboxDeliveryAction(input: { if (!input.threadExists) { return input.shellStatus === "live" ? "remove" : "wait"; } - const canSendWhileBusy = input.activeTurnMessageBehavior === "steer" || !input.threadBusy; + 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 8dd8aaad3568..833ece1bd2dc 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -466,6 +466,7 @@ describe("thread outbox", () => { shellStatus: "synchronizing", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("wait"); expect( @@ -475,6 +476,7 @@ describe("thread outbox", () => { shellStatus: "live", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("remove"); expect( @@ -484,6 +486,7 @@ describe("thread outbox", () => { shellStatus: "live", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("send"); }); @@ -495,6 +498,7 @@ describe("thread outbox", () => { shellStatus: "live" as const, environmentConnected: true, threadBusy: true, + threadSteerable: true, }; // Omitted preserves the behavior of outbox entries written by older mobile builds. @@ -513,11 +517,12 @@ describe("thread outbox", () => { ).toBe("send"); }); - it("keeps steer delivery eligible when a thread becomes busy during persistence", () => { + 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); @@ -533,6 +538,13 @@ describe("thread outbox", () => { 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", () => { @@ -543,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 @@ -554,6 +567,7 @@ describe("thread outbox", () => { shellStatus: "synchronizing", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("wait"); expect( @@ -563,6 +577,7 @@ describe("thread outbox", () => { shellStatus: "live", environmentConnected: true, threadBusy: false, + threadSteerable: false, }), ).toBe("send"); expect( @@ -572,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 8308773806be..0a28c670b076 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -145,7 +145,7 @@ export function useThreadComposerState() { return null; } - const sendBehavior = await awaitActiveTurnMessageBehavior( + const sendBehaviorPromise = awaitActiveTurnMessageBehavior( appAtomRegistry, mobilePreferencesAtom, ); @@ -160,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, @@ -178,7 +189,6 @@ export function useThreadComposerState() { 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 diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index eab20a63df55..950321610c2d 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -316,6 +316,7 @@ 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") { @@ -382,6 +383,7 @@ export function useThreadOutboxDrain(): void { deliveryAction, isCreation: creation !== undefined, threadBusy: freshThreadBusy, + threadSteerable: freshThread?.session?.status === "running", activeTurnMessageBehavior: nextQueuedMessage.activeTurnMessageBehavior, }) ) { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 8ae374c8b052..152fd0733ad4 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -21,6 +21,7 @@ import { isRecoverableThreadResumeError, openCodexThread, resolveCodexSteeringTurnId, + shouldRetryCodexSteerAsStart, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -287,6 +288,27 @@ describe("buildTurnSteerParams", () => { }, ); }); + + it("retries an explicitly rejected steer without retrying ambiguous transport failures", () => { + NodeAssert.equal( + shouldRetryCodexSteerAsStart( + new CodexErrors.CodexAppServerRequestError({ + code: -32602, + errorMessage: "expected turn is no longer active", + }), + ), + true, + ); + NodeAssert.equal( + shouldRetryCodexSteerAsStart( + new CodexErrors.CodexAppServerTransportError({ + operation: "read-input-stream", + cause: new Error("connection lost"), + }), + ), + false, + ); + }); }); describe("buildCodexDeveloperInstructions", () => { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 00e26ea9c8c6..5452fc49583c 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"; @@ -401,6 +402,13 @@ export function resolveCodexSteeringTurnId( return session.status === "running" ? session.activeTurnId : undefined; } +export function shouldRetryCodexSteerAsStart(error: CodexSessionRuntimeError): boolean { + // Request errors are explicit rejections, so the steer input was not + // accepted. Transport and process failures have an ambiguous outcome and + // must not be retried as a new turn. + return error._tag === "CodexAppServerRequestError"; +} + export function buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; @@ -890,6 +898,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 @@ -1779,44 +1788,92 @@ 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 })), + Effect.catch((error) => + shouldRetryCodexSteerAsStart(error) + ? Effect.succeed({ _tag: "RetryAsStart" as const }) + : Effect.fail(error), + ), + ); + if (steerResult._tag === "Steered") { + const turnId = TurnId.make(steerResult.value.turnId); + yield* updateSession(sessionRef, { + status: "running", + activeTurnId: turnId, + }); + // 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 + // running turn instead of leaving a stale pending-turn row. + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "turn/started", + turnId, + message: "Codex turn steered.", + }); + 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 session = yield* Ref.get(sessionRef); - const steeringTurnId = resolveCodexSteeringTurnId(session); - if (steeringTurnId) { - const response = yield* client.request( - "turn/steer", - buildTurnSteerParams({ - threadId: providerThreadId, - activeTurnId: steeringTurnId, - ...(input.input ? { prompt: input.input } : {}), - ...(input.attachments ? { attachments: input.attachments } : {}), - }), - ); - const turnId = TurnId.make(response.turnId); + const turnId = TurnId.make(response.turn.id); yield* updateSession(sessionRef, { status: "running", activeTurnId: turnId, - }); - // 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 - // running turn instead of leaving a stale pending-turn row. - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - method: "turn/started", - turnId, - message: "Codex turn steered.", + ...(normalizedModel ? { model: normalizedModel } : {}), }); const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); return { @@ -1826,43 +1883,8 @@ export const makeCodexSessionRuntime = ( ? { 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 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 057ef52f675c..65bd14d7bd41 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, @@ -181,10 +178,7 @@ import { useEnvironmentSettings, } from "../hooks/useSettings"; import { - beginWebThreadOutboxDispatch, EMPTY_WEB_THREAD_OUTBOX_QUEUE, - finishWebThreadOutboxDispatch, - shouldDrainWebThreadOutbox, shouldQueueWebThreadMessage, useWebThreadOutboxStore, webThreadOutboxKey, @@ -1298,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, @@ -4815,12 +4800,17 @@ 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 || @@ -4983,6 +4973,14 @@ function ChatViewContent(props: ChatViewProps) { 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, @@ -5002,6 +5000,9 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); const attachmentsResult = await settlePromise(() => Promise.all( composerImagesSnapshot.map(async (image) => ({ @@ -5014,6 +5015,7 @@ function ChatViewContent(props: ChatViewProps) { ), ); if (attachmentsResult._tag === "Failure") { + restoreComposerDraftContent(composerDraftTarget, composerContentSnapshot); const error = squashAtomCommandFailure(attachmentsResult); setThreadError( threadIdForSend, @@ -5035,11 +5037,9 @@ function ChatViewContent(props: ChatViewProps) { modelSelection: ctxSelectedModelSelection, runtimeMode, interactionMode, + activeTurnMessageBehavior: settings.activeTurnMessageBehavior, createdAt, }); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( @@ -5068,24 +5068,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - 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) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -5129,6 +5111,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. @@ -5171,10 +5173,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]; @@ -5297,41 +5295,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( @@ -5353,100 +5333,6 @@ function ChatViewContent(props: ChatViewProps) { const queuedMessagePaused = nextQueuedMessage !== null && Boolean(pausedOutboxMessageIds[nextQueuedMessage.messageId]); - useEffect(() => { - if ( - !nextQueuedMessage || - !activeThread || - nextQueuedMessage.environmentId !== environmentId || - nextQueuedMessage.threadId !== activeThread.id || - !shouldDrainWebThreadOutbox({ - phase, - isSendBusy, - isConnecting, - environmentUnavailable: activeEnvironmentUnavailable, - paused: queuedMessagePaused, - }) || - !beginWebThreadOutboxDispatch(nextQueuedMessage.messageId) - ) { - return; - } - - const deliver = async () => { - beginLocalDispatch({ preparingWorktree: false }); - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId: nextQueuedMessage.threadId, - createdAt: nextQueuedMessage.createdAt, - modelSelection: nextQueuedMessage.modelSelection, - runtimeMode: nextQueuedMessage.runtimeMode, - interactionMode: nextQueuedMessage.interactionMode, - }); - const startResult = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId: nextQueuedMessage.environmentId, - input: { - commandId: nextQueuedMessage.commandId, - threadId: nextQueuedMessage.threadId, - message: { - messageId: nextQueuedMessage.messageId, - role: "user", - text: nextQueuedMessage.text, - attachments: nextQueuedMessage.attachments, - }, - modelSelection: nextQueuedMessage.modelSelection, - titleSeed: activeThread.title, - runtimeMode: nextQueuedMessage.runtimeMode, - interactionMode: nextQueuedMessage.interactionMode, - createdAt: nextQueuedMessage.createdAt, - }, - }); - - if (startResult._tag === "Failure") { - useWebThreadOutboxStore.getState().pause(nextQueuedMessage.messageId); - resetLocalDispatch(); - if (!isAtomCommandInterrupted(startResult)) { - const error = squashAtomCommandFailure(startResult); - setThreadError( - nextQueuedMessage.threadId, - error instanceof Error ? error.message : "Failed to send the queued message.", - ); - } - return; - } - - const { durable } = useWebThreadOutboxStore.getState().remove(nextQueuedMessage); - if (!durable) { - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Queued message sent", - description: - "Browser storage could not save the queue update. Its stable command ID prevents a duplicate turn if it reappears after reload.", - }), - ); - } - }; - - void deliver().finally(() => { - finishWebThreadOutboxDispatch(nextQueuedMessage.messageId); - }); - }, [ - activeEnvironmentUnavailable, - activeThread, - beginLocalDispatch, - environmentId, - isConnecting, - isSendBusy, - nextQueuedMessage, - persistThreadSettingsForNextTurn, - phase, - queuedMessagePaused, - resetLocalDispatch, - setThreadError, - startThreadTurn, - ]); - const retryQueuedMessages = useCallback(() => { if (!nextQueuedMessage) { return; @@ -6431,7 +6317,13 @@ 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} diff --git a/apps/web/src/components/WebThreadOutboxDrain.tsx b/apps/web/src/components/WebThreadOutboxDrain.tsx new file mode 100644 index 000000000000..e5e7a6269e6d --- /dev/null +++ b/apps/web/src/components/WebThreadOutboxDrain.tsx @@ -0,0 +1,189 @@ +import { CommandId } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { resolveThreadMetadataUpdateForNextTurn } from "./ChatView.logic"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { useEnvironments } from "../state/environments"; +import { useThreadShells } from "../state/entities"; +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, + ); + if ( + !freshThread || + !shouldDrainWebThreadOutbox({ + sessionStatus: freshThread.session?.status ?? null, + environmentConnected: true, + 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") { + 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 4d308cd4c0c8..7b83fbdbcf3c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1153,7 +1153,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; const isPrimarySendBusy = isSendBusy && !(phase === "running" && settings.activeTurnMessageBehavior === "queue"); - const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; 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 ecf35f9c84bb..2d135e4c1481 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -154,10 +154,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({