authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-22 12:37:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-22 12:37:31-07:00
log37ac970e289f6a79940c00e17c5a58114d94d4cf
tree358d8e79b81efe2c8ecf25994cbbfdeccc7c990b
parent8919ae7c53973048827481ecc3a96d4d778955f7
signature Signed by PGP key B5690EEEBB952194

Persist mobile composer selectors across drafts (#3496)

Co-authored-by: codex <codex@users.noreply.github.com>

10 files changed, 751 insertions(+), 246 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+45-19
...@@ -30,7 +30,9 @@ import {...@@ -30,7 +30,9 @@ import {
30 resolveProviderOptionDescriptors,30 resolveProviderOptionDescriptors,
31} from "../../lib/providerOptions";31} from "../../lib/providerOptions";
32import { buildThreadRoutePath } from "../../lib/routes";32import { buildThreadRoutePath } from "../../lib/routes";
33import { scopedProjectKey } from "../../lib/scopedEntities";
33import { MOBILE_TYPOGRAPHY } from "../../lib/typography";34import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
34import { useProjects } from "../../state/entities";36import { useProjects } from "../../state/entities";
35import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";37import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
36import { useCreateProjectThread } from "./use-project-actions";38import { useCreateProjectThread } from "./use-project-actions";
...@@ -63,6 +65,7 @@ export function NewTaskDraftScreen(props: {...@@ -63,6 +65,7 @@ export function NewTaskDraftScreen(props: {
63 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);65 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
64 const { logicalProjects, selectedProject, setProject } = flow;66 const { logicalProjects, selectedProject, setProject } = flow;
65 const promptInputRef = useRef<ComposerEditorHandle>(null);67 const promptInputRef = useRef<ComposerEditorHandle>(null);
68 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
6669
67 const borderColor = useThemeColor("--color-border");70 const borderColor = useThemeColor("--color-border");
68 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";71 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
...@@ -78,6 +81,12 @@ export function NewTaskDraftScreen(props: {...@@ -78,6 +81,12 @@ export function NewTaskDraftScreen(props: {
78 ) ?? null;81 ) ?? null;
7982
80 if (directProject) {83 if (directProject) {
84 if (
85 selectedProject?.environmentId === directProject.environmentId &&
86 selectedProject.id === directProject.id
87 ) {
88 return;
89 }
81 setProject(directProject);90 setProject(directProject);
82 return;91 return;
83 }92 }
...@@ -105,10 +114,16 @@ export function NewTaskDraftScreen(props: {...@@ -105,10 +114,16 @@ export function NewTaskDraftScreen(props: {
105114
106 useEffect(() => {115 useEffect(() => {
107 if (!selectedProject) {116 if (!selectedProject) {
117 loadedBranchesProjectKeyRef.current = null;
118 return;
119 }
120 const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`;
121 if (loadedBranchesProjectKeyRef.current === projectKey) {
108 return;122 return;
109 }123 }
124 loadedBranchesProjectKeyRef.current = projectKey;
110 void flow.loadBranches();125 void flow.loadBranches();
111 }, [flow, selectedProject]);126 }, [flow.loadBranches, selectedProject]);
112127
113 useEffect(() => {128 useEffect(() => {
114 if (!selectedProject) {129 if (!selectedProject) {
...@@ -292,11 +307,7 @@ export function NewTaskDraftScreen(props: {...@@ -292,11 +307,7 @@ export function NewTaskDraftScreen(props: {
292 if (!event.startsWith("model:")) {307 if (!event.startsWith("model:")) {
293 return;308 return;
294 }309 }
295 // Defer state update so the native menu dismiss animation completes310 flow.setSelectedModelKey(event.slice("model:".length));
296 // before re-rendering the menu actions (prevents submenu jump).
297 setTimeout(() => {
298 flow.setSelectedModelKey(event.slice("model:".length));
299 }, 150);
300 }311 }
301312
302 function handleEnvironmentMenuAction(event: string) {313 function handleEnvironmentMenuAction(event: string) {
...@@ -366,27 +377,42 @@ export function NewTaskDraftScreen(props: {...@@ -366,27 +377,42 @@ export function NewTaskDraftScreen(props: {
366 );377 );
367378
368 async function handleStart(): Promise<void> {379 async function handleStart(): Promise<void> {
380 const selectedProject = flow.selectedProject;
381 if (!selectedProject) {
382 return;
383 }
384 const draft = getComposerDraftSnapshot(
385 `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`,
386 );
387 const modelSelection = draft.modelSelection ?? flow.selectedModel;
388 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
389 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
390 const selectedWorktreePath =
391 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
392 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
393 const interactionMode = draft.interactionMode ?? flow.interactionMode;
394 const initialMessageText = draft.text.trim();
395
369 if (396 if (
370 !flow.selectedProject ||397 !modelSelection ||
371 !flow.selectedModel ||398 initialMessageText.length === 0 ||
372 flow.prompt.trim().length === 0 ||
373 flow.submitting ||399 flow.submitting ||
374 (flow.workspaceMode === "worktree" && !flow.selectedBranchName)400 (workspaceMode === "worktree" && !selectedBranchName)
375 ) {401 ) {
376 return;402 return;
377 }403 }
378404
379 flow.setSubmitting(true);405 flow.setSubmitting(true);
380 const result = await createProjectThread({406 const result = await createProjectThread({
381 project: flow.selectedProject,407 project: selectedProject,
382 modelSelection: flow.selectedModel,408 modelSelection,
383 envMode: flow.workspaceMode,409 envMode: workspaceMode,
384 branch: flow.selectedBranchName,410 branch: selectedBranchName,
385 worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath,411 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
386 runtimeMode: flow.runtimeMode,412 runtimeMode,
387 interactionMode: flow.interactionMode,413 interactionMode,
388 initialMessageText: flow.prompt.trim(),414 initialMessageText,
389 initialAttachments: flow.attachments,415 initialAttachments: draft.attachments,
390 });416 });
391 flow.setSubmitting(false);417 flow.setSubmitting(false);
392418
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+17-68
...@@ -1,13 +1,7 @@...@@ -1,13 +1,7 @@
1import { Stack, useLocalSearchParams, useRouter } from "expo-router";1import { Stack, useLocalSearchParams, useRouter } from "expo-router";
2import { useCallback, useMemo, useState } from "react";2import { useCallback, useMemo, useState } from "react";
3import * as Option from "effect/Option";3import * as Option from "effect/Option";
4import {4import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
5 EnvironmentId,
6 type ModelSelection,
7 type ProjectScript,
8 type ProviderInteractionMode,
9 type RuntimeMode,
10} from "@t3tools/contracts";
11import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";5import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
12import { Pressable, ScrollView, Text as RNText, View } from "react-native";6import { Pressable, ScrollView, Text as RNText, View } from "react-native";
13import { useWorkspaceState } from "../../state/workspace";7import { useWorkspaceState } from "../../state/workspace";
...@@ -79,18 +73,6 @@ export function ThreadRouteScreen() {...@@ -79,18 +73,6 @@ export function ThreadRouteScreen() {
79 const gitState = useSelectedThreadGitState();73 const gitState = useSelectedThreadGitState();
80 const gitActions = useSelectedThreadGitActions();74 const gitActions = useSelectedThreadGitActions();
81 const requests = useSelectedThreadRequests();75 const requests = useSelectedThreadRequests();
82 const updateThreadMetadata = useAtomCommand(
83 threadEnvironment.updateMetadata,
84 "thread metadata update",
85 );
86 const setThreadRuntimeMode = useAtomCommand(
87 threadEnvironment.setRuntimeMode,
88 "thread runtime mode",
89 );
90 const setThreadInteractionMode = useAtomCommand(
91 threadEnvironment.setInteractionMode,
92 "thread interaction mode",
93 );
94 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");76 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
95 const router = useRouter();77 const router = useRouter();
96 const params = useLocalSearchParams<{78 const params = useLocalSearchParams<{
...@@ -105,6 +87,18 @@ export function ThreadRouteScreen() {...@@ -105,6 +87,18 @@ export function ThreadRouteScreen() {
105 const routeConnectionState =87 const routeConnectionState =
106 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);88 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
107 const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null;89 const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null;
90 const selectedThreadWithDraftSettings = useMemo(
91 () =>
92 selectedThread
93 ? {
94 ...selectedThread,
95 modelSelection: composer.modelSelection ?? selectedThread.modelSelection,
96 runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode,
97 interactionMode: composer.interactionMode ?? selectedThread.interactionMode,
98 }
99 : null,
100 [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread],
101 );
108102
109 /* ─── Native header theming ──────────────────────────────────────── */103 /* ─── Native header theming ──────────────────────────────────────── */
110 const iconColor = String(useThemeColor("--color-icon"));104 const iconColor = String(useThemeColor("--color-icon"));
...@@ -157,51 +151,6 @@ export function ThreadRouteScreen() {...@@ -157,51 +151,6 @@ export function ThreadRouteScreen() {
157 const handleOpenConnectionEditor = useCallback(() => {151 const handleOpenConnectionEditor = useCallback(() => {
158 void router.push("/connections");152 void router.push("/connections");
159 }, [router]);153 }, [router]);
160 const handleUpdateThreadModelSelection = useCallback(
161 (modelSelection: ModelSelection) => {
162 if (!selectedThread) {
163 return;
164 }
165 return updateThreadMetadata({
166 environmentId: selectedThread.environmentId,
167 input: {
168 threadId: selectedThread.id,
169 modelSelection,
170 },
171 });
172 },
173 [selectedThread, updateThreadMetadata],
174 );
175 const handleUpdateThreadRuntimeMode = useCallback(
176 (runtimeMode: RuntimeMode) => {
177 if (!selectedThread) {
178 return;
179 }
180 return setThreadRuntimeMode({
181 environmentId: selectedThread.environmentId,
182 input: {
183 threadId: selectedThread.id,
184 runtimeMode,
185 },
186 });
187 },
188 [selectedThread, setThreadRuntimeMode],
189 );
190 const handleUpdateThreadInteractionMode = useCallback(
191 (interactionMode: ProviderInteractionMode) => {
192 if (!selectedThread) {
193 return;
194 }
195 return setThreadInteractionMode({
196 environmentId: selectedThread.environmentId,
197 input: {
198 threadId: selectedThread.id,
199 interactionMode,
200 },
201 });
202 },
203 [selectedThread, setThreadInteractionMode],
204 );
205 const handleStopThread = useCallback(() => {154 const handleStopThread = useCallback(() => {
206 if (155 if (
207 !selectedThread ||156 !selectedThread ||
...@@ -435,7 +384,7 @@ export function ThreadRouteScreen() {...@@ -435,7 +384,7 @@ export function ThreadRouteScreen() {
435384
436 <View className="flex-1 bg-screen">385 <View className="flex-1 bg-screen">
437 <ThreadDetailScreen386 <ThreadDetailScreen
438 selectedThread={selectedThread}387 selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
439 contentPresentation={contentPresentation}388 contentPresentation={contentPresentation}
440 screenTone={connectionTone(routeConnectionState)}389 screenTone={connectionTone(routeConnectionState)}
441 connectionError={routeConnectionError}390 connectionError={routeConnectionError}
...@@ -466,9 +415,9 @@ export function ThreadRouteScreen() {...@@ -466,9 +415,9 @@ export function ThreadRouteScreen() {
466 onStopThread={handleStopThread}415 onStopThread={handleStopThread}
467 onSendMessage={composer.onSendMessage}416 onSendMessage={composer.onSendMessage}
468 onReconnectEnvironment={handleReconnectEnvironment}417 onReconnectEnvironment={handleReconnectEnvironment}
469 onUpdateThreadModelSelection={handleUpdateThreadModelSelection}418 onUpdateThreadModelSelection={composer.onUpdateModelSelection}
470 onUpdateThreadRuntimeMode={handleUpdateThreadRuntimeMode}419 onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode}
471 onUpdateThreadInteractionMode={handleUpdateThreadInteractionMode}420 onUpdateThreadInteractionMode={composer.onUpdateInteractionMode}
472 onRespondToApproval={requests.onRespondToApproval}421 onRespondToApproval={requests.onRespondToApproval}
473 onSelectUserInputOption={requests.onSelectUserInputOption}422 onSelectUserInputOption={requests.onSelectUserInputOption}
474 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}423 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+106-79
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import React, { useCallback, useMemo, useRef, useState } from "react";1import React, { useCallback, useEffect, useMemo, useState } from "react";
22
3import type {3import type {
4 EnvironmentId,4 EnvironmentId,
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23 removeComposerDraftAttachment,23 removeComposerDraftAttachment,
24 replaceComposerDraftAttachments,24 replaceComposerDraftAttachments,
25 setComposerDraftText,25 setComposerDraftText,
26 updateComposerDraftSettings,
26 useComposerDraft,27 useComposerDraft,
27} from "../../state/use-composer-drafts";28} from "../../state/use-composer-drafts";
28import { useBranches } from "../../state/queries";29import { useBranches } from "../../state/queries";
...@@ -158,34 +159,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -158,34 +159,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
158 ? selectedEnvironmentIdOverride159 ? selectedEnvironmentIdOverride
159 : (projects[0]?.environmentId ?? null);160 : (projects[0]?.environmentId ?? null);
160 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);161 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
161 const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);
162 const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local");
163 const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null);
164 const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null);
165 const branchLoadVersionRef = useRef(0);
166 const [submitting, setSubmitting] = useState(false);162 const [submitting, setSubmitting] = useState(false);
167 const [branchQuery, setBranchQuery] = useState("");163 const [branchQuery, setBranchQuery] = useState("");
168 const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE);
169 const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>(
170 DEFAULT_PROVIDER_INTERACTION_MODE,
171 );
172 const [modelSelectionOverrides, setModelSelectionOverrides] = useState<
173 Record<string, ModelSelection>
174 >({});
175 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);164 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
176165
177 const reset = useCallback(() => {166 const reset = useCallback(() => {
178 setSelectedEnvironmentId(null);167 setSelectedEnvironmentId(null);
179 setSelectedProjectKey(null);168 setSelectedProjectKey(null);
180 setSelectedModelKey(null);
181 setWorkspaceMode("local");
182 setSelectedBranchName(null);
183 setSelectedWorktreePath(null);
184 setSubmitting(false);169 setSubmitting(false);
185 setBranchQuery("");170 setBranchQuery("");
186 setRuntimeMode(DEFAULT_RUNTIME_MODE);
187 setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE);
188 setModelSelectionOverrides({});
189 setExpandedProvider(null);171 setExpandedProvider(null);
190 }, []);172 }, []);
191173
...@@ -247,33 +229,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -247,33 +229,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
247 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);229 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
248 const prompt = selectedProjectDraft.text;230 const prompt = selectedProjectDraft.text;
249 const attachments = selectedProjectDraft.attachments;231 const attachments = selectedProjectDraft.attachments;
232 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local";
233 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
234 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
235 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
236 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
250237
251 const modelOptions = useMemo(238 const modelOptions = useMemo(
252 () =>239 () =>
253 buildModelOptions(240 buildModelOptions(
254 selectedEnvironmentServerConfig,241 selectedEnvironmentServerConfig,
255 selectedProject?.defaultModelSelection ?? null,242 selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null,
256 ),243 ),
257 [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection],244 [
245 selectedEnvironmentServerConfig,
246 selectedProject?.defaultModelSelection,
247 selectedProjectDraft.modelSelection,
248 ],
258 );249 );
259250
260 const defaultModelKey = selectedProject?.defaultModelSelection251 const selectedModel =
261 ? `${selectedProject.defaultModelSelection.instanceId}:${selectedProject.defaultModelSelection.model}`252 selectedProjectDraft.modelSelection ??
262 : null;
263 const baseSelectedModel =
264 modelOptions.find((option) => option.key === selectedModelKey)?.selection ??
265 (defaultModelKey
266 ? modelOptions.find((option) => option.key === defaultModelKey)?.selection
267 : null) ??
268 selectedProject?.defaultModelSelection ??253 selectedProject?.defaultModelSelection ??
269 modelOptions[0]?.selection ??254 modelOptions[0]?.selection ??
270 null;255 null;
271 const selectedModelIdentity = baseSelectedModel256 const selectedModelKey = selectedModel
272 ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}`257 ? `${selectedModel.instanceId}:${selectedModel.model}`
273 : null;258 : null;
274 const selectedModel =
275 (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ??
276 baseSelectedModel;
277259
278 const selectedModelOption =260 const selectedModelOption =
279 modelOptions.find(261 modelOptions.find(
...@@ -282,13 +264,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -282,13 +264,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
282 option.selection.instanceId === selectedModel.instanceId &&264 option.selection.instanceId === selectedModel.instanceId &&
283 option.selection.model === selectedModel.model,265 option.selection.model === selectedModel.model,
284 ) ?? null;266 ) ?? null;
285 const selectedProviderSkills =267 const selectedProviderSkills = useMemo(
286 selectedEnvironmentServerConfig?.providers.find(268 () =>
287 (provider) => provider.instanceId === selectedModel?.instanceId,269 selectedEnvironmentServerConfig?.providers.find(
288 )?.skills ?? [];270 (provider) => provider.instanceId === selectedModel?.instanceId,
271 )?.skills ?? [],
272 [selectedEnvironmentServerConfig, selectedModel?.instanceId],
273 );
274 const setSelectedModelKey = useCallback(
275 (key: string | null) => {
276 if (!key || !selectedProjectDraftKey) {
277 return;
278 }
279 const option = modelOptions.find((candidate) => candidate.key === key);
280 if (!option) {
281 return;
282 }
283 updateComposerDraftSettings(selectedProjectDraftKey, {
284 modelSelection: option.selection,
285 });
286 },
287 [modelOptions, selectedProjectDraftKey],
288 );
289 const setSelectedModelOptions = useCallback(289 const setSelectedModelOptions = useCallback(
290 (options: ReadonlyArray<ProviderOptionSelection> | undefined) => {290 (options: ReadonlyArray<ProviderOptionSelection> | undefined) => {
291 if (!selectedModel || !selectedModelIdentity) {291 if (!selectedModel || !selectedProjectDraftKey) {
292 return;292 return;
293 }293 }
294 const nextSelection: ModelSelection = options294 const nextSelection: ModelSelection = options
...@@ -297,12 +297,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -297,12 +297,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
297 instanceId: selectedModel.instanceId,297 instanceId: selectedModel.instanceId,
298 model: selectedModel.model,298 model: selectedModel.model,
299 };299 };
300 setModelSelectionOverrides((current) => ({300 updateComposerDraftSettings(selectedProjectDraftKey, {
301 ...current,301 modelSelection: nextSelection,
302 [selectedModelIdentity]: nextSelection,302 });
303 }));
304 },303 },
305 [selectedModel, selectedModelIdentity],304 [selectedModel, selectedProjectDraftKey],
306 );305 );
307306
308 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);307 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
...@@ -381,62 +380,85 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -381,62 +380,85 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
381380
382 const setProject = useCallback((project: EnvironmentProject) => {381 const setProject = useCallback((project: EnvironmentProject) => {
383 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);382 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
384 branchLoadVersionRef.current += 1;
385 setSelectedEnvironmentId(project.environmentId);383 setSelectedEnvironmentId(project.environmentId);
386 setSelectedProjectKey(nextProjectKey);384 setSelectedProjectKey(nextProjectKey);
387 setSelectedBranchName(null);
388 setSelectedWorktreePath(null);
389 setModelSelectionOverrides({});
390 }, []);385 }, []);
391386
392 const selectEnvironment = useCallback((environmentId: EnvironmentId) => {387 const selectEnvironment = useCallback((environmentId: EnvironmentId) => {
393 branchLoadVersionRef.current += 1;
394 setSelectedEnvironmentId(environmentId);388 setSelectedEnvironmentId(environmentId);
395 setSelectedProjectKey(null);389 setSelectedProjectKey(null);
396 setSelectedBranchName(null);
397 setSelectedWorktreePath(null);
398 setModelSelectionOverrides({});
399 }, []);390 }, []);
400391
392 const setWorkspaceMode = useCallback(
393 (mode: WorkspaceMode) => {
394 if (!selectedProjectDraftKey) {
395 return;
396 }
397 updateComposerDraftSettings(selectedProjectDraftKey, {
398 workspaceSelection: {
399 mode,
400 branch: selectedBranchName,
401 worktreePath: selectedWorktreePath,
402 },
403 });
404 },
405 [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath],
406 );
407
401 const selectBranch = useCallback(408 const selectBranch = useCallback(
402 (branch: VcsRef) => {409 (branch: VcsRef) => {
403 setSelectedBranchName(branch.name);410 if (!selectedProject || !selectedProjectDraftKey) {
404 setSelectedWorktreePath(411 return;
405 selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null,412 }
406 );413 updateComposerDraftSettings(selectedProjectDraftKey, {
414 workspaceSelection: {
415 mode: workspaceMode,
416 branch: branch.name,
417 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),
418 },
419 });
407 },420 },
408 [selectedProject],421 [selectedProject, selectedProjectDraftKey, workspaceMode],
409 );422 );
410423
424 const refreshBranches = branchState.refresh;
411 const loadBranches = useCallback(async () => {425 const loadBranches = useCallback(async () => {
412 if (!selectedProject) {426 if (!selectedProject) {
413 return;427 return;
414 }428 }
429 setPendingConnectionError(null);
430 refreshBranches();
431 }, [refreshBranches, selectedProject]);
415432
416 const loadVersion = ++branchLoadVersionRef.current;433 useEffect(() => {
417 const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id);434 if (workspaceMode !== "worktree" || selectedBranchName !== null) {
418 branchState.refresh();
419 if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) {
420 return;435 return;
421 }436 }
422 setPendingConnectionError(null);437 const preferredBranch =
423 if (workspaceMode === "worktree" && !selectedBranchName) {438 availableBranches.find((branch) => branch.current) ??
424 const preferredBranch =439 availableBranches.find((branch) => branch.isDefault) ??
425 availableBranches.find((branch) => branch.current)?.name ??440 null;
426 availableBranches.find((branch) => branch.isDefault)?.name ??441 if (preferredBranch) {
427 null;442 selectBranch(preferredBranch);
428 if (preferredBranch) {
429 setSelectedBranchName(preferredBranch);
430 }
431 }443 }
432 }, [444 }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]);
433 availableBranches,445
434 branchState,446 const setRuntimeMode = useCallback(
435 selectedBranchName,447 (value: RuntimeMode) => {
436 selectedProject,448 if (selectedProjectDraftKey) {
437 selectedProjectKey,449 updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value });
438 workspaceMode,450 }
439 ]);451 },
452 [selectedProjectDraftKey],
453 );
454 const setInteractionMode = useCallback(
455 (value: ProviderInteractionMode) => {
456 if (selectedProjectDraftKey) {
457 updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value });
458 }
459 },
460 [selectedProjectDraftKey],
461 );
440462
441 const value = useMemo<NewTaskFlowContextValue>(463 const value = useMemo<NewTaskFlowContextValue>(
442 () => ({464 () => ({
...@@ -513,6 +535,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -513,6 +535,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
513 setProject,535 setProject,
514 selectBranch,536 selectBranch,
515 selectEnvironment,537 selectEnvironment,
538 setInteractionMode,
539 setPrompt,
540 setRuntimeMode,
541 setSelectedModelKey,
542 setWorkspaceMode,
516 submitting,543 submitting,
517 workspaceMode,544 workspaceMode,
518 appendAttachments,545 appendAttachments,
apps/mobile/src/lib/composer-image-schema.ts created+11
...@@ -0,0 +1,11 @@
1import * as Schema from "effect/Schema";
2
3export const DraftComposerImageAttachmentSchema = Schema.Struct({
4 id: Schema.String,
5 previewUri: Schema.String,
6 type: Schema.Literal("image"),
7 name: Schema.String,
8 mimeType: Schema.String,
9 sizeBytes: Schema.Number,
10 dataUrl: Schema.String,
11});
apps/mobile/src/state/thread-outbox-model.ts+65-13
...@@ -1,32 +1,38 @@...@@ -1,32 +1,38 @@
1import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";1import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
3import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts";3import {
4 CommandId,
5 EnvironmentId,
6 IsoDateTime,
7 MessageId,
8 ModelSelection,
9 ProviderInteractionMode,
10 RuntimeMode,
11 ThreadId,
12 type ModelSelection as ModelSelectionType,
13 type ProviderInteractionMode as ProviderInteractionModeType,
14 type RuntimeMode as RuntimeModeType,
15} from "@t3tools/contracts";
4import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
517
18import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
6import type { DraftComposerImageAttachment } from "../lib/composerImages";19import type { DraftComposerImageAttachment } from "../lib/composerImages";
7import { scopedThreadKey } from "../lib/scopedEntities";20import { scopedThreadKey } from "../lib/scopedEntities";
821
9const THREAD_OUTBOX_SCHEMA_VERSION = 1;22const THREAD_OUTBOX_SCHEMA_VERSION = 2;
10const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;23const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
1124
12const DraftComposerImageAttachmentSchema = Schema.Struct({
13 id: Schema.String,
14 previewUri: Schema.String,
15 type: Schema.Literal("image"),
16 name: Schema.String,
17 mimeType: Schema.String,
18 sizeBytes: Schema.Number,
19 dataUrl: Schema.String,
20});
21
22export const QueuedThreadMessageSchema = Schema.Struct({25export const QueuedThreadMessageSchema = Schema.Struct({
23 schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION),26 schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]),
24 environmentId: EnvironmentId,27 environmentId: EnvironmentId,
25 threadId: ThreadId,28 threadId: ThreadId,
26 messageId: MessageId,29 messageId: MessageId,
27 commandId: CommandId,30 commandId: CommandId,
28 text: Schema.String,31 text: Schema.String,
29 attachments: Schema.Array(DraftComposerImageAttachmentSchema),32 attachments: Schema.Array(DraftComposerImageAttachmentSchema),
33 modelSelection: Schema.optional(ModelSelection),
34 runtimeMode: Schema.optional(RuntimeMode),
35 interactionMode: Schema.optional(ProviderInteractionMode),
30 createdAt: IsoDateTime,36 createdAt: IsoDateTime,
31});37});
3238
...@@ -40,9 +46,37 @@ export interface QueuedThreadMessage {...@@ -40,9 +46,37 @@ export interface QueuedThreadMessage {
40 readonly commandId: CommandId;46 readonly commandId: CommandId;
41 readonly text: string;47 readonly text: string;
42 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;48 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
49 readonly modelSelection?: ModelSelectionType;
50 readonly runtimeMode?: RuntimeModeType;
51 readonly interactionMode?: ProviderInteractionModeType;
43 readonly createdAt: string;52 readonly createdAt: string;
44}53}
4554
55export interface ThreadSettingsSnapshot {
56 readonly modelSelection: ModelSelectionType;
57 readonly runtimeMode: RuntimeModeType;
58 readonly interactionMode: ProviderInteractionModeType;
59}
60
61export function resolveQueuedThreadSettings(
62 message: QueuedThreadMessage,
63 thread: ThreadSettingsSnapshot,
64): ThreadSettingsSnapshot {
65 return {
66 modelSelection: message.modelSelection ?? thread.modelSelection,
67 runtimeMode: message.runtimeMode ?? thread.runtimeMode,
68 interactionMode: message.interactionMode ?? thread.interactionMode,
69 };
70}
71
72export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean {
73 return (
74 left.instanceId === right.instanceId &&
75 left.model === right.model &&
76 JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null)
77 );
78}
79
46export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown {80export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown {
47 return encodeStoredQueuedThreadMessage({81 return encodeStoredQueuedThreadMessage({
48 schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION,82 schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION,
...@@ -119,3 +153,21 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {...@@ -119,3 +153,21 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
119 }153 }
120 return isTransportConnectionErrorMessage(errorMessage(error));154 return isTransportConnectionErrorMessage(errorMessage(error));
121}155}
156
157export type ThreadOutboxCommandStage = "settings-sync" | "start-turn";
158export type ThreadOutboxFailureAction = "retry" | "discard";
159
160export function resolveThreadOutboxFailureAction(input: {
161 readonly stage: ThreadOutboxCommandStage;
162 readonly error: unknown;
163 readonly interrupted: boolean;
164}): ThreadOutboxFailureAction {
165 if (
166 input.stage === "settings-sync" ||
167 input.interrupted ||
168 shouldRetryThreadOutboxDelivery(input.error)
169 ) {
170 return "retry";
171 }
172 return "discard";
173}
apps/mobile/src/state/thread-outbox.test.ts+78-1
...@@ -1,11 +1,21 @@...@@ -1,11 +1,21 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";2import {
3 CommandId,
4 EnvironmentId,
5 MessageId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";9import { AtomRegistry } from "effect/unstable/reactivity";
410
5import {11import {
6 decodeQueuedThreadMessage,12 decodeQueuedThreadMessage,
13 encodeQueuedThreadMessage,
7 groupQueuedThreadMessages,14 groupQueuedThreadMessages,
15 modelSelectionsEqual,
8 resolveThreadOutboxDeliveryAction,16 resolveThreadOutboxDeliveryAction,
17 resolveThreadOutboxFailureAction,
18 resolveQueuedThreadSettings,
9 shouldRetryThreadOutboxDelivery,19 shouldRetryThreadOutboxDelivery,
10 threadOutboxRetryDelayMs,20 threadOutboxRetryDelayMs,
11 type QueuedThreadMessage,21 type QueuedThreadMessage,
...@@ -66,6 +76,54 @@ describe("thread outbox", () => {...@@ -66,6 +76,54 @@ describe("thread outbox", () => {
66 ).toThrow();76 ).toThrow();
67 });77 });
6878
79 it("persists the exact selector snapshot while remaining compatible with v1 messages", () => {
80 const legacyMessage = queuedMessage({
81 messageId: "message-1",
82 createdAt: "2026-06-08T10:00:01.000Z",
83 });
84 const selectedMessage = {
85 ...legacyMessage,
86 modelSelection: {
87 instanceId: ProviderInstanceId.make("codex"),
88 model: "gpt-5.4",
89 options: [{ id: "reasoningEffort", value: "xhigh" }],
90 },
91 runtimeMode: "approval-required",
92 interactionMode: "plan",
93 } satisfies QueuedThreadMessage;
94
95 expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual(
96 selectedMessage,
97 );
98 expect(
99 resolveQueuedThreadSettings(legacyMessage, {
100 modelSelection: selectedMessage.modelSelection,
101 runtimeMode: selectedMessage.runtimeMode,
102 interactionMode: selectedMessage.interactionMode,
103 }),
104 ).toEqual({
105 modelSelection: selectedMessage.modelSelection,
106 runtimeMode: selectedMessage.runtimeMode,
107 interactionMode: selectedMessage.interactionMode,
108 });
109 });
110
111 it("compares model options as part of the queued settings change", () => {
112 const base = {
113 instanceId: ProviderInstanceId.make("codex"),
114 model: "gpt-5.4",
115 options: [{ id: "reasoningEffort", value: "medium" }],
116 } as const;
117
118 expect(modelSelectionsEqual(base, base)).toBe(true);
119 expect(
120 modelSelectionsEqual(base, {
121 ...base,
122 options: [{ id: "reasoningEffort", value: "xhigh" }],
123 }),
124 ).toBe(false);
125 });
126
69 it("backs off queued delivery retries and caps them at sixteen seconds", () => {127 it("backs off queued delivery retries and caps them at sixteen seconds", () => {
70 expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([128 expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([
71 1_000, 2_000, 4_000, 8_000, 16_000, 16_000,129 1_000, 2_000, 4_000, 8_000, 16_000, 16_000,
...@@ -271,4 +329,23 @@ describe("thread outbox", () => {...@@ -271,4 +329,23 @@ describe("thread outbox", () => {
271 ).toBe(true);329 ).toBe(true);
272 expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false);330 expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false);
273 });331 });
332
333 it("retains queued messages when settings synchronization fails before startTurn", () => {
334 const deterministicFailure = new Error("Thread no longer exists");
335
336 expect(
337 resolveThreadOutboxFailureAction({
338 stage: "settings-sync",
339 error: deterministicFailure,
340 interrupted: false,
341 }),
342 ).toBe("retry");
343 expect(
344 resolveThreadOutboxFailureAction({
345 stage: "start-turn",
346 error: deterministicFailure,
347 interrupted: false,
348 }),
349 ).toBe("discard");
350 });
274});351});
apps/mobile/src/state/use-composer-drafts.test.ts+128-3
...@@ -1,14 +1,136 @@...@@ -1,14 +1,136 @@
1import { describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
33
4import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts";4import { appAtomRegistry } from "./atom-registry";
5import {
6 clearComposerDraftContentState,
7 composerDraftsAtom,
8 decodePersistedComposerDrafts,
9 type ComposerDraft,
10 getComposerDraftSnapshot,
11 removeComposerDraftsForEnvironment,
12} from "./use-composer-drafts";
513
6const DRAFT: ComposerDraft = {14const DRAFT: ComposerDraft = {
7 text: "hello",15 text: "hello",
8 attachments: [],16 attachments: [],
9};17};
1018
19afterEach(() => {
20 appAtomRegistry.set(composerDraftsAtom, {});
21});
22
11describe("mobile composer drafts", () => {23describe("mobile composer drafts", () => {
24 it("hydrates selector state even when the message content is empty", () => {
25 expect(
26 decodePersistedComposerDrafts({
27 schemaVersion: 1,
28 drafts: {
29 "new-task:environment-1:project-1": {
30 text: "",
31 attachments: [],
32 modelSelection: {
33 instanceId: "codex",
34 model: "gpt-5.4",
35 options: [{ id: "reasoningEffort", value: "xhigh" }],
36 },
37 runtimeMode: "approval-required",
38 interactionMode: "plan",
39 workspaceSelection: {
40 mode: "worktree",
41 branch: "main",
42 worktreePath: null,
43 },
44 },
45 },
46 }),
47 ).toEqual({
48 "new-task:environment-1:project-1": {
49 text: "",
50 attachments: [],
51 modelSelection: {
52 instanceId: "codex",
53 model: "gpt-5.4",
54 options: [{ id: "reasoningEffort", value: "xhigh" }],
55 },
56 runtimeMode: "approval-required",
57 interactionMode: "plan",
58 workspaceSelection: {
59 mode: "worktree",
60 branch: "main",
61 worktreePath: null,
62 },
63 },
64 });
65 });
66
67 it("keeps legacy content-only drafts and rejects invalid selector state", () => {
68 expect(
69 decodePersistedComposerDrafts({
70 schemaVersion: 1,
71 drafts: {
72 "environment-1:thread-1": DRAFT,
73 },
74 }),
75 ).toEqual({
76 "environment-1:thread-1": DRAFT,
77 });
78
79 expect(() =>
80 decodePersistedComposerDrafts({
81 schemaVersion: 1,
82 drafts: {
83 "environment-1:thread-1": {
84 ...DRAFT,
85 runtimeMode: "sometimes-safe",
86 },
87 },
88 }),
89 ).toThrow();
90 });
91
92 it("clears sent content without clearing the selected model or workspace", () => {
93 const draftKey = "environment-1:thread-1";
94 const draft: ComposerDraft = {
95 text: "send this",
96 attachments: [],
97 modelSelection: {
98 instanceId: ProviderInstanceId.make("codex"),
99 model: "gpt-5.4",
100 options: [{ id: "reasoningEffort", value: "xhigh" }],
101 },
102 workspaceSelection: {
103 mode: "worktree",
104 branch: "main",
105 worktreePath: null,
106 },
107 };
108
109 expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({
110 [draftKey]: {
111 ...draft,
112 text: "",
113 attachments: [],
114 },
115 });
116 });
117
118 it("reads the latest selector state synchronously for send", () => {
119 const draftKey = "environment-1:thread-1";
120 const selectedDraft: ComposerDraft = {
121 text: "send this",
122 attachments: [],
123 modelSelection: {
124 instanceId: ProviderInstanceId.make("codex"),
125 model: "gpt-5.4",
126 options: [{ id: "reasoningEffort", value: "xhigh" }],
127 },
128 };
129 appAtomRegistry.set(composerDraftsAtom, { [draftKey]: selectedDraft });
130
131 expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
132 });
133
12 it("removes only drafts owned by the selected environment", () => {134 it("removes only drafts owned by the selected environment", () => {
13 const environmentId = EnvironmentId.make("environment-cloud");135 const environmentId = EnvironmentId.make("environment-cloud");
14 const retainedEnvironmentId = EnvironmentId.make("environment-local");136 const retainedEnvironmentId = EnvironmentId.make("environment-local");
...@@ -17,12 +139,15 @@ describe("mobile composer drafts", () => {...@@ -17,12 +139,15 @@ describe("mobile composer drafts", () => {
17 removeComposerDraftsForEnvironment(139 removeComposerDraftsForEnvironment(
18 {140 {
19 [`${environmentId}:thread-cloud`]: DRAFT,141 [`${environmentId}:thread-cloud`]: DRAFT,
142 [`new-task:${environmentId}:project-cloud`]: DRAFT,
20 [`${retainedEnvironmentId}:thread-local`]: DRAFT,143 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
144 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
21 },145 },
22 environmentId,146 environmentId,
23 ),147 ),
24 ).toEqual({148 ).toEqual({
25 [`${retainedEnvironmentId}:thread-local`]: DRAFT,149 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
150 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
26 });151 });
27 });152 });
28});153});
apps/mobile/src/state/use-composer-drafts.ts+124-22
...@@ -1,9 +1,18 @@...@@ -1,9 +1,18 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";2import {
3 ModelSelection as ModelSelectionSchema,
4 ProviderInteractionMode as ProviderInteractionModeSchema,
5 RuntimeMode as RuntimeModeSchema,
6 type EnvironmentId,
7 type ModelSelection,
8 type ProviderInteractionMode,
9 type RuntimeMode,
10} from "@t3tools/contracts";
3import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
4import { useEffect } from "react";12import { useEffect } from "react";
5import { Atom } from "effect/unstable/reactivity";13import { Atom } from "effect/unstable/reactivity";
614
15import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
7import type { DraftComposerImageAttachment } from "../lib/composerImages";16import type { DraftComposerImageAttachment } from "../lib/composerImages";
8import { appAtomRegistry } from "./atom-registry";17import { appAtomRegistry } from "./atom-registry";
918
...@@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo...@@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo
29export interface ComposerDraft {38export interface ComposerDraft {
30 readonly text: string;39 readonly text: string;
31 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;40 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
41 readonly modelSelection?: ModelSelection;
42 readonly runtimeMode?: RuntimeMode;
43 readonly interactionMode?: ProviderInteractionMode;
44 readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
32}45}
3346
34interface PersistedComposerDrafts {47export interface ComposerDraftWorkspaceSelection {
35 readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION;48 readonly mode: "local" | "worktree";
36 readonly drafts: Record<string, ComposerDraft>;49 readonly branch: string | null;
50 readonly worktreePath: string | null;
37}51}
3852
53export type ComposerDraftSettingsUpdate = Pick<
54 ComposerDraft,
55 "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection"
56>;
57
58const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({
59 mode: Schema.Literals(["local", "worktree"]),
60 branch: Schema.NullOr(Schema.String),
61 worktreePath: Schema.NullOr(Schema.String),
62});
63
64const ComposerDraftSchema = Schema.Struct({
65 text: Schema.String,
66 attachments: Schema.Array(DraftComposerImageAttachmentSchema),
67 modelSelection: Schema.optional(ModelSelectionSchema),
68 runtimeMode: Schema.optional(RuntimeModeSchema),
69 interactionMode: Schema.optional(ProviderInteractionModeSchema),
70 workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema),
71});
72
73const PersistedComposerDraftsSchema = Schema.Struct({
74 schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),
75 drafts: Schema.Record(Schema.String, ComposerDraftSchema),
76});
77
78const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(
79 PersistedComposerDraftsSchema,
80);
81
39const EMPTY_DRAFT: ComposerDraft = {82const EMPTY_DRAFT: ComposerDraft = {
40 text: "",83 text: "",
41 attachments: [],84 attachments: [],
...@@ -54,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {...@@ -54,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
54 return EMPTY_DRAFT;97 return EMPTY_DRAFT;
55 }98 }
56 return {99 return {
100 ...draft,
57 text: draft.text,101 text: draft.text,
58 attachments: draft.attachments,102 attachments: draft.attachments,
59 };103 };
60}104}
61105
106export function getComposerDraftSnapshot(draftKey: string): ComposerDraft {
107 return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]);
108}
109
62function isEmptyDraft(draft: ComposerDraft): boolean {110function isEmptyDraft(draft: ComposerDraft): boolean {
63 return draft.text.length === 0 && draft.attachments.length === 0;111 return (
112 draft.text.length === 0 &&
113 draft.attachments.length === 0 &&
114 draft.modelSelection === undefined &&
115 draft.runtimeMode === undefined &&
116 draft.interactionMode === undefined &&
117 draft.workspaceSelection === undefined
118 );
119}
120
121export function decodePersistedComposerDrafts(value: unknown): Record<string, ComposerDraft> {
122 const parsed = decodePersistedComposerDraftsDocument(value);
123 return Object.fromEntries(
124 Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)),
125 );
64}126}
65127
66async function getComposerDraftsFile() {128async function getComposerDraftsFile() {
...@@ -80,20 +142,7 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra...@@ -80,20 +142,7 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra
80 operation = "read";142 operation = "read";
81 const raw = await file.text();143 const raw = await file.text();
82 operation = "decode";144 operation = "decode";
83 const parsed = JSON.parse(raw) as Partial<PersistedComposerDrafts>;145 return decodePersistedComposerDrafts(JSON.parse(raw) as unknown);
84 if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) {
85 return {};
86 }
87 return Object.fromEntries(
88 Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => {
89 const draft = entry[1];
90 return (
91 typeof draft?.text === "string" &&
92 Array.isArray(draft.attachments) &&
93 !isEmptyDraft(draft)
94 );
95 }),
96 );
97 } catch (cause) {146 } catch (cause) {
98 console.warn(147 console.warn(
99 "[composer-drafts] ignored persisted draft failure",148 "[composer-drafts] ignored persisted draft failure",
...@@ -116,10 +165,10 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft...@@ -116,10 +165,10 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
116 const nonEmptyDrafts = Object.fromEntries(165 const nonEmptyDrafts = Object.fromEntries(
117 Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)),166 Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)),
118 );167 );
119 const document: PersistedComposerDrafts = {168 const document = {
120 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,169 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
121 drafts: nonEmptyDrafts,170 drafts: nonEmptyDrafts,
122 };171 } as const;
123 const encoded = JSON.stringify(document);172 const encoded = JSON.stringify(document);
124 operation = "write";173 operation = "write";
125 if (!file.exists) {174 if (!file.exists) {
...@@ -282,6 +331,55 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)...@@ -282,6 +331,55 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
282 });331 });
283}332}
284333
334export function updateComposerDraftSettings(
335 draftKey: string,
336 settings: Partial<ComposerDraftSettingsUpdate>,
337): void {
338 updateComposerDrafts((current) => {
339 const draft = {
340 ...normalizeDraft(current[draftKey]),
341 ...settings,
342 };
343 if (isEmptyDraft(draft)) {
344 const next = { ...current };
345 delete next[draftKey];
346 return next;
347 }
348 return {
349 ...current,
350 [draftKey]: draft,
351 };
352 });
353}
354
355export function clearComposerDraftContentState(
356 current: Record<string, ComposerDraft>,
357 draftKey: string,
358): Record<string, ComposerDraft> {
359 const existing = current[draftKey];
360 if (!existing) {
361 return current;
362 }
363 const draft = {
364 ...existing,
365 text: "",
366 attachments: [],
367 };
368 if (isEmptyDraft(draft)) {
369 const next = { ...current };
370 delete next[draftKey];
371 return next;
372 }
373 return {
374 ...current,
375 [draftKey]: draft,
376 };
377}
378
379export function clearComposerDraftContent(draftKey: string): void {
380 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey));
381}
382
285export function clearComposerDraft(draftKey: string): void {383export function clearComposerDraft(draftKey: string): void {
286 updateComposerDrafts((current) => {384 updateComposerDrafts((current) => {
287 if (!current[draftKey]) {385 if (!current[draftKey]) {
...@@ -298,8 +396,12 @@ export function removeComposerDraftsForEnvironment(...@@ -298,8 +396,12 @@ export function removeComposerDraftsForEnvironment(
298 environmentId: EnvironmentId,396 environmentId: EnvironmentId,
299): Record<string, ComposerDraft> {397): Record<string, ComposerDraft> {
300 const environmentPrefix = `${environmentId}:`;398 const environmentPrefix = `${environmentId}:`;
399 const newTaskPrefix = `new-task:${environmentId}:`;
301 return Object.fromEntries(400 return Object.fromEntries(
302 Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)),401 Object.entries(drafts).filter(
402 ([draftKey]) =>
403 !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix),
404 ),
303 );405 );
304}406}
305407
apps/mobile/src/state/use-thread-composer-state.ts+61-8
...@@ -1,7 +1,15 @@...@@ -1,7 +1,15 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo } from "react";2import { useCallback, useEffect, useMemo } from "react";
33
4import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";4import {
5 CommandId,
6 MessageId,
7 type EnvironmentId,
8 type ModelSelection,
9 type ProviderInteractionMode,
10 type RuntimeMode,
11 type ThreadId,
12} from "@t3tools/contracts";
5import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";13import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
6import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";14import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
715
...@@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry";...@@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry";
18import {26import {
19 appendComposerDraftAttachments,27 appendComposerDraftAttachments,
20 appendComposerDraftText,28 appendComposerDraftText,
21 clearComposerDraft,29 clearComposerDraftContent,
22 composerDraftsAtom,30 composerDraftsAtom,
23 ensureComposerDraftsLoaded,31 ensureComposerDraftsLoaded,
32 getComposerDraftSnapshot,
24 removeComposerDraftAttachment,33 removeComposerDraftAttachment,
25 setComposerDraftText,34 setComposerDraftText,
35 updateComposerDraftSettings,
26 useComposerDraft,36 useComposerDraft,
27} from "./use-composer-drafts";37} from "./use-composer-drafts";
28import { setPendingConnectionError } from "../state/use-remote-environment-registry";38import { setPendingConnectionError } from "../state/use-remote-environment-registry";
...@@ -98,6 +108,10 @@ export function useThreadComposerState() {...@@ -98,6 +108,10 @@ export function useThreadComposerState() {
98 const draftMessage = selectedDraft?.text ?? "";108 const draftMessage = selectedDraft?.text ?? "";
99 const draftAttachments = selectedDraft?.attachments ?? [];109 const draftAttachments = selectedDraft?.attachments ?? [];
100 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;110 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
111 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
112 const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null;
113 const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null;
114 const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null;
101115
102 const selectedThreadSessionActivity = useMemo(() => {116 const selectedThreadSessionActivity = useMemo(() => {
103 const selectedThread = selectedThreadDetail ?? selectedThreadShell;117 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
...@@ -130,7 +144,6 @@ export function useThreadComposerState() {...@@ -130,7 +144,6 @@ export function useThreadComposerState() {
130 selectedThreadShell,144 selectedThreadShell,
131 ]);145 ]);
132146
133 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
134 const activeThreadBusy =147 const activeThreadBusy =
135 !!selectedThread &&148 !!selectedThread &&
136 (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting");149 (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting");
...@@ -141,9 +154,10 @@ export function useThreadComposerState() {...@@ -141,9 +154,10 @@ export function useThreadComposerState() {
141 }154 }
142155
143 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);156 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
144 const draft = composerDrafts[threadKey];157 const draft = getComposerDraftSnapshot(threadKey);
145 const text = (draft?.text ?? "").trim();158 const thread = selectedThreadDetail ?? selectedThreadShell;
146 const attachments = draft?.attachments ?? [];159 const text = draft.text.trim();
160 const attachments = draft.attachments;
147 if (text.length === 0 && attachments.length === 0) {161 if (text.length === 0 && attachments.length === 0) {
148 return;162 return;
149 }163 }
...@@ -157,15 +171,18 @@ export function useThreadComposerState() {...@@ -157,15 +171,18 @@ export function useThreadComposerState() {
157 commandId: CommandId.make(metadata.commandId),171 commandId: CommandId.make(metadata.commandId),
158 text,172 text,
159 attachments,173 attachments,
174 modelSelection: draft.modelSelection ?? thread.modelSelection,
175 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
176 interactionMode: draft.interactionMode ?? thread.interactionMode,
160 createdAt: metadata.createdAt,177 createdAt: metadata.createdAt,
161 });178 });
162 clearComposerDraft(threadKey);179 clearComposerDraftContent(threadKey);
163 } catch (error) {180 } catch (error) {
164 setPendingConnectionError(181 setPendingConnectionError(
165 error instanceof Error ? error.message : "Failed to save the queued message.",182 error instanceof Error ? error.message : "Failed to save the queued message.",
166 );183 );
167 }184 }
168 }, [composerDrafts, selectedThreadShell]);185 }, [selectedThreadDetail, selectedThreadShell]);
169186
170 const onChangeDraftMessage = useCallback(187 const onChangeDraftMessage = useCallback(
171 (value: string) => {188 (value: string) => {
...@@ -255,12 +272,45 @@ export function useThreadComposerState() {...@@ -255,12 +272,45 @@ export function useThreadComposerState() {
255 [selectedThreadShell],272 [selectedThreadShell],
256 );273 );
257274
275 const onUpdateModelSelection = useCallback(
276 (value: ModelSelection) => {
277 if (!selectedThreadKey) {
278 return;
279 }
280 updateComposerDraftSettings(selectedThreadKey, { modelSelection: value });
281 },
282 [selectedThreadKey],
283 );
284
285 const onUpdateRuntimeMode = useCallback(
286 (value: RuntimeMode) => {
287 if (!selectedThreadKey) {
288 return;
289 }
290 updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value });
291 },
292 [selectedThreadKey],
293 );
294
295 const onUpdateInteractionMode = useCallback(
296 (value: ProviderInteractionMode) => {
297 if (!selectedThreadKey) {
298 return;
299 }
300 updateComposerDraftSettings(selectedThreadKey, { interactionMode: value });
301 },
302 [selectedThreadKey],
303 );
304
258 return {305 return {
259 selectedThreadFeed,306 selectedThreadFeed,
260 selectedThreadQueueCount,307 selectedThreadQueueCount,
261 activeWorkStartedAt,308 activeWorkStartedAt,
262 draftMessage,309 draftMessage,
263 draftAttachments,310 draftAttachments,
311 modelSelection,
312 runtimeMode,
313 interactionMode,
264 activeThreadBusy,314 activeThreadBusy,
265 onChangeDraftMessage,315 onChangeDraftMessage,
266 onPickDraftImages,316 onPickDraftImages,
...@@ -268,5 +318,8 @@ export function useThreadComposerState() {...@@ -268,5 +318,8 @@ export function useThreadComposerState() {
268 onNativePasteImages,318 onNativePasteImages,
269 onRemoveDraftImage,319 onRemoveDraftImage,
270 onSendMessage,320 onSendMessage,
321 onUpdateModelSelection,
322 onUpdateRuntimeMode,
323 onUpdateInteractionMode,
271 };324 };
272}325}
apps/mobile/src/state/use-thread-outbox-drain.ts+116-33
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { type MessageId } from "@t3tools/contracts";3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4import { CommandId, type MessageId } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";5import * as Cause from "effect/Cause";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";6import { AsyncResult, Atom } from "effect/unstable/reactivity";
6import { useCallback, useEffect, useRef, useState } from "react";7import { useCallback, useEffect, useRef, useState } from "react";
...@@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry";...@@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry";
10import { useThreadShells } from "./entities";11import { useThreadShells } from "./entities";
11import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";12import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";
12import {13import {
14 modelSelectionsEqual,
13 resolveThreadOutboxDeliveryAction,15 resolveThreadOutboxDeliveryAction,
14 shouldRetryThreadOutboxDelivery,16 resolveThreadOutboxFailureAction,
17 resolveQueuedThreadSettings,
15 threadOutboxRetryDelayMs,18 threadOutboxRetryDelayMs,
16 type QueuedThreadMessage,19 type QueuedThreadMessage,
20 type ThreadOutboxCommandStage,
17} from "./thread-outbox-model";21} from "./thread-outbox-model";
18import { threadEnvironment } from "./threads";22import { threadEnvironment } from "./threads";
19import { useAtomCommand } from "./use-atom-command";23import { useAtomCommand } from "./use-atom-command";
...@@ -44,8 +48,21 @@ function findThread(...@@ -44,8 +48,21 @@ function findThread(
44 );48 );
45}49}
4650
51function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId {
52 return CommandId.make(`${message.commandId}:${setting}`);
53}
54
47export function useThreadOutboxDrain(): void {55export function useThreadOutboxDrain(): void {
48 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });56 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
57 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
58 reportFailure: false,
59 });
60 const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {
61 reportFailure: false,
62 });
63 const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, {
64 reportFailure: false,
65 });
49 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);66 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
50 const queuedMessagesByThreadKey = useThreadOutboxMessages();67 const queuedMessagesByThreadKey = useThreadOutboxMessages();
51 const shellStatuses = useThreadOutboxShellStatuses();68 const shellStatuses = useThreadOutboxShellStatuses();
...@@ -68,6 +85,98 @@ export function useThreadOutboxDrain(): void {...@@ -68,6 +85,98 @@ export function useThreadOutboxDrain(): void {
6885
69 const sendQueuedMessage = useCallback(86 const sendQueuedMessage = useCallback(
70 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {87 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
88 const settings = resolveQueuedThreadSettings(queuedMessage, thread);
89 const reportFailure = (
90 commandResult: AtomCommandResult<unknown, unknown>,
91 stage: ThreadOutboxCommandStage,
92 ): boolean => {
93 if (!AsyncResult.isFailure(commandResult)) {
94 return false;
95 }
96 const action = resolveThreadOutboxFailureAction({
97 stage,
98 error: Cause.squash(commandResult.cause),
99 interrupted: Cause.hasInterruptsOnly(commandResult.cause),
100 });
101 const retry = action === "retry";
102 console.warn("[thread-outbox] queued message delivery failed", {
103 environmentId: queuedMessage.environmentId,
104 threadId: queuedMessage.threadId,
105 messageId: queuedMessage.messageId,
106 stage,
107 cause: commandResult.cause,
108 retry,
109 });
110 return retry;
111 };
112 const completeDelivery = async (
113 deliveryResult: AtomCommandResult<unknown, unknown>,
114 ): Promise<boolean> => {
115 if (reportFailure(deliveryResult, "start-turn")) {
116 return false;
117 }
118
119 try {
120 await removeThreadOutboxMessage(queuedMessage);
121 return true;
122 } catch (error) {
123 console.warn("[thread-outbox] failed to remove delivered queued message", {
124 environmentId: queuedMessage.environmentId,
125 threadId: queuedMessage.threadId,
126 messageId: queuedMessage.messageId,
127 error,
128 });
129 return false;
130 }
131 };
132
133 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
134 const updateResult = await updateThreadMetadata({
135 environmentId: queuedMessage.environmentId,
136 input: {
137 commandId: settingsCommandId(queuedMessage, "model-selection"),
138 threadId: queuedMessage.threadId,
139 modelSelection: settings.modelSelection,
140 },
141 });
142 if (AsyncResult.isFailure(updateResult)) {
143 reportFailure(updateResult, "settings-sync");
144 return false;
145 }
146 }
147
148 if (settings.runtimeMode !== thread.runtimeMode) {
149 const runtimeResult = await setThreadRuntimeMode({
150 environmentId: queuedMessage.environmentId,
151 input: {
152 commandId: settingsCommandId(queuedMessage, "runtime-mode"),
153 threadId: queuedMessage.threadId,
154 runtimeMode: settings.runtimeMode,
155 createdAt: queuedMessage.createdAt,
156 },
157 });
158 if (AsyncResult.isFailure(runtimeResult)) {
159 reportFailure(runtimeResult, "settings-sync");
160 return false;
161 }
162 }
163
164 if (settings.interactionMode !== thread.interactionMode) {
165 const interactionResult = await setThreadInteractionMode({
166 environmentId: queuedMessage.environmentId,
167 input: {
168 commandId: settingsCommandId(queuedMessage, "interaction-mode"),
169 threadId: queuedMessage.threadId,
170 interactionMode: settings.interactionMode,
171 createdAt: queuedMessage.createdAt,
172 },
173 });
174 if (AsyncResult.isFailure(interactionResult)) {
175 reportFailure(interactionResult, "settings-sync");
176 return false;
177 }
178 }
179
71 const deliveryResult = await startTurn({180 const deliveryResult = await startTurn({
72 environmentId: queuedMessage.environmentId,181 environmentId: queuedMessage.environmentId,
73 input: {182 input: {
...@@ -79,41 +188,15 @@ export function useThreadOutboxDrain(): void {...@@ -79,41 +188,15 @@ export function useThreadOutboxDrain(): void {
79 text: queuedMessage.text,188 text: queuedMessage.text,
80 attachments: queuedMessage.attachments,189 attachments: queuedMessage.attachments,
81 },190 },
82 runtimeMode: thread.runtimeMode,191 modelSelection: settings.modelSelection,
83 interactionMode: thread.interactionMode,192 runtimeMode: settings.runtimeMode,
193 interactionMode: settings.interactionMode,
84 createdAt: queuedMessage.createdAt,194 createdAt: queuedMessage.createdAt,
85 },195 },
86 });196 });
87 if (AsyncResult.isFailure(deliveryResult)) {197 return completeDelivery(deliveryResult);
88 const error = Cause.squash(deliveryResult.cause);
89 const retry =
90 Cause.hasInterruptsOnly(deliveryResult.cause) || shouldRetryThreadOutboxDelivery(error);
91 console.warn("[thread-outbox] queued message delivery failed", {
92 environmentId: queuedMessage.environmentId,
93 threadId: queuedMessage.threadId,
94 messageId: queuedMessage.messageId,
95 cause: deliveryResult.cause,
96 retry,
97 });
98 if (retry) {
99 return false;
100 }
101 }
102
103 try {
104 await removeThreadOutboxMessage(queuedMessage);
105 return true;
106 } catch (error) {
107 console.warn("[thread-outbox] failed to remove delivered queued message", {
108 environmentId: queuedMessage.environmentId,
109 threadId: queuedMessage.threadId,
110 messageId: queuedMessage.messageId,
111 error,
112 });
113 return false;
114 }
115 },198 },
116 [startTurn],199 [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata],
117 );200 );
118201
119 useEffect(() => {202 useEffect(() => {