| author | |
| committer | |
| log | 37ac970e289f6a79940c00e17c5a58114d94d4cf |
| tree | 358d8e79b81efe2c8ecf25994cbbfdeccc7c990b |
| parent | 8919ae7c53973048827481ecc3a96d4d778955f7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 32 | import { buildThreadRoutePath } from "../../lib/routes"; | 32 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 33 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 33 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 34 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; | ||
| 34 | import { useProjects } from "../../state/entities"; | 36 | import { useProjects } from "../../state/entities"; |
| 35 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 37 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 36 | import { useCreateProjectThread } from "./use-project-actions"; | 38 | import { 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); | ||
| 66 | 69 | ||
| 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; |
| 79 | 82 | ||
| 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: { |
| 105 | 114 | ||
| 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]); |
| 112 | 127 | ||
| 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 completes | 310 | 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 | } |
| 301 | 312 | ||
| 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 | ); |
| 367 | 378 | ||
| 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 | } |
| 378 | 404 | ||
| 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); |
| 392 | 418 |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+17-68| ... | @@ -1,13 +1,7 @@ | ... | @@ -1,13 +1,7 @@ |
| 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | import { useCallback, useMemo, useState } from "react"; | 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 4 | import { | 4 | import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; |
| 5 | EnvironmentId, | ||
| 6 | type ModelSelection, | ||
| 7 | type ProjectScript, | ||
| 8 | type ProviderInteractionMode, | ||
| 9 | type RuntimeMode, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 5 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 12 | import { Pressable, ScrollView, Text as RNText, View } from "react-native"; | 6 | import { Pressable, ScrollView, Text as RNText, View } from "react-native"; |
| 13 | import { useWorkspaceState } from "../../state/workspace"; | 7 | import { 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 | ); | ||
| 108 | 102 | ||
| 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() { |
| 435 | 384 | ||
| 436 | <View className="flex-1 bg-screen"> | 385 | <View className="flex-1 bg-screen"> |
| 437 | <ThreadDetailScreen | 386 | <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 @@ |
| 1 | import React, { useCallback, useMemo, useRef, useState } from "react"; | 1 | import React, { useCallback, useEffect, useMemo, useState } from "react"; |
| 2 | 2 | ||
| 3 | import type { | 3 | import 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"; |
| 28 | import { useBranches } from "../../state/queries"; | 29 | import { 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 | ? selectedEnvironmentIdOverride | 159 | ? 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); |
| 176 | 165 | ||
| 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 | }, []); |
| 191 | 173 | ||
| ... | @@ -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; | ||
| 250 | 237 | ||
| 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 | ); |
| 259 | 250 | ||
| 260 | const defaultModelKey = selectedProject?.defaultModelSelection | 251 | 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 = baseSelectedModel | 256 | 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; | ||
| 277 | 259 | ||
| 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 = options | 294 | 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 | ); |
| 307 | 306 | ||
| 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) { |
| 381 | 380 | ||
| 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 | }, []); |
| 391 | 386 | ||
| 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 | }, []); |
| 400 | 391 | ||
| 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 | ); |
| 410 | 423 | ||
| 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]); | ||
| 415 | 432 | ||
| 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 | ); | ||
| 440 | 462 | ||
| 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 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | export 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 @@ |
| 1 | import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; | 1 | import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; |
| 2 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; | 3 | import { |
| 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"; | ||
| 4 | import * as Schema from "effect/Schema"; | 16 | import * as Schema from "effect/Schema"; |
| 5 | 17 | ||
| 18 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; | ||
| 6 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 19 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 7 | import { scopedThreadKey } from "../lib/scopedEntities"; | 20 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 8 | 21 | ||
| 9 | const THREAD_OUTBOX_SCHEMA_VERSION = 1; | 22 | const THREAD_OUTBOX_SCHEMA_VERSION = 2; |
| 10 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; | 23 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; |
| 11 | 24 | ||
| 12 | const 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 | |||
| 22 | export const QueuedThreadMessageSchema = Schema.Struct({ | 25 | export 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 | }); |
| 32 | 38 | ||
| ... | @@ -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 | } |
| 45 | 54 | ||
| 55 | export interface ThreadSettingsSnapshot { | ||
| 56 | readonly modelSelection: ModelSelectionType; | ||
| 57 | readonly runtimeMode: RuntimeModeType; | ||
| 58 | readonly interactionMode: ProviderInteractionModeType; | ||
| 59 | } | ||
| 60 | |||
| 61 | export 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 | |||
| 72 | export 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 | |||
| 46 | export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { | 80 | export 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 | |||
| 157 | export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; | ||
| 158 | export type ThreadOutboxFailureAction = "retry" | "discard"; | ||
| 159 | |||
| 160 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; | 2 | import { |
| 3 | CommandId, | ||
| 4 | EnvironmentId, | ||
| 5 | MessageId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | 9 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 10 | ||
| 5 | import { | 11 | import { |
| 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 | }); |
| 68 | 78 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts"; | 4 | import { appAtomRegistry } from "./atom-registry"; |
| 5 | import { | ||
| 6 | clearComposerDraftContentState, | ||
| 7 | composerDraftsAtom, | ||
| 8 | decodePersistedComposerDrafts, | ||
| 9 | type ComposerDraft, | ||
| 10 | getComposerDraftSnapshot, | ||
| 11 | removeComposerDraftsForEnvironment, | ||
| 12 | } from "./use-composer-drafts"; | ||
| 5 | 13 | ||
| 6 | const DRAFT: ComposerDraft = { | 14 | const DRAFT: ComposerDraft = { |
| 7 | text: "hello", | 15 | text: "hello", |
| 8 | attachments: [], | 16 | attachments: [], |
| 9 | }; | 17 | }; |
| 10 | 18 | ||
| 19 | afterEach(() => { | ||
| 20 | appAtomRegistry.set(composerDraftsAtom, {}); | ||
| 21 | }); | ||
| 22 | |||
| 11 | describe("mobile composer drafts", () => { | 23 | describe("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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import { |
| 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"; | ||
| 3 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 4 | import { useEffect } from "react"; | 12 | import { useEffect } from "react"; |
| 5 | import { Atom } from "effect/unstable/reactivity"; | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 14 | ||
| 15 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; | ||
| 7 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 16 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 8 | import { appAtomRegistry } from "./atom-registry"; | 17 | import { appAtomRegistry } from "./atom-registry"; |
| 9 | 18 | ||
| ... | @@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo | ... | @@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo |
| 29 | export interface ComposerDraft { | 38 | export 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 | } |
| 33 | 46 | ||
| 34 | interface PersistedComposerDrafts { | 47 | export 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 | } |
| 38 | 52 | ||
| 53 | export type ComposerDraftSettingsUpdate = Pick< | ||
| 54 | ComposerDraft, | ||
| 55 | "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | ||
| 56 | >; | ||
| 57 | |||
| 58 | const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ | ||
| 59 | mode: Schema.Literals(["local", "worktree"]), | ||
| 60 | branch: Schema.NullOr(Schema.String), | ||
| 61 | worktreePath: Schema.NullOr(Schema.String), | ||
| 62 | }); | ||
| 63 | |||
| 64 | const 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 | |||
| 73 | const PersistedComposerDraftsSchema = Schema.Struct({ | ||
| 74 | schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), | ||
| 75 | drafts: Schema.Record(Schema.String, ComposerDraftSchema), | ||
| 76 | }); | ||
| 77 | |||
| 78 | const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( | ||
| 79 | PersistedComposerDraftsSchema, | ||
| 80 | ); | ||
| 81 | |||
| 39 | const EMPTY_DRAFT: ComposerDraft = { | 82 | const 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 | } |
| 61 | 105 | ||
| 106 | export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { | ||
| 107 | return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); | ||
| 108 | } | ||
| 109 | |||
| 62 | function isEmptyDraft(draft: ComposerDraft): boolean { | 110 | function 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 | |||
| 121 | export 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 | } |
| 65 | 127 | ||
| 66 | async function getComposerDraftsFile() { | 128 | async 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 | } |
| 284 | 333 | ||
| 334 | export 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 | |||
| 355 | export 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 | |||
| 379 | export function clearComposerDraftContent(draftKey: string): void { | ||
| 380 | updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); | ||
| 381 | } | ||
| 382 | |||
| 285 | export function clearComposerDraft(draftKey: string): void { | 383 | export 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 | } |
| 305 | 407 |
apps/mobile/src/state/use-thread-composer-state.ts+61-8| ... | @@ -1,7 +1,15 @@ | ... | @@ -1,7 +1,15 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useEffect, useMemo } from "react"; | 2 | import { useCallback, useEffect, useMemo } from "react"; |
| 3 | 3 | ||
| 4 | import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; | 4 | import { |
| 5 | CommandId, | ||
| 6 | MessageId, | ||
| 7 | type EnvironmentId, | ||
| 8 | type ModelSelection, | ||
| 9 | type ProviderInteractionMode, | ||
| 10 | type RuntimeMode, | ||
| 11 | type ThreadId, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 5 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 13 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 6 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; | 14 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; |
| 7 | 15 | ||
| ... | @@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry"; | ... | @@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry"; |
| 18 | import { | 26 | import { |
| 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"; |
| 28 | import { setPendingConnectionError } from "../state/use-remote-environment-registry"; | 38 | import { 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; | ||
| 101 | 115 | ||
| 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 | ]); |
| 132 | 146 | ||
| 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 | } |
| 142 | 155 | ||
| 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]); |
| 169 | 186 | ||
| 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 | ); |
| 257 | 274 | ||
| 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { type MessageId } from "@t3tools/contracts"; | 3 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 4 | import { CommandId, type MessageId } from "@t3tools/contracts"; | ||
| 4 | import * as Cause from "effect/Cause"; | 5 | import * as Cause from "effect/Cause"; |
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 6 | import { useCallback, useEffect, useRef, useState } from "react"; | 7 | import { useCallback, useEffect, useRef, useState } from "react"; |
| ... | @@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry"; | ... | @@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry"; |
| 10 | import { useThreadShells } from "./entities"; | 11 | import { useThreadShells } from "./entities"; |
| 11 | import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; | 12 | import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; |
| 12 | import { | 13 | import { |
| 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"; |
| 18 | import { threadEnvironment } from "./threads"; | 22 | import { threadEnvironment } from "./threads"; |
| 19 | import { useAtomCommand } from "./use-atom-command"; | 23 | import { useAtomCommand } from "./use-atom-command"; |
| ... | @@ -44,8 +48,21 @@ function findThread( | ... | @@ -44,8 +48,21 @@ function findThread( |
| 44 | ); | 48 | ); |
| 45 | } | 49 | } |
| 46 | 50 | ||
| 51 | function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { | ||
| 52 | return CommandId.make(`${message.commandId}:${setting}`); | ||
| 53 | } | ||
| 54 | |||
| 47 | export function useThreadOutboxDrain(): void { | 55 | export 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 { |
| 68 | 85 | ||
| 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 | ); |
| 118 | 201 | ||
| 119 | useEffect(() => { | 202 | useEffect(() => { |