diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 1516b7cbc73..d162ae7e236 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -63,6 +63,7 @@ import { import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; +import { useThreadModeSyncDrain } from "./state/use-thread-mode-sync-drain"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -293,12 +294,12 @@ function workspacePathFromState(state: NavigationState): string { return path.startsWith("/") ? path : `/${path}`; } -// The drain hook subscribes to the outbox, all thread shells, projects, and -// connection statuses. Hosting it in a null-rendering leaf keeps those -// updates from re-rendering RootStackLayout (and with it every screen) on -// each enqueue, shell change, or reconnect. -function ThreadOutboxDrainWorker() { +// The sync hooks subscribe to queued work, thread shells, and connection +// statuses. Hosting them in a null-rendering leaf keeps those updates from +// re-rendering RootStackLayout (and with it every screen). +function BackgroundSyncWorker() { useThreadOutboxDrain(); + useThreadModeSyncDrain(); return null; } @@ -337,7 +338,7 @@ function RootStackLayout(props: { return ( - + diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index fed97e81e08..5a12a7b266c 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { CommandId, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import { appAtomRegistry } from "./atom-registry"; import { @@ -66,6 +66,29 @@ describe("mobile composer drafts", () => { }); }); + it("preserves modes when hydrating an edited pending task", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "pending-task:message-1": { + text: "edit this queued task", + attachments: [], + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }, + }), + ).toEqual({ + "pending-task:message-1": { + text: "edit this queued task", + attachments: [], + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }); + }); + it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( decodePersistedComposerDrafts({ @@ -91,6 +114,67 @@ describe("mobile composer drafts", () => { ).toThrow(); }); + it("drops server-owned modes when hydrating an existing thread draft", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + text: "keep this draft", + attachments: [], + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }, + }), + ).toEqual({ + "environment-1:thread-1": { + text: "keep this draft", + attachments: [], + }, + }); + }); + + it("hydrates pending existing-thread mode changes for retry", () => { + const runtimeModeSync = { + value: "approval-required" as const, + commandId: CommandId.make("command-runtime"), + createdAt: "2026-08-06T10:00:00.000Z", + dispatchSequence: null, + }; + const interactionModeSync = { + value: "plan" as const, + commandId: CommandId.make("command-interaction"), + createdAt: "2026-08-06T10:00:01.000Z", + dispatchSequence: 12, + }; + + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + text: "keep this draft", + attachments: [], + runtimeMode: "full-access", + interactionMode: "default", + runtimeModeSync, + interactionModeSync, + }, + }, + }), + ).toEqual({ + "environment-1:thread-1": { + text: "keep this draft", + attachments: [], + runtimeMode: runtimeModeSync.value, + interactionMode: interactionModeSync.value, + runtimeModeSync, + interactionModeSync, + }, + }); + }); + it("clears sent content without clearing the selected model or workspace", () => { const draftKey = "environment-1:thread-1"; const draft: ComposerDraft = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 24fa547e272..9ae4b4f9918 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,6 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; import { + CommandId, + IsoDateTime, ModelSelection as ModelSelectionSchema, + NonNegativeInt, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, @@ -9,6 +12,7 @@ import { type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; +import type { PendingThreadModeSync } from "@t3tools/client-runtime/state/thread-mode-sync"; import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; @@ -44,6 +48,8 @@ export interface ComposerDraft { readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; + readonly runtimeModeSync?: PendingThreadModeSync; + readonly interactionModeSync?: PendingThreadModeSync; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } @@ -62,7 +68,12 @@ export interface ComposerDraftWorkspaceSelection { export type ComposerDraftSettingsUpdate = Pick< ComposerDraft, - "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" + | "modelSelection" + | "runtimeMode" + | "interactionMode" + | "runtimeModeSync" + | "interactionModeSync" + | "workspaceSelection" >; const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ @@ -72,6 +83,20 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ startFromOrigin: Schema.optional(Schema.Boolean), }); +const PendingRuntimeModeSyncSchema = Schema.Struct({ + value: RuntimeModeSchema, + commandId: CommandId, + createdAt: IsoDateTime, + dispatchSequence: Schema.NullOr(NonNegativeInt), +}); + +const PendingInteractionModeSyncSchema = Schema.Struct({ + value: ProviderInteractionModeSchema, + commandId: CommandId, + createdAt: IsoDateTime, + dispatchSequence: Schema.NullOr(NonNegativeInt), +}); + const ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), @@ -79,6 +104,8 @@ const ComposerDraftSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), + runtimeModeSync: Schema.optional(PendingRuntimeModeSyncSchema), + interactionModeSync: Schema.optional(PendingInteractionModeSyncSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), }); @@ -131,15 +158,38 @@ function isEmptyDraft(draft: ComposerDraft): boolean { draft.modelSelection === undefined && draft.runtimeMode === undefined && draft.interactionMode === undefined && + draft.runtimeModeSync === undefined && + draft.interactionModeSync === undefined && draft.workspaceSelection === undefined ); } export function decodePersistedComposerDrafts(value: unknown): Record { const parsed = decodePersistedComposerDraftsDocument(value); - return Object.fromEntries( - Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), - ); + const drafts: Record = {}; + for (const [draftKey, draft] of Object.entries(parsed.drafts)) { + let decodedDraft: ComposerDraft = draft; + if (!draftKey.startsWith("new-task:") && !draftKey.startsWith("pending-task:")) { + const { + runtimeMode: _runtimeMode, + interactionMode: _interactionMode, + runtimeModeSync, + interactionModeSync, + ...threadDraft + } = draft; + decodedDraft = { + ...threadDraft, + ...(runtimeModeSync ? { runtimeMode: runtimeModeSync.value, runtimeModeSync } : {}), + ...(interactionModeSync + ? { interactionMode: interactionModeSync.value, interactionModeSync } + : {}), + }; + } + if (!isEmptyDraft(decodedDraft)) { + drafts[draftKey] = decodedDraft; + } + } + return drafts; } async function getComposerDraftsFile() { diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 6fb41fc091f..2d218365ed4 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -27,13 +27,33 @@ const connectionPairingUrlAtom = Atom.make("").pipe( Atom.withLabel("mobile:connection-pairing-url"), ); -const pendingConnectionErrorAtom = Atom.make(null).pipe( +interface PendingConnectionError { + readonly id: number; + readonly message: string; +} + +let nextPendingConnectionErrorId = 0; + +const pendingConnectionErrorAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:pending-connection-error"), ); -export function setPendingConnectionError(message: string | null): void { - appAtomRegistry.set(pendingConnectionErrorAtom, message); +export function setPendingConnectionError(message: string | null): number | null { + if (message === null) { + appAtomRegistry.set(pendingConnectionErrorAtom, null); + return null; + } + const id = ++nextPendingConnectionErrorId; + appAtomRegistry.set(pendingConnectionErrorAtom, { id, message }); + return id; +} + +export function clearPendingConnectionError(id: number): void { + const pendingError = appAtomRegistry.get(pendingConnectionErrorAtom); + if (pendingError?.id === id) { + appAtomRegistry.set(pendingConnectionErrorAtom, null); + } } function toSavedConnection( @@ -147,7 +167,7 @@ export function useRemoteConnectionStatus() { return { connectedEnvironments, connectionState: workspace.state.connectionState, - connectionError: pendingConnectionError ?? workspace.state.connectionError, + connectionError: pendingConnectionError?.message ?? workspace.state.connectionError, }; } @@ -221,7 +241,7 @@ export function useRemoteConnections() { connectionPairingUrl, connectionState, connectionError, - pairingConnectionError: pendingConnectionError, + pairingConnectionError: pendingConnectionError?.message ?? null, connectedEnvironments, connectedEnvironmentCount: connectedEnvironments.length, onChangeConnectionPairingUrl, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index b09aadf7e6b..9b953f46f0c 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -11,6 +11,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { beginThreadModeSync } from "@t3tools/client-runtime/state/thread-mode-sync"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -22,6 +23,7 @@ import { import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed } from "../lib/threadActivity"; +import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "../state/atom-registry"; import { appendComposerDraftAttachments, @@ -281,22 +283,36 @@ export function useThreadComposerState() { const onUpdateRuntimeMode = useCallback( (value: RuntimeMode) => { - if (!selectedThreadKey) { + if (!selectedThreadKey || !selectedThreadShell || value === runtimeMode) { return; } - updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + updateComposerDraftSettings(selectedThreadKey, { + runtimeMode: value, + runtimeModeSync: beginThreadModeSync( + value, + CommandId.make(uuidv4()), + new Date().toISOString(), + ), + }); }, - [selectedThreadKey], + [runtimeMode, selectedThreadKey, selectedThreadShell], ); const onUpdateInteractionMode = useCallback( (value: ProviderInteractionMode) => { - if (!selectedThreadKey) { + if (!selectedThreadKey || !selectedThreadShell || value === interactionMode) { return; } - updateComposerDraftSettings(selectedThreadKey, { interactionMode: value }); + updateComposerDraftSettings(selectedThreadKey, { + interactionMode: value, + interactionModeSync: beginThreadModeSync( + value, + CommandId.make(uuidv4()), + new Date().toISOString(), + ), + }); }, - [selectedThreadKey], + [interactionMode, selectedThreadKey, selectedThreadShell], ); return { diff --git a/apps/mobile/src/state/use-thread-mode-sync-drain.ts b/apps/mobile/src/state/use-thread-mode-sync-drain.ts new file mode 100644 index 00000000000..545fe794608 --- /dev/null +++ b/apps/mobile/src/state/use-thread-mode-sync-drain.ts @@ -0,0 +1,261 @@ +import { useAtomValue } from "@effect/atom-react"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { + acknowledgeThreadModeSync, + failThreadModeSync, + recordThreadModeDispatch, + shouldDispatchThreadModeSync, + type PendingThreadModeSync, +} from "@t3tools/client-runtime/state/thread-mode-sync"; +import type { CommandId, DispatchResult, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + composerDraftsAtom, + ensureComposerDraftsLoaded, + getComposerDraftSnapshot, + type ComposerDraft, + updateComposerDraftSettings, +} from "./use-composer-drafts"; +import { environmentSnapshotAtom } from "./shell"; +import { shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs } from "./thread-outbox-model"; +import { threadEnvironment } from "./threads"; +import { useAtomCommand } from "./use-atom-command"; +import { + setPendingConnectionError, + useRemoteConnectionStatus, +} from "./use-remote-environment-registry"; + +const threadModeSyncShellSequencesAtom = Atom.make((get) => { + const sequences = new Map(); + for (const [threadKey, draft] of Object.entries(get(composerDraftsAtom))) { + if (draft.runtimeModeSync === undefined && draft.interactionModeSync === undefined) { + continue; + } + const threadRef = parseScopedThreadKey(threadKey); + if (threadRef === null || sequences.has(threadRef.environmentId)) { + continue; + } + const snapshot = get(environmentSnapshotAtom(threadRef.environmentId)); + sequences.set(threadRef.environmentId, snapshot?.snapshotSequence ?? null); + } + return sequences; +}).pipe(Atom.withLabel("mobile:thread-mode-sync:shell-sequences")); + +export function useThreadModeSyncDrain(): void { + const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + reportFailure: false, + }); + const drafts = useAtomValue(composerDraftsAtom); + const shellSequences = useAtomValue(threadModeSyncShellSequencesAtom); + const { connectedEnvironments } = useRemoteConnectionStatus(); + const [retryTick, setRetryTick] = useState(0); + const inFlightCommandIdsRef = useRef(new Set()); + const retryAttemptRef = useRef(new Map()); + const retryTimersRef = useRef(new Map>()); + const clearRetry = useCallback((commandId: CommandId) => { + retryAttemptRef.current.delete(commandId); + const timer = retryTimersRef.current.get(commandId); + if (timer !== undefined) { + clearTimeout(timer); + retryTimersRef.current.delete(commandId); + } + }, []); + const scheduleRetry = useCallback((commandId: CommandId) => { + const attempt = (retryAttemptRef.current.get(commandId) ?? 0) + 1; + retryAttemptRef.current.set(commandId, attempt); + const existingTimer = retryTimersRef.current.get(commandId); + if (existingTimer !== undefined) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + retryTimersRef.current.delete(commandId); + setRetryTick((current) => current + 1); + }, threadOutboxRetryDelayMs(attempt)); + retryTimersRef.current.set(commandId, timer); + }, []); + + useEffect(() => { + ensureComposerDraftsLoaded(); + return () => { + for (const timer of retryTimersRef.current.values()) { + clearTimeout(timer); + } + retryTimersRef.current.clear(); + }; + }, []); + + useEffect(() => { + const connectedEnvironmentIds = new Set(); + for (const environment of connectedEnvironments) { + if (environment.connectionState === "connected") { + connectedEnvironmentIds.add(environment.environmentId); + } + } + const reportTerminalFailure = (error: unknown, fallback: string) => { + setPendingConnectionError(error instanceof Error ? error.message : fallback); + }; + const dispatchModeSync = (input: { + readonly threadKey: string; + readonly pending: PendingThreadModeSync; + readonly dispatch: () => Promise>; + readonly readPending: (draft: ComposerDraft) => PendingThreadModeSync | undefined; + readonly clearPending: () => void; + readonly writePending: (pending: PendingThreadModeSync | null) => void; + readonly failureMessage: string; + }) => { + const { commandId } = input.pending; + inFlightCommandIdsRef.current.add(commandId); + void input + .dispatch() + .then((result) => { + const currentPending = + input.readPending(getComposerDraftSnapshot(input.threadKey)) ?? null; + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + const retryable = + isAtomCommandInterrupted(result) || shouldRetryThreadOutboxDelivery(error); + const next = failThreadModeSync(currentPending, commandId, retryable); + if (next !== currentPending) { + input.clearPending(); + clearRetry(commandId); + reportTerminalFailure(error, input.failureMessage); + } else if (currentPending?.commandId === commandId) { + scheduleRetry(commandId); + } else { + clearRetry(commandId); + } + return; + } + const next = recordThreadModeDispatch(currentPending, commandId, result.value.sequence); + if (next !== currentPending) { + input.writePending(next); + } + clearRetry(commandId); + }) + .finally(() => { + inFlightCommandIdsRef.current.delete(commandId); + }); + }; + + for (const [threadKey, draft] of Object.entries(drafts)) { + const threadRef = parseScopedThreadKey(threadKey); + if (threadRef === null) { + continue; + } + + const appliedSequence = shellSequences.get(threadRef.environmentId) ?? null; + const runtimeModeSync = draft.runtimeModeSync ?? null; + const interactionModeSync = draft.interactionModeSync ?? null; + const runtimeAcknowledged = + runtimeModeSync !== null && + acknowledgeThreadModeSync(runtimeModeSync, appliedSequence) === null; + const interactionAcknowledged = + interactionModeSync !== null && + acknowledgeThreadModeSync(interactionModeSync, appliedSequence) === null; + if (runtimeAcknowledged || interactionAcknowledged) { + updateComposerDraftSettings(threadKey, { + ...(runtimeAcknowledged ? { runtimeMode: undefined, runtimeModeSync: undefined } : {}), + ...(interactionAcknowledged + ? { interactionMode: undefined, interactionModeSync: undefined } + : {}), + }); + if (runtimeAcknowledged && runtimeModeSync !== null) { + clearRetry(runtimeModeSync.commandId); + } + if (interactionAcknowledged && interactionModeSync !== null) { + clearRetry(interactionModeSync.commandId); + } + continue; + } + + const environmentConnected = connectedEnvironmentIds.has(threadRef.environmentId); + + if ( + runtimeModeSync !== null && + shouldDispatchThreadModeSync( + runtimeModeSync, + environmentConnected, + inFlightCommandIdsRef.current.has(runtimeModeSync.commandId), + ) + ) { + dispatchModeSync({ + threadKey, + pending: runtimeModeSync, + dispatch: () => + setThreadRuntimeMode({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + runtimeMode: runtimeModeSync.value, + commandId: runtimeModeSync.commandId, + createdAt: runtimeModeSync.createdAt, + }, + }), + readPending: (current) => current.runtimeModeSync, + clearPending: () => + updateComposerDraftSettings(threadKey, { + runtimeMode: undefined, + runtimeModeSync: undefined, + }), + writePending: (next) => + updateComposerDraftSettings(threadKey, { runtimeModeSync: next ?? undefined }), + failureMessage: "Failed to change access mode.", + }); + } + + if ( + interactionModeSync !== null && + shouldDispatchThreadModeSync( + interactionModeSync, + environmentConnected, + inFlightCommandIdsRef.current.has(interactionModeSync.commandId), + ) + ) { + dispatchModeSync({ + threadKey, + pending: interactionModeSync, + dispatch: () => + setThreadInteractionMode({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + interactionMode: interactionModeSync.value, + commandId: interactionModeSync.commandId, + createdAt: interactionModeSync.createdAt, + }, + }), + readPending: (current) => current.interactionModeSync, + clearPending: () => + updateComposerDraftSettings(threadKey, { + interactionMode: undefined, + interactionModeSync: undefined, + }), + writePending: (next) => + updateComposerDraftSettings(threadKey, { + interactionModeSync: next ?? undefined, + }), + failureMessage: "Failed to change interaction mode.", + }); + } + } + }, [ + clearRetry, + connectedEnvironments, + drafts, + retryTick, + scheduleRetry, + setThreadInteractionMode, + setThreadRuntimeMode, + shellSequences, + ]); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c260c9e9118..d19fa63266d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -65,6 +65,13 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { + acknowledgeThreadModeSync, + beginThreadModeSync, + failThreadModeSync, + recordThreadModeDispatch, + type PendingThreadModeSync, +} from "@t3tools/client-runtime/state/thread-mode-sync"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; @@ -173,7 +180,7 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; @@ -230,7 +237,7 @@ import { useThreadRefs, useThreadShell, } from "../state/entities"; -import { environmentShell } from "../state/shell"; +import { environmentShell, environmentSnapshotAtom } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; @@ -1152,6 +1159,26 @@ function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +function usePendingThreadModeSync() { + const [pending, setPendingState] = useState | null>(null); + const pendingRef = useRef(pending); + const updatePending = useCallback( + ( + update: (current: PendingThreadModeSync | null) => PendingThreadModeSync | null, + ) => { + const previous = pendingRef.current; + const next = update(previous); + if (next !== previous) { + pendingRef.current = next; + setPendingState(next); + } + return { previous, next }; + }, + [], + ); + return [pending, updatePending] as const; +} + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -1189,6 +1216,11 @@ function ChatViewContent(props: ChatViewProps) { const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { reportFailure: false, }); + const [pendingRuntimeModeSync, updatePendingRuntimeModeSync] = + usePendingThreadModeSync(); + const [pendingInteractionModeSync, updatePendingInteractionModeSync] = + usePendingThreadModeSync(); + const environmentSnapshot = useAtomValue(environmentSnapshotAtom(environmentId)); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, @@ -1471,6 +1503,57 @@ function ChatViewContent(props: ChatViewProps) { const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const serverModeThreadId = activeThreadId ?? (routeKind === "server" ? threadId : null); + const appliedShellSequence = environmentSnapshot?.snapshotSequence ?? null; + + useLayoutEffect(() => { + updatePendingRuntimeModeSync(() => null); + updatePendingInteractionModeSync(() => null); + if (routeKind !== "server") return; + setComposerDraftRuntimeMode(composerDraftTarget, null); + setComposerDraftInteractionMode(composerDraftTarget, null); + }, [ + composerDraftTarget, + routeKind, + routeThreadKey, + setComposerDraftInteractionMode, + setComposerDraftRuntimeMode, + updatePendingInteractionModeSync, + updatePendingRuntimeModeSync, + ]); + + useEffect(() => { + if (routeKind !== "server") return; + const runtimeTransition = updatePendingRuntimeModeSync((current) => + acknowledgeThreadModeSync(current, appliedShellSequence), + ); + if (runtimeTransition.previous !== null && runtimeTransition.next === null) { + const currentDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + if (currentDraft?.runtimeMode === runtimeTransition.previous.value) { + setComposerDraftRuntimeMode(composerDraftTarget, null); + } + } + const interactionTransition = updatePendingInteractionModeSync((current) => + acknowledgeThreadModeSync(current, appliedShellSequence), + ); + if (interactionTransition.previous !== null && interactionTransition.next === null) { + const currentDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + if (currentDraft?.interactionMode === interactionTransition.previous.value) { + setComposerDraftInteractionMode(composerDraftTarget, null); + } + } + }, [ + appliedShellSequence, + composerDraftTarget, + pendingInteractionModeSync, + pendingRuntimeModeSync, + routeKind, + setComposerDraftInteractionMode, + setComposerDraftRuntimeMode, + updatePendingInteractionModeSync, + updatePendingRuntimeModeSync, + ]); + const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -3087,16 +3170,57 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftRuntimeMode(composerDraftTarget, mode); if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); + } else if (serverModeThreadId) { + const pending = beginThreadModeSync(mode, newCommandId(), new Date().toISOString()); + updatePendingRuntimeModeSync(() => pending); + void setThreadRuntimeMode({ + environmentId, + input: { + threadId: serverModeThreadId, + runtimeMode: mode, + commandId: pending.commandId, + createdAt: pending.createdAt, + }, + }).then((result) => { + if (result._tag === "Success") { + updatePendingRuntimeModeSync((current) => + recordThreadModeDispatch(current, pending.commandId, result.value.sequence), + ); + return; + } + const transition = updatePendingRuntimeModeSync((current) => + failThreadModeSync(current, pending.commandId, false), + ); + if (transition.previous?.commandId !== pending.commandId) return; + if ( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.runtimeMode === + transition.previous.value + ) { + setComposerDraftRuntimeMode(composerDraftTarget, null); + } + if (isAtomCommandInterrupted(result)) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not change access mode", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + }); } scheduleComposerFocus(); }, [ + composerDraftTarget, + environmentId, isLocalDraftThread, runtimeMode, scheduleComposerFocus, - composerDraftTarget, + serverModeThreadId, setComposerDraftRuntimeMode, setDraftThreadContext, + setThreadRuntimeMode, + updatePendingRuntimeModeSync, ], ); @@ -3106,16 +3230,57 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftInteractionMode(composerDraftTarget, mode); if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); + } else if (serverModeThreadId) { + const pending = beginThreadModeSync(mode, newCommandId(), new Date().toISOString()); + updatePendingInteractionModeSync(() => pending); + void setThreadInteractionMode({ + environmentId, + input: { + threadId: serverModeThreadId, + interactionMode: mode, + commandId: pending.commandId, + createdAt: pending.createdAt, + }, + }).then((result) => { + if (result._tag === "Success") { + updatePendingInteractionModeSync((current) => + recordThreadModeDispatch(current, pending.commandId, result.value.sequence), + ); + return; + } + const transition = updatePendingInteractionModeSync((current) => + failThreadModeSync(current, pending.commandId, false), + ); + if (transition.previous?.commandId !== pending.commandId) return; + if ( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) + ?.interactionMode === transition.previous.value + ) { + setComposerDraftInteractionMode(composerDraftTarget, null); + } + if (isAtomCommandInterrupted(result)) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not change interaction mode", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + }); } scheduleComposerFocus(); }, [ + composerDraftTarget, + environmentId, interactionMode, isLocalDraftThread, scheduleComposerFocus, - composerDraftTarget, + serverModeThreadId, setComposerDraftInteractionMode, setDraftThreadContext, + setThreadInteractionMode, + updatePendingInteractionModeSync, ], ); const toggleInteractionMode = useCallback(() => { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 19822b8b7ee..9477627ecc1 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1649,6 +1649,22 @@ describe("composerDraftStore runtime and interaction settings", () => { expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); }); + + it("keeps unsent content when confirmed mode overrides are cleared", () => { + const store = useComposerDraftStore.getState(); + + store.setPrompt(threadRef, "keep this draft"); + store.setRuntimeMode(threadRef, "approval-required"); + store.setInteractionMode(threadRef, "plan"); + store.setRuntimeMode(threadRef, null); + store.setInteractionMode(threadRef, null); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ + prompt: "keep this draft", + runtimeMode: null, + interactionMode: null, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1daa871652..28018fd7a2b 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -135,6 +135,10 @@ "types": "./src/state/threadSettled.ts", "default": "./src/state/threadSettled.ts" }, + "./state/thread-mode-sync": { + "types": "./src/state/threadModeSync.ts", + "default": "./src/state/threadModeSync.ts" + }, "./state/thread-search": { "types": "./src/state/threadSearch.ts", "default": "./src/state/threadSearch.ts" diff --git a/packages/client-runtime/src/state/threadModeSync.test.ts b/packages/client-runtime/src/state/threadModeSync.test.ts new file mode 100644 index 00000000000..309ffe8e988 --- /dev/null +++ b/packages/client-runtime/src/state/threadModeSync.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId } from "@t3tools/contracts"; + +import { + acknowledgeThreadModeSync, + beginThreadModeSync, + failThreadModeSync, + recordThreadModeDispatch, + shouldDispatchThreadModeSync, +} from "./threadModeSync.ts"; + +const commandId = (value: string) => CommandId.make(value); + +describe("thread mode sync", () => { + it("keeps the latest A -> B -> A override until its own sequence is applied", () => { + const first = beginThreadModeSync("approval-required", commandId("command-1"), "created-1"); + const latest = beginThreadModeSync("full-access", commandId("command-2"), "created-2"); + + expect(recordThreadModeDispatch(latest, first.commandId, 10)).toBe(latest); + + const dispatched = recordThreadModeDispatch(latest, latest.commandId, 12); + expect(acknowledgeThreadModeSync(dispatched, 11)).toBe(dispatched); + expect(acknowledgeThreadModeSync(dispatched, 12)).toBeNull(); + }); + + it("clears an override when a later coalesced shell sequence is applied", () => { + const pending = recordThreadModeDispatch( + beginThreadModeSync("approval-required", commandId("command-1"), "created-1"), + commandId("command-1"), + 10, + ); + + expect(acknowledgeThreadModeSync(pending, 11)).toBeNull(); + }); + + it("retains retryable failures and accepts a same-command retry", () => { + const pending = beginThreadModeSync("approval-required", commandId("command-1"), "created-1"); + + expect(failThreadModeSync(pending, pending.commandId, true)).toBe(pending); + expect(shouldDispatchThreadModeSync(pending, false, false)).toBe(false); + expect(shouldDispatchThreadModeSync(pending, true, true)).toBe(false); + expect(shouldDispatchThreadModeSync(pending, true, false)).toBe(true); + expect(recordThreadModeDispatch(pending, pending.commandId, 10)).toEqual({ + ...pending, + dispatchSequence: 10, + }); + }); + + it("does not dispatch again after the command sequence is known", () => { + const pending = recordThreadModeDispatch( + beginThreadModeSync("approval-required", commandId("command-1"), "created-1"), + commandId("command-1"), + 10, + ); + + expect(shouldDispatchThreadModeSync(pending, true, false)).toBe(false); + }); + + it("ignores stale failures and clears a matching terminal failure", () => { + const pending = beginThreadModeSync("approval-required", commandId("command-2"), "created-2"); + + expect(failThreadModeSync(pending, commandId("command-1"), false)).toBe(pending); + expect(failThreadModeSync(pending, pending.commandId, false)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/threadModeSync.ts b/packages/client-runtime/src/state/threadModeSync.ts new file mode 100644 index 00000000000..ab4e0ebe9af --- /dev/null +++ b/packages/client-runtime/src/state/threadModeSync.ts @@ -0,0 +1,61 @@ +import type { CommandId, IsoDateTime } from "@t3tools/contracts"; + +export interface PendingThreadModeSync { + readonly value: Value; + readonly commandId: CommandId; + readonly createdAt: IsoDateTime; + readonly dispatchSequence: number | null; +} + +export function beginThreadModeSync( + value: Value, + commandId: CommandId, + createdAt: IsoDateTime, +): PendingThreadModeSync { + return { value, commandId, createdAt, dispatchSequence: null }; +} + +export function recordThreadModeDispatch( + pending: PendingThreadModeSync | null, + commandId: CommandId, + sequence: number, +): PendingThreadModeSync | null { + if (pending === null || pending.commandId !== commandId) { + return pending; + } + return { ...pending, dispatchSequence: sequence }; +} + +export function failThreadModeSync( + pending: PendingThreadModeSync | null, + commandId: CommandId, + retryable: boolean, +): PendingThreadModeSync | null { + if (pending === null || pending.commandId !== commandId || retryable) { + return pending; + } + return null; +} + +export function acknowledgeThreadModeSync( + pending: PendingThreadModeSync | null, + appliedSequence: number | null, +): PendingThreadModeSync | null { + if ( + pending === null || + pending.dispatchSequence === null || + appliedSequence === null || + appliedSequence < pending.dispatchSequence + ) { + return pending; + } + return null; +} + +export function shouldDispatchThreadModeSync( + pending: PendingThreadModeSync | null, + connected: boolean, + inFlight: boolean, +): boolean { + return pending !== null && pending.dispatchSequence === null && connected && !inFlight; +}