diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 92c13c20070..ce24198f5e2 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -30,7 +30,9 @@ import { resolveProviderOptionDescriptors, } from "../../lib/providerOptions"; import { buildThreadRoutePath } from "../../lib/routes"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { useProjects } from "../../state/entities"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; @@ -63,6 +65,7 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const promptInputRef = useRef(null); + const loadedBranchesProjectKeyRef = useRef(null); const borderColor = useThemeColor("--color-border"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; @@ -78,6 +81,12 @@ export function NewTaskDraftScreen(props: { ) ?? null; if (directProject) { + if ( + selectedProject?.environmentId === directProject.environmentId && + selectedProject.id === directProject.id + ) { + return; + } setProject(directProject); return; } @@ -105,10 +114,16 @@ export function NewTaskDraftScreen(props: { useEffect(() => { if (!selectedProject) { + loadedBranchesProjectKeyRef.current = null; + return; + } + const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`; + if (loadedBranchesProjectKeyRef.current === projectKey) { return; } + loadedBranchesProjectKeyRef.current = projectKey; void flow.loadBranches(); - }, [flow, selectedProject]); + }, [flow.loadBranches, selectedProject]); useEffect(() => { if (!selectedProject) { @@ -292,11 +307,7 @@ export function NewTaskDraftScreen(props: { if (!event.startsWith("model:")) { return; } - // Defer state update so the native menu dismiss animation completes - // before re-rendering the menu actions (prevents submenu jump). - setTimeout(() => { - flow.setSelectedModelKey(event.slice("model:".length)); - }, 150); + flow.setSelectedModelKey(event.slice("model:".length)); } function handleEnvironmentMenuAction(event: string) { @@ -366,27 +377,42 @@ export function NewTaskDraftScreen(props: { ); async function handleStart(): Promise { + const selectedProject = flow.selectedProject; + if (!selectedProject) { + return; + } + const draft = getComposerDraftSnapshot( + `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`, + ); + const modelSelection = draft.modelSelection ?? flow.selectedModel; + const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; + const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; + const selectedWorktreePath = + draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; + const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; + const interactionMode = draft.interactionMode ?? flow.interactionMode; + const initialMessageText = draft.text.trim(); + if ( - !flow.selectedProject || - !flow.selectedModel || - flow.prompt.trim().length === 0 || + !modelSelection || + initialMessageText.length === 0 || flow.submitting || - (flow.workspaceMode === "worktree" && !flow.selectedBranchName) + (workspaceMode === "worktree" && !selectedBranchName) ) { return; } flow.setSubmitting(true); const result = await createProjectThread({ - project: flow.selectedProject, - modelSelection: flow.selectedModel, - envMode: flow.workspaceMode, - branch: flow.selectedBranchName, - worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, - runtimeMode: flow.runtimeMode, - interactionMode: flow.interactionMode, - initialMessageText: flow.prompt.trim(), - initialAttachments: flow.attachments, + project: selectedProject, + modelSelection, + envMode: workspaceMode, + branch: selectedBranchName, + worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, + runtimeMode, + interactionMode, + initialMessageText, + initialAttachments: draft.attachments, }); flow.setSubmitting(false); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7bb74ae88ff..f8c916974e5 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,13 +1,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useState } from "react"; import * as Option from "effect/Option"; -import { - EnvironmentId, - type ModelSelection, - type ProjectScript, - type ProviderInteractionMode, - type RuntimeMode, -} from "@t3tools/contracts"; +import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Pressable, ScrollView, Text as RNText, View } from "react-native"; import { useWorkspaceState } from "../../state/workspace"; @@ -79,18 +73,6 @@ export function ThreadRouteScreen() { const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); - const updateThreadMetadata = useAtomCommand( - threadEnvironment.updateMetadata, - "thread metadata update", - ); - const setThreadRuntimeMode = useAtomCommand( - threadEnvironment.setRuntimeMode, - "thread runtime mode", - ); - const setThreadInteractionMode = useAtomCommand( - threadEnvironment.setInteractionMode, - "thread interaction mode", - ); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const router = useRouter(); const params = useLocalSearchParams<{ @@ -105,6 +87,18 @@ export function ThreadRouteScreen() { const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; + const selectedThreadWithDraftSettings = useMemo( + () => + selectedThread + ? { + ...selectedThread, + modelSelection: composer.modelSelection ?? selectedThread.modelSelection, + runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode, + interactionMode: composer.interactionMode ?? selectedThread.interactionMode, + } + : null, + [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread], + ); /* ─── Native header theming ──────────────────────────────────────── */ const iconColor = String(useThemeColor("--color-icon")); @@ -157,51 +151,6 @@ export function ThreadRouteScreen() { const handleOpenConnectionEditor = useCallback(() => { void router.push("/connections"); }, [router]); - const handleUpdateThreadModelSelection = useCallback( - (modelSelection: ModelSelection) => { - if (!selectedThread) { - return; - } - return updateThreadMetadata({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - modelSelection, - }, - }); - }, - [selectedThread, updateThreadMetadata], - ); - const handleUpdateThreadRuntimeMode = useCallback( - (runtimeMode: RuntimeMode) => { - if (!selectedThread) { - return; - } - return setThreadRuntimeMode({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - runtimeMode, - }, - }); - }, - [selectedThread, setThreadRuntimeMode], - ); - const handleUpdateThreadInteractionMode = useCallback( - (interactionMode: ProviderInteractionMode) => { - if (!selectedThread) { - return; - } - return setThreadInteractionMode({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - interactionMode, - }, - }); - }, - [selectedThread, setThreadInteractionMode], - ); const handleStopThread = useCallback(() => { if ( !selectedThread || @@ -435,7 +384,7 @@ export function ThreadRouteScreen() { (null); - const [selectedModelKey, setSelectedModelKey] = useState(null); - const [workspaceMode, setWorkspaceMode] = useState("local"); - const [selectedBranchName, setSelectedBranchName] = useState(null); - const [selectedWorktreePath, setSelectedWorktreePath] = useState(null); - const branchLoadVersionRef = useRef(0); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const [runtimeMode, setRuntimeMode] = useState(DEFAULT_RUNTIME_MODE); - const [interactionMode, setInteractionMode] = useState( - DEFAULT_PROVIDER_INTERACTION_MODE, - ); - const [modelSelectionOverrides, setModelSelectionOverrides] = useState< - Record - >({}); const [expandedProvider, setExpandedProvider] = useState(null); const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); - setSelectedModelKey(null); - setWorkspaceMode("local"); - setSelectedBranchName(null); - setSelectedWorktreePath(null); setSubmitting(false); setBranchQuery(""); - setRuntimeMode(DEFAULT_RUNTIME_MODE); - setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); - setModelSelectionOverrides({}); setExpandedProvider(null); }, []); @@ -247,33 +229,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; + const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; + const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; + const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, ), - [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection], + [ + selectedEnvironmentServerConfig, + selectedProject?.defaultModelSelection, + selectedProjectDraft.modelSelection, + ], ); - const defaultModelKey = selectedProject?.defaultModelSelection - ? `${selectedProject.defaultModelSelection.instanceId}:${selectedProject.defaultModelSelection.model}` - : null; - const baseSelectedModel = - modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? - (defaultModelKey - ? modelOptions.find((option) => option.key === defaultModelKey)?.selection - : null) ?? + const selectedModel = + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? modelOptions[0]?.selection ?? null; - const selectedModelIdentity = baseSelectedModel - ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}` + const selectedModelKey = selectedModel + ? `${selectedModel.instanceId}:${selectedModel.model}` : null; - const selectedModel = - (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ?? - baseSelectedModel; const selectedModelOption = modelOptions.find( @@ -282,13 +264,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = - selectedEnvironmentServerConfig?.providers.find( - (provider) => provider.instanceId === selectedModel?.instanceId, - )?.skills ?? []; + const selectedProviderSkills = useMemo( + () => + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + )?.skills ?? [], + [selectedEnvironmentServerConfig, selectedModel?.instanceId], + ); + const setSelectedModelKey = useCallback( + (key: string | null) => { + if (!key || !selectedProjectDraftKey) { + return; + } + const option = modelOptions.find((candidate) => candidate.key === key); + if (!option) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: option.selection, + }); + }, + [modelOptions, selectedProjectDraftKey], + ); const setSelectedModelOptions = useCallback( (options: ReadonlyArray | undefined) => { - if (!selectedModel || !selectedModelIdentity) { + if (!selectedModel || !selectedProjectDraftKey) { return; } const nextSelection: ModelSelection = options @@ -297,12 +297,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { instanceId: selectedModel.instanceId, model: selectedModel.model, }; - setModelSelectionOverrides((current) => ({ - ...current, - [selectedModelIdentity]: nextSelection, - })); + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: nextSelection, + }); }, - [selectedModel, selectedModelIdentity], + [selectedModel, selectedProjectDraftKey], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); @@ -381,62 +380,85 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const setProject = useCallback((project: EnvironmentProject) => { const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(project.environmentId); setSelectedProjectKey(nextProjectKey); - setSelectedBranchName(null); - setSelectedWorktreePath(null); - setModelSelectionOverrides({}); }, []); const selectEnvironment = useCallback((environmentId: EnvironmentId) => { - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(environmentId); setSelectedProjectKey(null); - setSelectedBranchName(null); - setSelectedWorktreePath(null); - setModelSelectionOverrides({}); }, []); + const setWorkspaceMode = useCallback( + (mode: WorkspaceMode) => { + if (!selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode, + branch: selectedBranchName, + worktreePath: selectedWorktreePath, + }, + }); + }, + [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], + ); + const selectBranch = useCallback( (branch: VcsRef) => { - setSelectedBranchName(branch.name); - setSelectedWorktreePath( - selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null, - ); + if (!selectedProject || !selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: workspaceMode, + branch: branch.name, + worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), + }, + }); }, - [selectedProject], + [selectedProject, selectedProjectDraftKey, workspaceMode], ); + const refreshBranches = branchState.refresh; const loadBranches = useCallback(async () => { if (!selectedProject) { return; } + setPendingConnectionError(null); + refreshBranches(); + }, [refreshBranches, selectedProject]); - const loadVersion = ++branchLoadVersionRef.current; - const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); - branchState.refresh(); - if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { + useEffect(() => { + if (workspaceMode !== "worktree" || selectedBranchName !== null) { return; } - setPendingConnectionError(null); - if (workspaceMode === "worktree" && !selectedBranchName) { - const preferredBranch = - availableBranches.find((branch) => branch.current)?.name ?? - availableBranches.find((branch) => branch.isDefault)?.name ?? - null; - if (preferredBranch) { - setSelectedBranchName(preferredBranch); - } + const preferredBranch = + availableBranches.find((branch) => branch.current) ?? + availableBranches.find((branch) => branch.isDefault) ?? + null; + if (preferredBranch) { + selectBranch(preferredBranch); } - }, [ - availableBranches, - branchState, - selectedBranchName, - selectedProject, - selectedProjectKey, - workspaceMode, - ]); + }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); + + const setRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); + } + }, + [selectedProjectDraftKey], + ); + const setInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value }); + } + }, + [selectedProjectDraftKey], + ); const value = useMemo( () => ({ @@ -513,6 +535,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setProject, selectBranch, selectEnvironment, + setInteractionMode, + setPrompt, + setRuntimeMode, + setSelectedModelKey, + setWorkspaceMode, submitting, workspaceMode, appendAttachments, diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts new file mode 100644 index 00000000000..a121b70ddb5 --- /dev/null +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -0,0 +1,11 @@ +import * as Schema from "effect/Schema"; + +export const DraftComposerImageAttachmentSchema = Schema.Struct({ + id: Schema.String, + previewUri: Schema.String, + type: Schema.Literal("image"), + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + dataUrl: Schema.String, +}); diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index aa7a1055136..ed0c06ba38b 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,32 +1,38 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; -import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + IsoDateTime, + MessageId, + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + type ModelSelection as ModelSelectionType, + type ProviderInteractionMode as ProviderInteractionModeType, + type RuntimeMode as RuntimeModeType, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 1; +const THREAD_OUTBOX_SCHEMA_VERSION = 2; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; -const DraftComposerImageAttachmentSchema = Schema.Struct({ - id: Schema.String, - previewUri: Schema.String, - type: Schema.Literal("image"), - name: Schema.String, - mimeType: Schema.String, - sizeBytes: Schema.Number, - dataUrl: Schema.String, -}); - export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION), + schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, commandId: CommandId, text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelection), + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), createdAt: IsoDateTime, }); @@ -40,9 +46,37 @@ export interface QueuedThreadMessage { readonly commandId: CommandId; readonly text: string; readonly attachments: ReadonlyArray; + readonly modelSelection?: ModelSelectionType; + readonly runtimeMode?: RuntimeModeType; + readonly interactionMode?: ProviderInteractionModeType; readonly createdAt: string; } +export interface ThreadSettingsSnapshot { + readonly modelSelection: ModelSelectionType; + readonly runtimeMode: RuntimeModeType; + readonly interactionMode: ProviderInteractionModeType; +} + +export function resolveQueuedThreadSettings( + message: QueuedThreadMessage, + thread: ThreadSettingsSnapshot, +): ThreadSettingsSnapshot { + return { + modelSelection: message.modelSelection ?? thread.modelSelection, + runtimeMode: message.runtimeMode ?? thread.runtimeMode, + interactionMode: message.interactionMode ?? thread.interactionMode, + }; +} + +export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean { + return ( + left.instanceId === right.instanceId && + left.model === right.model && + JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) + ); +} + export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { return encodeStoredQueuedThreadMessage({ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, @@ -119,3 +153,21 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { } return isTransportConnectionErrorMessage(errorMessage(error)); } + +export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; +export type ThreadOutboxFailureAction = "retry" | "discard"; + +export function resolveThreadOutboxFailureAction(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; +}): ThreadOutboxFailureAction { + if ( + input.stage === "settings-sync" || + input.interrupted || + shouldRetryThreadOutboxDelivery(input.error) + ) { + return "retry"; + } + return "discard"; +} diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d6b91c1c4f6..68d06d2e424 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from "@effect/vitest"; -import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; import { decodeQueuedThreadMessage, + encodeQueuedThreadMessage, groupQueuedThreadMessages, + modelSelectionsEqual, resolveThreadOutboxDeliveryAction, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadMessage, @@ -66,6 +76,54 @@ describe("thread outbox", () => { ).toThrow(); }); + it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { + const legacyMessage = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const selectedMessage = { + ...legacyMessage, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } satisfies QueuedThreadMessage; + + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( + selectedMessage, + ); + expect( + resolveQueuedThreadSettings(legacyMessage, { + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }), + ).toEqual({ + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }); + }); + + it("compares model options as part of the queued settings change", () => { + const base = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + } as const; + + expect(modelSelectionsEqual(base, base)).toBe(true); + expect( + modelSelectionsEqual(base, { + ...base, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }), + ).toBe(false); + }); + it("backs off queued delivery retries and caps them at sixteen seconds", () => { expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, @@ -271,4 +329,23 @@ describe("thread outbox", () => { ).toBe(true); expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); }); + + it("retains queued messages when settings synchronization fails before startTurn", () => { + const deterministicFailure = new Error("Thread no longer exists"); + + expect( + resolveThreadOutboxFailureAction({ + stage: "settings-sync", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("retry"); + expect( + resolveThreadOutboxFailureAction({ + stage: "start-turn", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("discard"); + }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 48e4e8703f0..d02abb6a265 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,14 +1,136 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts"; +import { appAtomRegistry } from "./atom-registry"; +import { + clearComposerDraftContentState, + composerDraftsAtom, + decodePersistedComposerDrafts, + type ComposerDraft, + getComposerDraftSnapshot, + removeComposerDraftsForEnvironment, +} from "./use-composer-drafts"; const DRAFT: ComposerDraft = { text: "hello", attachments: [], }; +afterEach(() => { + appAtomRegistry.set(composerDraftsAtom, {}); +}); + describe("mobile composer drafts", () => { + it("hydrates selector state even when the message content is empty", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }, + }), + ).toEqual({ + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }); + }); + + it("keeps legacy content-only drafts and rejects invalid selector state", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + }), + ).toEqual({ + "environment-1:thread-1": DRAFT, + }); + + expect(() => + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + ...DRAFT, + runtimeMode: "sometimes-safe", + }, + }, + }), + ).toThrow(); + }); + + it("clears sent content without clearing the selected model or workspace", () => { + const draftKey = "environment-1:thread-1"; + const draft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }; + + expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ + [draftKey]: { + ...draft, + text: "", + attachments: [], + }, + }); + }); + + it("reads the latest selector state synchronously for send", () => { + const draftKey = "environment-1:thread-1"; + const selectedDraft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }; + appAtomRegistry.set(composerDraftsAtom, { [draftKey]: selectedDraft }); + + expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); + }); + it("removes only drafts owned by the selected environment", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); @@ -17,12 +139,15 @@ describe("mobile composer drafts", () => { removeComposerDraftsForEnvironment( { [`${environmentId}:thread-cloud`]: DRAFT, + [`new-task:${environmentId}:project-cloud`]: DRAFT, [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }, environmentId, ), ).toEqual({ [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }); }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index d0329ad2598..9e2c1566190 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,9 +1,18 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + ModelSelection as ModelSelectionSchema, + ProviderInteractionMode as ProviderInteractionModeSchema, + RuntimeMode as RuntimeModeSchema, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; @@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } -interface PersistedComposerDrafts { - readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION; - readonly drafts: Record; +export interface ComposerDraftWorkspaceSelection { + readonly mode: "local" | "worktree"; + readonly branch: string | null; + readonly worktreePath: string | null; } +export type ComposerDraftSettingsUpdate = Pick< + ComposerDraft, + "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" +>; + +const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ + mode: Schema.Literals(["local", "worktree"]), + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), +}); + +const ComposerDraftSchema = Schema.Struct({ + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelectionSchema), + runtimeMode: Schema.optional(RuntimeModeSchema), + interactionMode: Schema.optional(ProviderInteractionModeSchema), + workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), +}); + +const PersistedComposerDraftsSchema = Schema.Struct({ + schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), + drafts: Schema.Record(Schema.String, ComposerDraftSchema), +}); + +const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( + PersistedComposerDraftsSchema, +); + const EMPTY_DRAFT: ComposerDraft = { text: "", attachments: [], @@ -54,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { return EMPTY_DRAFT; } return { + ...draft, text: draft.text, attachments: draft.attachments, }; } +export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { + return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); +} + function isEmptyDraft(draft: ComposerDraft): boolean { - return draft.text.length === 0 && draft.attachments.length === 0; + return ( + draft.text.length === 0 && + draft.attachments.length === 0 && + draft.modelSelection === undefined && + draft.runtimeMode === undefined && + draft.interactionMode === 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)), + ); } async function getComposerDraftsFile() { @@ -80,20 +142,7 @@ async function loadPersistedComposerDrafts(): Promise; - if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { - return {}; - } - return Object.fromEntries( - Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => { - const draft = entry[1]; - return ( - typeof draft?.text === "string" && - Array.isArray(draft.attachments) && - !isEmptyDraft(draft) - ); - }), - ); + return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", @@ -116,10 +165,10 @@ async function writePersistedComposerDrafts(drafts: Record !isEmptyDraft(draft)), ); - const document: PersistedComposerDrafts = { + const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, - }; + } as const; const encoded = JSON.stringify(document); operation = "write"; if (!file.exists) { @@ -282,6 +331,55 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) }); } +export function updateComposerDraftSettings( + draftKey: string, + settings: Partial, +): void { + updateComposerDrafts((current) => { + const draft = { + ...normalizeDraft(current[draftKey]), + ...settings, + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; + }); +} + +export function clearComposerDraftContentState( + current: Record, + draftKey: string, +): Record { + const existing = current[draftKey]; + if (!existing) { + return current; + } + const draft = { + ...existing, + text: "", + attachments: [], + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; +} + +export function clearComposerDraftContent(draftKey: string): void { + updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); +} + export function clearComposerDraft(draftKey: string): void { updateComposerDrafts((current) => { if (!current[draftKey]) { @@ -298,8 +396,12 @@ export function removeComposerDraftsForEnvironment( environmentId: EnvironmentId, ): Record { const environmentPrefix = `${environmentId}:`; + const newTaskPrefix = `new-task:${environmentId}:`; return Object.fromEntries( - Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)), + Object.entries(drafts).filter( + ([draftKey]) => + !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix), + ), ); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 60970b32a4d..0b8cba16e16 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,7 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; -import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; @@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry"; import { appendComposerDraftAttachments, appendComposerDraftText, - clearComposerDraft, + clearComposerDraftContent, composerDraftsAtom, ensureComposerDraftsLoaded, + getComposerDraftSnapshot, removeComposerDraftAttachment, setComposerDraftText, + updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; @@ -98,6 +108,10 @@ export function useThreadComposerState() { const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; + const selectedThread = selectedThreadDetail ?? selectedThreadShell; + const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; + const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; @@ -130,7 +144,6 @@ export function useThreadComposerState() { selectedThreadShell, ]); - const selectedThread = selectedThreadDetail ?? selectedThreadShell; const activeThreadBusy = !!selectedThread && (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); @@ -141,9 +154,10 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const draft = composerDrafts[threadKey]; - const text = (draft?.text ?? "").trim(); - const attachments = draft?.attachments ?? []; + const draft = getComposerDraftSnapshot(threadKey); + const thread = selectedThreadDetail ?? selectedThreadShell; + const text = draft.text.trim(); + const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { return; } @@ -157,15 +171,18 @@ export function useThreadComposerState() { commandId: CommandId.make(metadata.commandId), text, attachments, + modelSelection: draft.modelSelection ?? thread.modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: draft.interactionMode ?? thread.interactionMode, createdAt: metadata.createdAt, }); - clearComposerDraft(threadKey); + clearComposerDraftContent(threadKey); } catch (error) { setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); } - }, [composerDrafts, selectedThreadShell]); + }, [selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { @@ -255,12 +272,45 @@ export function useThreadComposerState() { [selectedThreadShell], ); + const onUpdateModelSelection = useCallback( + (value: ModelSelection) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { modelSelection: value }); + }, + [selectedThreadKey], + ); + + const onUpdateRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + }, + [selectedThreadKey], + ); + + const onUpdateInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { interactionMode: value }); + }, + [selectedThreadKey], + ); + return { selectedThreadFeed, selectedThreadQueueCount, activeWorkStartedAt, draftMessage, draftAttachments, + modelSelection, + runtimeMode, + interactionMode, activeThreadBusy, onChangeDraftMessage, onPickDraftImages, @@ -268,5 +318,8 @@ export function useThreadComposerState() { onNativePasteImages, onRemoveDraftImage, onSendMessage, + onUpdateModelSelection, + onUpdateRuntimeMode, + onUpdateInteractionMode, }; } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 840456e2d1c..e912d6366b4 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { type MessageId } from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { CommandId, type MessageId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry"; import { useThreadShells } from "./entities"; import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; import { + modelSelectionsEqual, resolveThreadOutboxDeliveryAction, - shouldRetryThreadOutboxDelivery, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, threadOutboxRetryDelayMs, type QueuedThreadMessage, + type ThreadOutboxCommandStage, } from "./thread-outbox-model"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; @@ -44,8 +48,21 @@ function findThread( ); } +function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { + return CommandId.make(`${message.commandId}:${setting}`); +} + export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + reportFailure: false, + }); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const shellStatuses = useThreadOutboxShellStatuses(); @@ -68,6 +85,98 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { + const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const reportFailure = ( + commandResult: AtomCommandResult, + stage: ThreadOutboxCommandStage, + ): boolean => { + if (!AsyncResult.isFailure(commandResult)) { + return false; + } + const action = resolveThreadOutboxFailureAction({ + stage, + error: Cause.squash(commandResult.cause), + interrupted: Cause.hasInterruptsOnly(commandResult.cause), + }); + const retry = action === "retry"; + console.warn("[thread-outbox] queued message delivery failed", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + stage, + cause: commandResult.cause, + retry, + }); + return retry; + }; + const completeDelivery = async ( + deliveryResult: AtomCommandResult, + ): Promise => { + if (reportFailure(deliveryResult, "start-turn")) { + return false; + } + + try { + await removeThreadOutboxMessage(queuedMessage); + return true; + } catch (error) { + console.warn("[thread-outbox] failed to remove delivered queued message", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + error, + }); + return false; + } + }; + + if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { + const updateResult = await updateThreadMetadata({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "model-selection"), + threadId: queuedMessage.threadId, + modelSelection: settings.modelSelection, + }, + }); + if (AsyncResult.isFailure(updateResult)) { + reportFailure(updateResult, "settings-sync"); + return false; + } + } + + if (settings.runtimeMode !== thread.runtimeMode) { + const runtimeResult = await setThreadRuntimeMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "runtime-mode"), + threadId: queuedMessage.threadId, + runtimeMode: settings.runtimeMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(runtimeResult)) { + reportFailure(runtimeResult, "settings-sync"); + return false; + } + } + + if (settings.interactionMode !== thread.interactionMode) { + const interactionResult = await setThreadInteractionMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "interaction-mode"), + threadId: queuedMessage.threadId, + interactionMode: settings.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(interactionResult)) { + reportFailure(interactionResult, "settings-sync"); + return false; + } + } + const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -79,41 +188,15 @@ export function useThreadOutboxDrain(): void { text: queuedMessage.text, attachments: queuedMessage.attachments, }, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, + modelSelection: settings.modelSelection, + runtimeMode: settings.runtimeMode, + interactionMode: settings.interactionMode, createdAt: queuedMessage.createdAt, }, }); - if (AsyncResult.isFailure(deliveryResult)) { - const error = Cause.squash(deliveryResult.cause); - const retry = - Cause.hasInterruptsOnly(deliveryResult.cause) || shouldRetryThreadOutboxDelivery(error); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - cause: deliveryResult.cause, - retry, - }); - if (retry) { - return false; - } - } - - try { - await removeThreadOutboxMessage(queuedMessage); - return true; - } catch (error) { - console.warn("[thread-outbox] failed to remove delivered queued message", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - error, - }); - return false; - } + return completeDelivery(deliveryResult); }, - [startTurn], + [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata], ); useEffect(() => {