| 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 | 30 | resolveProviderOptionDescriptors, |
| 31 | 31 | } from "../../lib/providerOptions"; |
| 32 | 32 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 33 | import { scopedProjectKey } from "../../lib/scopedEntities"; | |
| 33 | 34 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; | |
| 34 | 36 | import { useProjects } from "../../state/entities"; |
| 35 | 37 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 36 | 38 | import { useCreateProjectThread } from "./use-project-actions"; |
| ... | ... | @@ -63,6 +65,7 @@ export function NewTaskDraftScreen(props: { |
| 63 | 65 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); |
| 64 | 66 | const { logicalProjects, selectedProject, setProject } = flow; |
| 65 | 67 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 68 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); | |
| 66 | 69 | |
| 67 | 70 | const borderColor = useThemeColor("--color-border"); |
| 68 | 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 | 81 | ) ?? null; |
| 79 | 82 | |
| 80 | 83 | if (directProject) { |
| 84 | if ( | |
| 85 | selectedProject?.environmentId === directProject.environmentId && | |
| 86 | selectedProject.id === directProject.id | |
| 87 | ) { | |
| 88 | return; | |
| 89 | } | |
| 81 | 90 | setProject(directProject); |
| 82 | 91 | return; |
| 83 | 92 | } |
| ... | ... | @@ -105,10 +114,16 @@ export function NewTaskDraftScreen(props: { |
| 105 | 114 | |
| 106 | 115 | useEffect(() => { |
| 107 | 116 | if (!selectedProject) { |
| 117 | loadedBranchesProjectKeyRef.current = null; | |
| 118 | return; | |
| 119 | } | |
| 120 | const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`; | |
| 121 | if (loadedBranchesProjectKeyRef.current === projectKey) { | |
| 108 | 122 | return; |
| 109 | 123 | } |
| 124 | loadedBranchesProjectKeyRef.current = projectKey; | |
| 110 | 125 | void flow.loadBranches(); |
| 111 | }, [flow, selectedProject]); | |
| 126 | }, [flow.loadBranches, selectedProject]); | |
| 112 | 127 | |
| 113 | 128 | useEffect(() => { |
| 114 | 129 | if (!selectedProject) { |
| ... | ... | @@ -292,11 +307,7 @@ export function NewTaskDraftScreen(props: { |
| 292 | 307 | if (!event.startsWith("model:")) { |
| 293 | 308 | return; |
| 294 | 309 | } |
| 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)); | |
| 300 | 311 | } |
| 301 | 312 | |
| 302 | 313 | function handleEnvironmentMenuAction(event: string) { |
| ... | ... | @@ -366,27 +377,42 @@ export function NewTaskDraftScreen(props: { |
| 366 | 377 | ); |
| 367 | 378 | |
| 368 | 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 | 396 | if ( |
| 370 | !flow.selectedProject || | |
| 371 | !flow.selectedModel || | |
| 372 | flow.prompt.trim().length === 0 || | |
| 397 | !modelSelection || | |
| 398 | initialMessageText.length === 0 || | |
| 373 | 399 | flow.submitting || |
| 374 | (flow.workspaceMode === "worktree" && !flow.selectedBranchName) | |
| 400 | (workspaceMode === "worktree" && !selectedBranchName) | |
| 375 | 401 | ) { |
| 376 | 402 | return; |
| 377 | 403 | } |
| 378 | 404 | |
| 379 | 405 | flow.setSubmitting(true); |
| 380 | 406 | 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, | |
| 390 | 416 | }); |
| 391 | 417 | flow.setSubmitting(false); |
| 392 | 418 |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+17-68| ... | ... | @@ -1,13 +1,7 @@ |
| 1 | 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 2 | 2 | import { useCallback, useMemo, useState } from "react"; |
| 3 | 3 | import * as Option from "effect/Option"; |
| 4 | import { | |
| 5 | EnvironmentId, | |
| 6 | type ModelSelection, | |
| 7 | type ProjectScript, | |
| 8 | type ProviderInteractionMode, | |
| 9 | type RuntimeMode, | |
| 10 | } from "@t3tools/contracts"; | |
| 4 | import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; | |
| 11 | 5 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 12 | 6 | import { Pressable, ScrollView, Text as RNText, View } from "react-native"; |
| 13 | 7 | import { useWorkspaceState } from "../../state/workspace"; |
| ... | ... | @@ -79,18 +73,6 @@ export function ThreadRouteScreen() { |
| 79 | 73 | const gitState = useSelectedThreadGitState(); |
| 80 | 74 | const gitActions = useSelectedThreadGitActions(); |
| 81 | 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 | 76 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); |
| 95 | 77 | const router = useRouter(); |
| 96 | 78 | const params = useLocalSearchParams<{ |
| ... | ... | @@ -105,6 +87,18 @@ export function ThreadRouteScreen() { |
| 105 | 87 | const routeConnectionState = |
| 106 | 88 | routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); |
| 107 | 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 | 103 | /* ─── Native header theming ──────────────────────────────────────── */ |
| 110 | 104 | const iconColor = String(useThemeColor("--color-icon")); |
| ... | ... | @@ -157,51 +151,6 @@ export function ThreadRouteScreen() { |
| 157 | 151 | const handleOpenConnectionEditor = useCallback(() => { |
| 158 | 152 | void router.push("/connections"); |
| 159 | 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 | 154 | const handleStopThread = useCallback(() => { |
| 206 | 155 | if ( |
| 207 | 156 | !selectedThread || |
| ... | ... | @@ -435,7 +384,7 @@ export function ThreadRouteScreen() { |
| 435 | 384 | |
| 436 | 385 | <View className="flex-1 bg-screen"> |
| 437 | 386 | <ThreadDetailScreen |
| 438 | selectedThread={selectedThread} | |
| 387 | selectedThread={selectedThreadWithDraftSettings ?? selectedThread} | |
| 439 | 388 | contentPresentation={contentPresentation} |
| 440 | 389 | screenTone={connectionTone(routeConnectionState)} |
| 441 | 390 | connectionError={routeConnectionError} |
| ... | ... | @@ -466,9 +415,9 @@ export function ThreadRouteScreen() { |
| 466 | 415 | onStopThread={handleStopThread} |
| 467 | 416 | onSendMessage={composer.onSendMessage} |
| 468 | 417 | onReconnectEnvironment={handleReconnectEnvironment} |
| 469 | onUpdateThreadModelSelection={handleUpdateThreadModelSelection} | |
| 470 | onUpdateThreadRuntimeMode={handleUpdateThreadRuntimeMode} | |
| 471 | onUpdateThreadInteractionMode={handleUpdateThreadInteractionMode} | |
| 418 | onUpdateThreadModelSelection={composer.onUpdateModelSelection} | |
| 419 | onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} | |
| 420 | onUpdateThreadInteractionMode={composer.onUpdateInteractionMode} | |
| 472 | 421 | onRespondToApproval={requests.onRespondToApproval} |
| 473 | 422 | onSelectUserInputOption={requests.onSelectUserInputOption} |
| 474 | 423 | onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+106-79| ... | ... | @@ -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 | 3 | import type { |
| 4 | 4 | EnvironmentId, |
| ... | ... | @@ -23,6 +23,7 @@ import { |
| 23 | 23 | removeComposerDraftAttachment, |
| 24 | 24 | replaceComposerDraftAttachments, |
| 25 | 25 | setComposerDraftText, |
| 26 | updateComposerDraftSettings, | |
| 26 | 27 | useComposerDraft, |
| 27 | 28 | } from "../../state/use-composer-drafts"; |
| 28 | 29 | import { useBranches } from "../../state/queries"; |
| ... | ... | @@ -158,34 +159,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 158 | 159 | ? selectedEnvironmentIdOverride |
| 159 | 160 | : (projects[0]?.environmentId ?? null); |
| 160 | 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 | 162 | const [submitting, setSubmitting] = useState(false); |
| 167 | 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 | 164 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); |
| 176 | 165 | |
| 177 | 166 | const reset = useCallback(() => { |
| 178 | 167 | setSelectedEnvironmentId(null); |
| 179 | 168 | setSelectedProjectKey(null); |
| 180 | setSelectedModelKey(null); | |
| 181 | setWorkspaceMode("local"); | |
| 182 | setSelectedBranchName(null); | |
| 183 | setSelectedWorktreePath(null); | |
| 184 | 169 | setSubmitting(false); |
| 185 | 170 | setBranchQuery(""); |
| 186 | setRuntimeMode(DEFAULT_RUNTIME_MODE); | |
| 187 | setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); | |
| 188 | setModelSelectionOverrides({}); | |
| 189 | 171 | setExpandedProvider(null); |
| 190 | 172 | }, []); |
| 191 | 173 | |
| ... | ... | @@ -247,33 +229,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 247 | 229 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); |
| 248 | 230 | const prompt = selectedProjectDraft.text; |
| 249 | 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 | 238 | const modelOptions = useMemo( |
| 252 | 239 | () => |
| 253 | 240 | buildModelOptions( |
| 254 | 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 | |
| 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 ?? | |
| 268 | 253 | selectedProject?.defaultModelSelection ?? |
| 269 | 254 | modelOptions[0]?.selection ?? |
| 270 | 255 | null; |
| 271 | const selectedModelIdentity = baseSelectedModel | |
| 272 | ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}` | |
| 256 | const selectedModelKey = selectedModel | |
| 257 | ? `${selectedModel.instanceId}:${selectedModel.model}` | |
| 273 | 258 | : null; |
| 274 | const selectedModel = | |
| 275 | (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ?? | |
| 276 | baseSelectedModel; | |
| 277 | 259 | |
| 278 | 260 | const selectedModelOption = |
| 279 | 261 | modelOptions.find( |
| ... | ... | @@ -282,13 +264,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 282 | 264 | option.selection.instanceId === selectedModel.instanceId && |
| 283 | 265 | option.selection.model === selectedModel.model, |
| 284 | 266 | ) ?? 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 | ); | |
| 289 | 289 | const setSelectedModelOptions = useCallback( |
| 290 | 290 | (options: ReadonlyArray<ProviderOptionSelection> | undefined) => { |
| 291 | if (!selectedModel || !selectedModelIdentity) { | |
| 291 | if (!selectedModel || !selectedProjectDraftKey) { | |
| 292 | 292 | return; |
| 293 | 293 | } |
| 294 | 294 | const nextSelection: ModelSelection = options |
| ... | ... | @@ -297,12 +297,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 297 | 297 | instanceId: selectedModel.instanceId, |
| 298 | 298 | model: selectedModel.model, |
| 299 | 299 | }; |
| 300 | setModelSelectionOverrides((current) => ({ | |
| 301 | ...current, | |
| 302 | [selectedModelIdentity]: nextSelection, | |
| 303 | })); | |
| 300 | updateComposerDraftSettings(selectedProjectDraftKey, { | |
| 301 | modelSelection: nextSelection, | |
| 302 | }); | |
| 304 | 303 | }, |
| 305 | [selectedModel, selectedModelIdentity], | |
| 304 | [selectedModel, selectedProjectDraftKey], | |
| 306 | 305 | ); |
| 307 | 306 | |
| 308 | 307 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); |
| ... | ... | @@ -381,62 +380,85 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 381 | 380 | |
| 382 | 381 | const setProject = useCallback((project: EnvironmentProject) => { |
| 383 | 382 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); |
| 384 | branchLoadVersionRef.current += 1; | |
| 385 | 383 | setSelectedEnvironmentId(project.environmentId); |
| 386 | 384 | setSelectedProjectKey(nextProjectKey); |
| 387 | setSelectedBranchName(null); | |
| 388 | setSelectedWorktreePath(null); | |
| 389 | setModelSelectionOverrides({}); | |
| 390 | 385 | }, []); |
| 391 | 386 | |
| 392 | 387 | const selectEnvironment = useCallback((environmentId: EnvironmentId) => { |
| 393 | branchLoadVersionRef.current += 1; | |
| 394 | 388 | setSelectedEnvironmentId(environmentId); |
| 395 | 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 | 408 | const selectBranch = useCallback( |
| 402 | 409 | (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 | }); | |
| 407 | 420 | }, |
| 408 | [selectedProject], | |
| 421 | [selectedProject, selectedProjectDraftKey, workspaceMode], | |
| 409 | 422 | ); |
| 410 | 423 | |
| 424 | const refreshBranches = branchState.refresh; | |
| 411 | 425 | const loadBranches = useCallback(async () => { |
| 412 | 426 | if (!selectedProject) { |
| 413 | 427 | return; |
| 414 | 428 | } |
| 429 | setPendingConnectionError(null); | |
| 430 | refreshBranches(); | |
| 431 | }, [refreshBranches, selectedProject]); | |
| 415 | 432 | |
| 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) { | |
| 420 | 435 | return; |
| 421 | 436 | } |
| 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); | |
| 431 | 443 | } |
| 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 | ); | |
| 440 | 462 | |
| 441 | 463 | const value = useMemo<NewTaskFlowContextValue>( |
| 442 | 464 | () => ({ |
| ... | ... | @@ -513,6 +535,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 513 | 535 | setProject, |
| 514 | 536 | selectBranch, |
| 515 | 537 | selectEnvironment, |
| 538 | setInteractionMode, | |
| 539 | setPrompt, | |
| 540 | setRuntimeMode, | |
| 541 | setSelectedModelKey, | |
| 542 | setWorkspaceMode, | |
| 516 | 543 | submitting, |
| 517 | 544 | workspaceMode, |
| 518 | 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 | 1 | import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; |
| 2 | 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 | 16 | import * as Schema from "effect/Schema"; |
| 5 | 17 | |
| 18 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; | |
| 6 | 19 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 7 | 20 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 8 | 21 | |
| 9 | const THREAD_OUTBOX_SCHEMA_VERSION = 1; | |
| 22 | const THREAD_OUTBOX_SCHEMA_VERSION = 2; | |
| 10 | 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 | 25 | export const QueuedThreadMessageSchema = Schema.Struct({ |
| 23 | schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION), | |
| 26 | schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]), | |
| 24 | 27 | environmentId: EnvironmentId, |
| 25 | 28 | threadId: ThreadId, |
| 26 | 29 | messageId: MessageId, |
| 27 | 30 | commandId: CommandId, |
| 28 | 31 | text: Schema.String, |
| 29 | 32 | attachments: Schema.Array(DraftComposerImageAttachmentSchema), |
| 33 | modelSelection: Schema.optional(ModelSelection), | |
| 34 | runtimeMode: Schema.optional(RuntimeMode), | |
| 35 | interactionMode: Schema.optional(ProviderInteractionMode), | |
| 30 | 36 | createdAt: IsoDateTime, |
| 31 | 37 | }); |
| 32 | 38 | |
| ... | ... | @@ -40,9 +46,37 @@ export interface QueuedThreadMessage { |
| 40 | 46 | readonly commandId: CommandId; |
| 41 | 47 | readonly text: string; |
| 42 | 48 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 49 | readonly modelSelection?: ModelSelectionType; | |
| 50 | readonly runtimeMode?: RuntimeModeType; | |
| 51 | readonly interactionMode?: ProviderInteractionModeType; | |
| 43 | 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 | 80 | export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { |
| 47 | 81 | return encodeStoredQueuedThreadMessage({ |
| 48 | 82 | schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, |
| ... | ... | @@ -119,3 +153,21 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { |
| 119 | 153 | } |
| 120 | 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 | 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 | 9 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 10 | |
| 5 | 11 | import { |
| 6 | 12 | decodeQueuedThreadMessage, |
| 13 | encodeQueuedThreadMessage, | |
| 7 | 14 | groupQueuedThreadMessages, |
| 15 | modelSelectionsEqual, | |
| 8 | 16 | resolveThreadOutboxDeliveryAction, |
| 17 | resolveThreadOutboxFailureAction, | |
| 18 | resolveQueuedThreadSettings, | |
| 9 | 19 | shouldRetryThreadOutboxDelivery, |
| 10 | 20 | threadOutboxRetryDelayMs, |
| 11 | 21 | type QueuedThreadMessage, |
| ... | ... | @@ -66,6 +76,54 @@ describe("thread outbox", () => { |
| 66 | 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 | 127 | it("backs off queued delivery retries and caps them at sixteen seconds", () => { |
| 70 | 128 | expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ |
| 71 | 129 | 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, |
| ... | ... | @@ -271,4 +329,23 @@ describe("thread outbox", () => { |
| 271 | 329 | ).toBe(true); |
| 272 | 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 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 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 | 14 | const DRAFT: ComposerDraft = { |
| 7 | 15 | text: "hello", |
| 8 | 16 | attachments: [], |
| 9 | 17 | }; |
| 10 | 18 | |
| 19 | afterEach(() => { | |
| 20 | appAtomRegistry.set(composerDraftsAtom, {}); | |
| 21 | }); | |
| 22 | ||
| 11 | 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 | 134 | it("removes only drafts owned by the selected environment", () => { |
| 13 | 135 | const environmentId = EnvironmentId.make("environment-cloud"); |
| 14 | 136 | const retainedEnvironmentId = EnvironmentId.make("environment-local"); |
| ... | ... | @@ -17,12 +139,15 @@ describe("mobile composer drafts", () => { |
| 17 | 139 | removeComposerDraftsForEnvironment( |
| 18 | 140 | { |
| 19 | 141 | [`${environmentId}:thread-cloud`]: DRAFT, |
| 142 | [`new-task:${environmentId}:project-cloud`]: DRAFT, | |
| 20 | 143 | [`${retainedEnvironmentId}:thread-local`]: DRAFT, |
| 144 | [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, | |
| 21 | 145 | }, |
| 22 | 146 | environmentId, |
| 23 | 147 | ), |
| 24 | 148 | ).toEqual({ |
| 25 | 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 | 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 | 11 | import * as Schema from "effect/Schema"; |
| 4 | 12 | import { useEffect } from "react"; |
| 5 | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 6 | 14 | |
| 15 | import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; | |
| 7 | 16 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 8 | 17 | import { appAtomRegistry } from "./atom-registry"; |
| 9 | 18 | |
| ... | ... | @@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass<Compo |
| 29 | 38 | export interface ComposerDraft { |
| 30 | 39 | readonly text: string; |
| 31 | 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 { | |
| 35 | readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION; | |
| 36 | readonly drafts: Record<string, ComposerDraft>; | |
| 47 | export interface ComposerDraftWorkspaceSelection { | |
| 48 | readonly mode: "local" | "worktree"; | |
| 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 | 82 | const EMPTY_DRAFT: ComposerDraft = { |
| 40 | 83 | text: "", |
| 41 | 84 | attachments: [], |
| ... | ... | @@ -54,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { |
| 54 | 97 | return EMPTY_DRAFT; |
| 55 | 98 | } |
| 56 | 99 | return { |
| 100 | ...draft, | |
| 57 | 101 | text: draft.text, |
| 58 | 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 | 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 | 128 | async function getComposerDraftsFile() { |
| ... | ... | @@ -80,20 +142,7 @@ async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDra |
| 80 | 142 | operation = "read"; |
| 81 | 143 | const raw = await file.text(); |
| 82 | 144 | 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); | |
| 97 | 146 | } catch (cause) { |
| 98 | 147 | console.warn( |
| 99 | 148 | "[composer-drafts] ignored persisted draft failure", |
| ... | ... | @@ -116,10 +165,10 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft |
| 116 | 165 | const nonEmptyDrafts = Object.fromEntries( |
| 117 | 166 | Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), |
| 118 | 167 | ); |
| 119 | const document: PersistedComposerDrafts = { | |
| 168 | const document = { | |
| 120 | 169 | schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, |
| 121 | 170 | drafts: nonEmptyDrafts, |
| 122 | }; | |
| 171 | } as const; | |
| 123 | 172 | const encoded = JSON.stringify(document); |
| 124 | 173 | operation = "write"; |
| 125 | 174 | if (!file.exists) { |
| ... | ... | @@ -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 | 383 | export function clearComposerDraft(draftKey: string): void { |
| 286 | 384 | updateComposerDrafts((current) => { |
| 287 | 385 | if (!current[draftKey]) { |
| ... | ... | @@ -298,8 +396,12 @@ export function removeComposerDraftsForEnvironment( |
| 298 | 396 | environmentId: EnvironmentId, |
| 299 | 397 | ): Record<string, ComposerDraft> { |
| 300 | 398 | const environmentPrefix = `${environmentId}:`; |
| 399 | const newTaskPrefix = `new-task:${environmentId}:`; | |
| 301 | 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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 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 | 13 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 6 | 14 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; |
| 7 | 15 | |
| ... | ... | @@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry"; |
| 18 | 26 | import { |
| 19 | 27 | appendComposerDraftAttachments, |
| 20 | 28 | appendComposerDraftText, |
| 21 | clearComposerDraft, | |
| 29 | clearComposerDraftContent, | |
| 22 | 30 | composerDraftsAtom, |
| 23 | 31 | ensureComposerDraftsLoaded, |
| 32 | getComposerDraftSnapshot, | |
| 24 | 33 | removeComposerDraftAttachment, |
| 25 | 34 | setComposerDraftText, |
| 35 | updateComposerDraftSettings, | |
| 26 | 36 | useComposerDraft, |
| 27 | 37 | } from "./use-composer-drafts"; |
| 28 | 38 | import { setPendingConnectionError } from "../state/use-remote-environment-registry"; |
| ... | ... | @@ -98,6 +108,10 @@ export function useThreadComposerState() { |
| 98 | 108 | const draftMessage = selectedDraft?.text ?? ""; |
| 99 | 109 | const draftAttachments = selectedDraft?.attachments ?? []; |
| 100 | 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 | 116 | const selectedThreadSessionActivity = useMemo(() => { |
| 103 | 117 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; |
| ... | ... | @@ -130,7 +144,6 @@ export function useThreadComposerState() { |
| 130 | 144 | selectedThreadShell, |
| 131 | 145 | ]); |
| 132 | 146 | |
| 133 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; | |
| 134 | 147 | const activeThreadBusy = |
| 135 | 148 | !!selectedThread && |
| 136 | 149 | (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); |
| ... | ... | @@ -141,9 +154,10 @@ export function useThreadComposerState() { |
| 141 | 154 | } |
| 142 | 155 | |
| 143 | 156 | 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; | |
| 147 | 161 | if (text.length === 0 && attachments.length === 0) { |
| 148 | 162 | return; |
| 149 | 163 | } |
| ... | ... | @@ -157,15 +171,18 @@ export function useThreadComposerState() { |
| 157 | 171 | commandId: CommandId.make(metadata.commandId), |
| 158 | 172 | text, |
| 159 | 173 | attachments, |
| 174 | modelSelection: draft.modelSelection ?? thread.modelSelection, | |
| 175 | runtimeMode: draft.runtimeMode ?? thread.runtimeMode, | |
| 176 | interactionMode: draft.interactionMode ?? thread.interactionMode, | |
| 160 | 177 | createdAt: metadata.createdAt, |
| 161 | 178 | }); |
| 162 | clearComposerDraft(threadKey); | |
| 179 | clearComposerDraftContent(threadKey); | |
| 163 | 180 | } catch (error) { |
| 164 | 181 | setPendingConnectionError( |
| 165 | 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 | 187 | const onChangeDraftMessage = useCallback( |
| 171 | 188 | (value: string) => { |
| ... | ... | @@ -255,12 +272,45 @@ export function useThreadComposerState() { |
| 255 | 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 | 305 | return { |
| 259 | 306 | selectedThreadFeed, |
| 260 | 307 | selectedThreadQueueCount, |
| 261 | 308 | activeWorkStartedAt, |
| 262 | 309 | draftMessage, |
| 263 | 310 | draftAttachments, |
| 311 | modelSelection, | |
| 312 | runtimeMode, | |
| 313 | interactionMode, | |
| 264 | 314 | activeThreadBusy, |
| 265 | 315 | onChangeDraftMessage, |
| 266 | 316 | onPickDraftImages, |
| ... | ... | @@ -268,5 +318,8 @@ export function useThreadComposerState() { |
| 268 | 318 | onNativePasteImages, |
| 269 | 319 | onRemoveDraftImage, |
| 270 | 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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 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 | 5 | import * as Cause from "effect/Cause"; |
| 5 | 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 6 | 7 | import { useCallback, useEffect, useRef, useState } from "react"; |
| ... | ... | @@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry"; |
| 10 | 11 | import { useThreadShells } from "./entities"; |
| 11 | 12 | import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; |
| 12 | 13 | import { |
| 14 | modelSelectionsEqual, | |
| 13 | 15 | resolveThreadOutboxDeliveryAction, |
| 14 | shouldRetryThreadOutboxDelivery, | |
| 16 | resolveThreadOutboxFailureAction, | |
| 17 | resolveQueuedThreadSettings, | |
| 15 | 18 | threadOutboxRetryDelayMs, |
| 16 | 19 | type QueuedThreadMessage, |
| 20 | type ThreadOutboxCommandStage, | |
| 17 | 21 | } from "./thread-outbox-model"; |
| 18 | 22 | import { threadEnvironment } from "./threads"; |
| 19 | 23 | import { useAtomCommand } from "./use-atom-command"; |
| ... | ... | @@ -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 | 55 | export function useThreadOutboxDrain(): void { |
| 48 | 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 | 66 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); |
| 50 | 67 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); |
| 51 | 68 | const shellStatuses = useThreadOutboxShellStatuses(); |
| ... | ... | @@ -68,6 +85,98 @@ export function useThreadOutboxDrain(): void { |
| 68 | 85 | |
| 69 | 86 | const sendQueuedMessage = useCallback( |
| 70 | 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 | 180 | const deliveryResult = await startTurn({ |
| 72 | 181 | environmentId: queuedMessage.environmentId, |
| 73 | 182 | input: { |
| ... | ... | @@ -79,41 +188,15 @@ export function useThreadOutboxDrain(): void { |
| 79 | 188 | text: queuedMessage.text, |
| 80 | 189 | attachments: queuedMessage.attachments, |
| 81 | 190 | }, |
| 82 | runtimeMode: thread.runtimeMode, | |
| 83 | interactionMode: thread.interactionMode, | |
| 191 | modelSelection: settings.modelSelection, | |
| 192 | runtimeMode: settings.runtimeMode, | |
| 193 | interactionMode: settings.interactionMode, | |
| 84 | 194 | createdAt: queuedMessage.createdAt, |
| 85 | 195 | }, |
| 86 | 196 | }); |
| 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); | |
| 115 | 198 | }, |
| 116 | [startTurn], | |
| 199 | [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata], | |
| 117 | 200 | ); |
| 118 | 201 | |
| 119 | 202 | useEffect(() => { |