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 {
3030 resolveProviderOptionDescriptors,
3131} from "../../lib/providerOptions";
3232import { buildThreadRoutePath } from "../../lib/routes";
33import { scopedProjectKey } from "../../lib/scopedEntities";
3334import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
35import { getComposerDraftSnapshot } from "../../state/use-composer-drafts";
3436import { useProjects } from "../../state/entities";
3537import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
3638import { useCreateProjectThread } from "./use-project-actions";
......@@ -63,6 +65,7 @@ export function NewTaskDraftScreen(props: {
6365 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
6466 const { logicalProjects, selectedProject, setProject } = flow;
6567 const promptInputRef = useRef<ComposerEditorHandle>(null);
68 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
6669
6770 const borderColor = useThemeColor("--color-border");
6871 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
......@@ -78,6 +81,12 @@ export function NewTaskDraftScreen(props: {
7881 ) ?? null;
7982
8083 if (directProject) {
84 if (
85 selectedProject?.environmentId === directProject.environmentId &&
86 selectedProject.id === directProject.id
87 ) {
88 return;
89 }
8190 setProject(directProject);
8291 return;
8392 }
......@@ -105,10 +114,16 @@ export function NewTaskDraftScreen(props: {
105114
106115 useEffect(() => {
107116 if (!selectedProject) {
117 loadedBranchesProjectKeyRef.current = null;
118 return;
119 }
120 const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`;
121 if (loadedBranchesProjectKeyRef.current === projectKey) {
108122 return;
109123 }
124 loadedBranchesProjectKeyRef.current = projectKey;
110125 void flow.loadBranches();
111 }, [flow, selectedProject]);
126 }, [flow.loadBranches, selectedProject]);
112127
113128 useEffect(() => {
114129 if (!selectedProject) {
......@@ -292,11 +307,7 @@ export function NewTaskDraftScreen(props: {
292307 if (!event.startsWith("model:")) {
293308 return;
294309 }
295 // Defer state update so the native menu dismiss animation completes
296 // before re-rendering the menu actions (prevents submenu jump).
297 setTimeout(() => {
298 flow.setSelectedModelKey(event.slice("model:".length));
299 }, 150);
310 flow.setSelectedModelKey(event.slice("model:".length));
300311 }
301312
302313 function handleEnvironmentMenuAction(event: string) {
......@@ -366,27 +377,42 @@ export function NewTaskDraftScreen(props: {
366377 );
367378
368379 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
369396 if (
370 !flow.selectedProject ||
371 !flow.selectedModel ||
372 flow.prompt.trim().length === 0 ||
397 !modelSelection ||
398 initialMessageText.length === 0 ||
373399 flow.submitting ||
374 (flow.workspaceMode === "worktree" && !flow.selectedBranchName)
400 (workspaceMode === "worktree" && !selectedBranchName)
375401 ) {
376402 return;
377403 }
378404
379405 flow.setSubmitting(true);
380406 const result = await createProjectThread({
381 project: flow.selectedProject,
382 modelSelection: flow.selectedModel,
383 envMode: flow.workspaceMode,
384 branch: flow.selectedBranchName,
385 worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath,
386 runtimeMode: flow.runtimeMode,
387 interactionMode: flow.interactionMode,
388 initialMessageText: flow.prompt.trim(),
389 initialAttachments: flow.attachments,
407 project: selectedProject,
408 modelSelection,
409 envMode: workspaceMode,
410 branch: selectedBranchName,
411 worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
412 runtimeMode,
413 interactionMode,
414 initialMessageText,
415 initialAttachments: draft.attachments,
390416 });
391417 flow.setSubmitting(false);
392418
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+17-68
......@@ -1,13 +1,7 @@
11import { Stack, useLocalSearchParams, useRouter } from "expo-router";
22import { useCallback, useMemo, useState } from "react";
33import * as Option from "effect/Option";
4import {
5 EnvironmentId,
6 type ModelSelection,
7 type ProjectScript,
8 type ProviderInteractionMode,
9 type RuntimeMode,
10} from "@t3tools/contracts";
4import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
115import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
126import { Pressable, ScrollView, Text as RNText, View } from "react-native";
137import { useWorkspaceState } from "../../state/workspace";
......@@ -79,18 +73,6 @@ export function ThreadRouteScreen() {
7973 const gitState = useSelectedThreadGitState();
8074 const gitActions = useSelectedThreadGitActions();
8175 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 );
9476 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
9577 const router = useRouter();
9678 const params = useLocalSearchParams<{
......@@ -105,6 +87,18 @@ export function ThreadRouteScreen() {
10587 const routeConnectionState =
10688 routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
10789 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
109103 /* ─── Native header theming ──────────────────────────────────────── */
110104 const iconColor = String(useThemeColor("--color-icon"));
......@@ -157,51 +151,6 @@ export function ThreadRouteScreen() {
157151 const handleOpenConnectionEditor = useCallback(() => {
158152 void router.push("/connections");
159153 }, [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 );
205154 const handleStopThread = useCallback(() => {
206155 if (
207156 !selectedThread ||
......@@ -435,7 +384,7 @@ export function ThreadRouteScreen() {
435384
436385 <View className="flex-1 bg-screen">
437386 <ThreadDetailScreen
438 selectedThread={selectedThread}
387 selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
439388 contentPresentation={contentPresentation}
440389 screenTone={connectionTone(routeConnectionState)}
441390 connectionError={routeConnectionError}
......@@ -466,9 +415,9 @@ export function ThreadRouteScreen() {
466415 onStopThread={handleStopThread}
467416 onSendMessage={composer.onSendMessage}
468417 onReconnectEnvironment={handleReconnectEnvironment}
469 onUpdateThreadModelSelection={handleUpdateThreadModelSelection}
470 onUpdateThreadRuntimeMode={handleUpdateThreadRuntimeMode}
471 onUpdateThreadInteractionMode={handleUpdateThreadInteractionMode}
418 onUpdateThreadModelSelection={composer.onUpdateModelSelection}
419 onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode}
420 onUpdateThreadInteractionMode={composer.onUpdateInteractionMode}
472421 onRespondToApproval={requests.onRespondToApproval}
473422 onSelectUserInputOption={requests.onSelectUserInputOption}
474423 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+106-79
......@@ -1,4 +1,4 @@
1import React, { useCallback, useMemo, useRef, useState } from "react";
1import React, { useCallback, useEffect, useMemo, useState } from "react";
22
33import type {
44 EnvironmentId,
......@@ -23,6 +23,7 @@ import {
2323 removeComposerDraftAttachment,
2424 replaceComposerDraftAttachments,
2525 setComposerDraftText,
26 updateComposerDraftSettings,
2627 useComposerDraft,
2728} from "../../state/use-composer-drafts";
2829import { useBranches } from "../../state/queries";
......@@ -158,34 +159,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
158159 ? selectedEnvironmentIdOverride
159160 : (projects[0]?.environmentId ?? null);
160161 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);
166162 const [submitting, setSubmitting] = useState(false);
167163 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 >({});
175164 const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
176165
177166 const reset = useCallback(() => {
178167 setSelectedEnvironmentId(null);
179168 setSelectedProjectKey(null);
180 setSelectedModelKey(null);
181 setWorkspaceMode("local");
182 setSelectedBranchName(null);
183 setSelectedWorktreePath(null);
184169 setSubmitting(false);
185170 setBranchQuery("");
186 setRuntimeMode(DEFAULT_RUNTIME_MODE);
187 setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE);
188 setModelSelectionOverrides({});
189171 setExpandedProvider(null);
190172 }, []);
191173
......@@ -247,33 +229,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
247229 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
248230 const prompt = selectedProjectDraft.text;
249231 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
251238 const modelOptions = useMemo(
252239 () =>
253240 buildModelOptions(
254241 selectedEnvironmentServerConfig,
255 selectedProject?.defaultModelSelection ?? null,
242 selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null,
256243 ),
257 [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection],
244 [
245 selectedEnvironmentServerConfig,
246 selectedProject?.defaultModelSelection,
247 selectedProjectDraft.modelSelection,
248 ],
258249 );
259250
260 const defaultModelKey = selectedProject?.defaultModelSelection
261 ? `${selectedProject.defaultModelSelection.instanceId}:${selectedProject.defaultModelSelection.model}`
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) ??
251 const selectedModel =
252 selectedProjectDraft.modelSelection ??
268253 selectedProject?.defaultModelSelection ??
269254 modelOptions[0]?.selection ??
270255 null;
271 const selectedModelIdentity = baseSelectedModel
272 ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}`
256 const selectedModelKey = selectedModel
257 ? `${selectedModel.instanceId}:${selectedModel.model}`
273258 : null;
274 const selectedModel =
275 (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ??
276 baseSelectedModel;
277259
278260 const selectedModelOption =
279261 modelOptions.find(
......@@ -282,13 +264,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
282264 option.selection.instanceId === selectedModel.instanceId &&
283265 option.selection.model === selectedModel.model,
284266 ) ?? null;
285 const selectedProviderSkills =
286 selectedEnvironmentServerConfig?.providers.find(
287 (provider) => provider.instanceId === selectedModel?.instanceId,
288 )?.skills ?? [];
267 const selectedProviderSkills = useMemo(
268 () =>
269 selectedEnvironmentServerConfig?.providers.find(
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 );
289289 const setSelectedModelOptions = useCallback(
290290 (options: ReadonlyArray<ProviderOptionSelection> | undefined) => {
291 if (!selectedModel || !selectedModelIdentity) {
291 if (!selectedModel || !selectedProjectDraftKey) {
292292 return;
293293 }
294294 const nextSelection: ModelSelection = options
......@@ -297,12 +297,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
297297 instanceId: selectedModel.instanceId,
298298 model: selectedModel.model,
299299 };
300 setModelSelectionOverrides((current) => ({
301 ...current,
302 [selectedModelIdentity]: nextSelection,
303 }));
300 updateComposerDraftSettings(selectedProjectDraftKey, {
301 modelSelection: nextSelection,
302 });
304303 },
305 [selectedModel, selectedModelIdentity],
304 [selectedModel, selectedProjectDraftKey],
306305 );
307306
308307 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
......@@ -381,62 +380,85 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
381380
382381 const setProject = useCallback((project: EnvironmentProject) => {
383382 const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
384 branchLoadVersionRef.current += 1;
385383 setSelectedEnvironmentId(project.environmentId);
386384 setSelectedProjectKey(nextProjectKey);
387 setSelectedBranchName(null);
388 setSelectedWorktreePath(null);
389 setModelSelectionOverrides({});
390385 }, []);
391386
392387 const selectEnvironment = useCallback((environmentId: EnvironmentId) => {
393 branchLoadVersionRef.current += 1;
394388 setSelectedEnvironmentId(environmentId);
395389 setSelectedProjectKey(null);
396 setSelectedBranchName(null);
397 setSelectedWorktreePath(null);
398 setModelSelectionOverrides({});
399390 }, []);
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
401408 const selectBranch = useCallback(
402409 (branch: VcsRef) => {
403 setSelectedBranchName(branch.name);
404 setSelectedWorktreePath(
405 selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null,
406 );
410 if (!selectedProject || !selectedProjectDraftKey) {
411 return;
412 }
413 updateComposerDraftSettings(selectedProjectDraftKey, {
414 workspaceSelection: {
415 mode: workspaceMode,
416 branch: branch.name,
417 worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),
418 },
419 });
407420 },
408 [selectedProject],
421 [selectedProject, selectedProjectDraftKey, workspaceMode],
409422 );
410423
424 const refreshBranches = branchState.refresh;
411425 const loadBranches = useCallback(async () => {
412426 if (!selectedProject) {
413427 return;
414428 }
429 setPendingConnectionError(null);
430 refreshBranches();
431 }, [refreshBranches, selectedProject]);
415432
416 const loadVersion = ++branchLoadVersionRef.current;
417 const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id);
418 branchState.refresh();
419 if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) {
433 useEffect(() => {
434 if (workspaceMode !== "worktree" || selectedBranchName !== null) {
420435 return;
421436 }
422 setPendingConnectionError(null);
423 if (workspaceMode === "worktree" && !selectedBranchName) {
424 const preferredBranch =
425 availableBranches.find((branch) => branch.current)?.name ??
426 availableBranches.find((branch) => branch.isDefault)?.name ??
427 null;
428 if (preferredBranch) {
429 setSelectedBranchName(preferredBranch);
430 }
437 const preferredBranch =
438 availableBranches.find((branch) => branch.current) ??
439 availableBranches.find((branch) => branch.isDefault) ??
440 null;
441 if (preferredBranch) {
442 selectBranch(preferredBranch);
431443 }
432 }, [
433 availableBranches,
434 branchState,
435 selectedBranchName,
436 selectedProject,
437 selectedProjectKey,
438 workspaceMode,
439 ]);
444 }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]);
445
446 const setRuntimeMode = useCallback(
447 (value: RuntimeMode) => {
448 if (selectedProjectDraftKey) {
449 updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value });
450 }
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
441463 const value = useMemo<NewTaskFlowContextValue>(
442464 () => ({
......@@ -513,6 +535,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
513535 setProject,
514536 selectBranch,
515537 selectEnvironment,
538 setInteractionMode,
539 setPrompt,
540 setRuntimeMode,
541 setSelectedModelKey,
542 setWorkspaceMode,
516543 submitting,
517544 workspaceMode,
518545 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 @@
11import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors";
22import 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";
416import * as Schema from "effect/Schema";
517
18import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
619import type { DraftComposerImageAttachment } from "../lib/composerImages";
720import { scopedThreadKey } from "../lib/scopedEntities";
821
9const THREAD_OUTBOX_SCHEMA_VERSION = 1;
22const THREAD_OUTBOX_SCHEMA_VERSION = 2;
1023const 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
2225export const QueuedThreadMessageSchema = Schema.Struct({
23 schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION),
26 schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]),
2427 environmentId: EnvironmentId,
2528 threadId: ThreadId,
2629 messageId: MessageId,
2730 commandId: CommandId,
2831 text: Schema.String,
2932 attachments: Schema.Array(DraftComposerImageAttachmentSchema),
33 modelSelection: Schema.optional(ModelSelection),
34 runtimeMode: Schema.optional(RuntimeMode),
35 interactionMode: Schema.optional(ProviderInteractionMode),
3036 createdAt: IsoDateTime,
3137});
3238
......@@ -40,9 +46,37 @@ export interface QueuedThreadMessage {
4046 readonly commandId: CommandId;
4147 readonly text: string;
4248 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
49 readonly modelSelection?: ModelSelectionType;
50 readonly runtimeMode?: RuntimeModeType;
51 readonly interactionMode?: ProviderInteractionModeType;
4352 readonly createdAt: string;
4453}
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
4680export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown {
4781 return encodeStoredQueuedThreadMessage({
4882 schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION,
......@@ -119,3 +153,21 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean {
119153 }
120154 return isTransportConnectionErrorMessage(errorMessage(error));
121155}
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 @@
11import { 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";
39import { AtomRegistry } from "effect/unstable/reactivity";
410
511import {
612 decodeQueuedThreadMessage,
13 encodeQueuedThreadMessage,
714 groupQueuedThreadMessages,
15 modelSelectionsEqual,
816 resolveThreadOutboxDeliveryAction,
17 resolveThreadOutboxFailureAction,
18 resolveQueuedThreadSettings,
919 shouldRetryThreadOutboxDelivery,
1020 threadOutboxRetryDelayMs,
1121 type QueuedThreadMessage,
......@@ -66,6 +76,54 @@ describe("thread outbox", () => {
6676 ).toThrow();
6777 });
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
69127 it("backs off queued delivery retries and caps them at sixteen seconds", () => {
70128 expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([
71129 1_000, 2_000, 4_000, 8_000, 16_000, 16_000,
......@@ -271,4 +329,23 @@ describe("thread outbox", () => {
271329 ).toBe(true);
272330 expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false);
273331 });
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 });
274351});
apps/mobile/src/state/use-composer-drafts.test.ts+128-3
......@@ -1,14 +1,136 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
1import { afterEach, describe, expect, it } from "@effect/vitest";
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
614const DRAFT: ComposerDraft = {
715 text: "hello",
816 attachments: [],
917};
1018
19afterEach(() => {
20 appAtomRegistry.set(composerDraftsAtom, {});
21});
22
1123describe("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
12134 it("removes only drafts owned by the selected environment", () => {
13135 const environmentId = EnvironmentId.make("environment-cloud");
14136 const retainedEnvironmentId = EnvironmentId.make("environment-local");
......@@ -17,12 +139,15 @@ describe("mobile composer drafts", () => {
17139 removeComposerDraftsForEnvironment(
18140 {
19141 [`${environmentId}:thread-cloud`]: DRAFT,
142 [`new-task:${environmentId}:project-cloud`]: DRAFT,
20143 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
144 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
21145 },
22146 environmentId,
23147 ),
24148 ).toEqual({
25149 [`${retainedEnvironmentId}:thread-local`]: DRAFT,
150 [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
26151 });
27152 });
28153});
apps/mobile/src/state/use-composer-drafts.ts+124-22
......@@ -1,9 +1,18 @@
11import { 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";
311import * as Schema from "effect/Schema";
412import { useEffect } from "react";
513import { Atom } from "effect/unstable/reactivity";
614
15import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
716import type { DraftComposerImageAttachment } from "../lib/composerImages";
817import { appAtomRegistry } from "./atom-registry";
918
......@@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo
2938export interface ComposerDraft {
3039 readonly text: string;
3140 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
41 readonly modelSelection?: ModelSelection;
42 readonly runtimeMode?: RuntimeMode;
43 readonly interactionMode?: ProviderInteractionMode;
44 readonly workspaceSelection?: ComposerDraftWorkspaceSelection;
3245}
3346
34interface PersistedComposerDrafts {
35 readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION;
36 readonly drafts: Record<string, ComposerDraft>;
47export interface ComposerDraftWorkspaceSelection {
48 readonly mode: "local" | "worktree";
49 readonly branch: string | null;
50 readonly worktreePath: string | null;
3751}
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
3982const EMPTY_DRAFT: ComposerDraft = {
4083 text: "",
4184 attachments: [],
......@@ -54,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
5497 return EMPTY_DRAFT;
5598 }
5699 return {
100 ...draft,
57101 text: draft.text,
58102 attachments: draft.attachments,
59103 };
60104}
61105
106export function getComposerDraftSnapshot(draftKey: string): ComposerDraft {
107 return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]);
108}
109
62110function 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 );
64126}
65127
66128async function getComposerDraftsFile() {
......@@ -80,20 +142,7 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra
80142 operation = "read";
81143 const raw = await file.text();
82144 operation = "decode";
83 const parsed = JSON.parse(raw) as Partial<PersistedComposerDrafts>;
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 );
145 return decodePersistedComposerDrafts(JSON.parse(raw) as unknown);
97146 } catch (cause) {
98147 console.warn(
99148 "[composer-drafts] ignored persisted draft failure",
......@@ -116,10 +165,10 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
116165 const nonEmptyDrafts = Object.fromEntries(
117166 Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)),
118167 );
119 const document: PersistedComposerDrafts = {
168 const document = {
120169 schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION,
121170 drafts: nonEmptyDrafts,
122 };
171 } as const;
123172 const encoded = JSON.stringify(document);
124173 operation = "write";
125174 if (!file.exists) {
......@@ -282,6 +331,55 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string)
282331 });
283332}
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
285383export function clearComposerDraft(draftKey: string): void {
286384 updateComposerDrafts((current) => {
287385 if (!current[draftKey]) {
......@@ -298,8 +396,12 @@ export function removeComposerDraftsForEnvironment(
298396 environmentId: EnvironmentId,
299397): Record<string, ComposerDraft> {
300398 const environmentPrefix = `${environmentId}:`;
399 const newTaskPrefix = `new-task:${environmentId}:`;
301400 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 ),
303405 );
304406}
305407
apps/mobile/src/state/use-thread-composer-state.ts+61-8
......@@ -1,7 +1,15 @@
11import { useAtomValue } from "@effect/atom-react";
22import { 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";
513import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
614import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
715
......@@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry";
1826import {
1927 appendComposerDraftAttachments,
2028 appendComposerDraftText,
21 clearComposerDraft,
29 clearComposerDraftContent,
2230 composerDraftsAtom,
2331 ensureComposerDraftsLoaded,
32 getComposerDraftSnapshot,
2433 removeComposerDraftAttachment,
2534 setComposerDraftText,
35 updateComposerDraftSettings,
2636 useComposerDraft,
2737} from "./use-composer-drafts";
2838import { setPendingConnectionError } from "../state/use-remote-environment-registry";
......@@ -98,6 +108,10 @@ export function useThreadComposerState() {
98108 const draftMessage = selectedDraft?.text ?? "";
99109 const draftAttachments = selectedDraft?.attachments ?? [];
100110 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
102116 const selectedThreadSessionActivity = useMemo(() => {
103117 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
......@@ -130,7 +144,6 @@ export function useThreadComposerState() {
130144 selectedThreadShell,
131145 ]);
132146
133 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
134147 const activeThreadBusy =
135148 !!selectedThread &&
136149 (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting");
......@@ -141,9 +154,10 @@ export function useThreadComposerState() {
141154 }
142155
143156 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
144 const draft = composerDrafts[threadKey];
145 const text = (draft?.text ?? "").trim();
146 const attachments = draft?.attachments ?? [];
157 const draft = getComposerDraftSnapshot(threadKey);
158 const thread = selectedThreadDetail ?? selectedThreadShell;
159 const text = draft.text.trim();
160 const attachments = draft.attachments;
147161 if (text.length === 0 && attachments.length === 0) {
148162 return;
149163 }
......@@ -157,15 +171,18 @@ export function useThreadComposerState() {
157171 commandId: CommandId.make(metadata.commandId),
158172 text,
159173 attachments,
174 modelSelection: draft.modelSelection ?? thread.modelSelection,
175 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
176 interactionMode: draft.interactionMode ?? thread.interactionMode,
160177 createdAt: metadata.createdAt,
161178 });
162 clearComposerDraft(threadKey);
179 clearComposerDraftContent(threadKey);
163180 } catch (error) {
164181 setPendingConnectionError(
165182 error instanceof Error ? error.message : "Failed to save the queued message.",
166183 );
167184 }
168 }, [composerDrafts, selectedThreadShell]);
185 }, [selectedThreadDetail, selectedThreadShell]);
169186
170187 const onChangeDraftMessage = useCallback(
171188 (value: string) => {
......@@ -255,12 +272,45 @@ export function useThreadComposerState() {
255272 [selectedThreadShell],
256273 );
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
258305 return {
259306 selectedThreadFeed,
260307 selectedThreadQueueCount,
261308 activeWorkStartedAt,
262309 draftMessage,
263310 draftAttachments,
311 modelSelection,
312 runtimeMode,
313 interactionMode,
264314 activeThreadBusy,
265315 onChangeDraftMessage,
266316 onPickDraftImages,
......@@ -268,5 +318,8 @@ export function useThreadComposerState() {
268318 onNativePasteImages,
269319 onRemoveDraftImage,
270320 onSendMessage,
321 onUpdateModelSelection,
322 onUpdateRuntimeMode,
323 onUpdateInteractionMode,
271324 };
272325}
apps/mobile/src/state/use-thread-outbox-drain.ts+116-33
......@@ -1,6 +1,7 @@
11import { useAtomValue } from "@effect/atom-react";
22import 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";
45import * as Cause from "effect/Cause";
56import { AsyncResult, Atom } from "effect/unstable/reactivity";
67import { useCallback, useEffect, useRef, useState } from "react";
......@@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry";
1011import { useThreadShells } from "./entities";
1112import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox";
1213import {
14 modelSelectionsEqual,
1315 resolveThreadOutboxDeliveryAction,
14 shouldRetryThreadOutboxDelivery,
16 resolveThreadOutboxFailureAction,
17 resolveQueuedThreadSettings,
1518 threadOutboxRetryDelayMs,
1619 type QueuedThreadMessage,
20 type ThreadOutboxCommandStage,
1721} from "./thread-outbox-model";
1822import { threadEnvironment } from "./threads";
1923import { useAtomCommand } from "./use-atom-command";
......@@ -44,8 +48,21 @@ function findThread(
4448 );
4549}
4650
51function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId {
52 return CommandId.make(`${message.commandId}:${setting}`);
53}
54
4755export function useThreadOutboxDrain(): void {
4856 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 });
4966 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
5067 const queuedMessagesByThreadKey = useThreadOutboxMessages();
5168 const shellStatuses = useThreadOutboxShellStatuses();
......@@ -68,6 +85,98 @@ export function useThreadOutboxDrain(): void {
6885
6986 const sendQueuedMessage = useCallback(
7087 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
71180 const deliveryResult = await startTurn({
72181 environmentId: queuedMessage.environmentId,
73182 input: {
......@@ -79,41 +188,15 @@ export function useThreadOutboxDrain(): void {
79188 text: queuedMessage.text,
80189 attachments: queuedMessage.attachments,
81190 },
82 runtimeMode: thread.runtimeMode,
83 interactionMode: thread.interactionMode,
191 modelSelection: settings.modelSelection,
192 runtimeMode: settings.runtimeMode,
193 interactionMode: settings.interactionMode,
84194 createdAt: queuedMessage.createdAt,
85195 },
86196 });
87 if (AsyncResult.isFailure(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 }
197 return completeDelivery(deliveryResult);
115198 },
116 [startTurn],
199 [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata],
117200 );
118201
119202 useEffect(() => {