| author | |
| committer | |
| log | e259dd23c7ef67a7f215389762a1ef5622c79088 |
| tree | 4bcae525c9094cae664e8fce2189d872653ae1c1 |
| parent | df78cda8bf9c0971300e1bf35251774d9fbc833a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>6 files changed, 163 insertions(+), 20 deletions(-)
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+18-4| ... | ... | @@ -42,7 +42,8 @@ import { |
| 42 | 42 | restoreComposerDraftSnapshot, |
| 43 | 43 | type ComposerDraft, |
| 44 | 44 | } from "../../state/use-composer-drafts"; |
| 45 | import { useProjects } from "../../state/entities"; | |
| 45 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; | |
| 46 | import { resolveSelectableModelSelection } from "../../lib/modelOptions"; | |
| 46 | 47 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 47 | 48 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 48 | 49 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; |
| ... | ... | @@ -90,6 +91,9 @@ export function NewTaskDraftScreen(props: { |
| 90 | 91 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); |
| 91 | 92 | const { logicalProjects, selectedProject, setProject } = flow; |
| 92 | 93 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 94 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( | |
| 95 | selectedProject?.environmentId ?? null, | |
| 96 | ); | |
| 93 | 97 | const environmentConnected = |
| 94 | 98 | selectedProject !== null && |
| 95 | 99 | connectedEnvironments.find( |
| ... | ... | @@ -795,7 +799,14 @@ export function NewTaskDraftScreen(props: { |
| 795 | 799 | return; |
| 796 | 800 | } |
| 797 | 801 | const draft = getComposerDraftSnapshot(draftKey); |
| 798 | const modelSelection = draft.modelSelection ?? flow.selectedModel; | |
| 802 | // Snapshot read keeps just-typed selector state; the availability gate | |
| 803 | // still applies so a stored selection on a disabled provider falls back | |
| 804 | // to the flow's resolved model. | |
| 805 | const modelSelection = | |
| 806 | resolveSelectableModelSelection( | |
| 807 | selectedEnvironmentServerConfig, | |
| 808 | draft.modelSelection ?? null, | |
| 809 | ) ?? flow.selectedModel; | |
| 799 | 810 | const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; |
| 800 | 811 | const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; |
| 801 | 812 | const selectedWorktreePath = |
| ... | ... | @@ -847,7 +858,10 @@ export function NewTaskDraftScreen(props: { |
| 847 | 858 | if (editingPendingTask) { |
| 848 | 859 | flow.finishEditingPendingTask(); |
| 849 | 860 | } else { |
| 850 | clearComposerDraftContent(draftKey); | |
| 861 | // Drop the workspace selection with the content: the next task should | |
| 862 | // re-resolve mode/branch/origin from the server's configured defaults | |
| 863 | // instead of resurrecting this task's picks. | |
| 864 | clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); | |
| 851 | 865 | } |
| 852 | 866 | navigation.getParent()?.goBack(); |
| 853 | 867 | return; |
| ... | ... | @@ -905,7 +919,7 @@ export function NewTaskDraftScreen(props: { |
| 905 | 919 | } |
| 906 | 920 | flow.finishEditingPendingTask(); |
| 907 | 921 | } else { |
| 908 | clearComposerDraftContent(draftKey); | |
| 922 | clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); | |
| 909 | 923 | } |
| 910 | 924 | navigation.dispatch( |
| 911 | 925 | StackActions.replace("Thread", { |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+37-12| ... | ... | @@ -22,7 +22,11 @@ import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../ |
| 22 | 22 | import type { TurnCommandMetadata } from "../../lib/commandMetadata"; |
| 23 | 23 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 24 | 24 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 25 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | |
| 25 | import { | |
| 26 | buildModelOptions, | |
| 27 | groupByProvider, | |
| 28 | resolveSelectableModelSelection, | |
| 29 | } from "../../lib/modelOptions"; | |
| 26 | 30 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 27 | 31 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 28 | 32 | import { appAtomRegistry } from "../../state/atom-registry"; |
| ... | ... | @@ -347,7 +351,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 347 | 351 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); |
| 348 | 352 | const prompt = selectedProjectDraft.text; |
| 349 | 353 | const attachments = selectedProjectDraft.attachments; |
| 350 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; | |
| 354 | // The server's configured default decides the mode until the user picks one | |
| 355 | // explicitly — same resolution web uses for new draft threads. | |
| 356 | const defaultWorkspaceMode: WorkspaceMode = | |
| 357 | selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local"; | |
| 358 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; | |
| 351 | 359 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; |
| 352 | 360 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; |
| 353 | 361 | // Keep the user's explicit choice separate from the resolved display value: |
| ... | ... | @@ -361,22 +369,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 361 | 369 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 362 | 370 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; |
| 363 | 371 | |
| 372 | // Stored selections (draft and project default) only count while their | |
| 373 | // provider is usable on the server; otherwise the server's default model | |
| 374 | // wins instead of silently targeting a disabled provider. | |
| 375 | const draftModelSelection = resolveSelectableModelSelection( | |
| 376 | selectedEnvironmentServerConfig, | |
| 377 | selectedProjectDraft.modelSelection ?? null, | |
| 378 | ); | |
| 379 | const projectDefaultModelSelection = resolveSelectableModelSelection( | |
| 380 | selectedEnvironmentServerConfig, | |
| 381 | selectedProject?.defaultModelSelection ?? null, | |
| 382 | ); | |
| 364 | 383 | const modelOptions = useMemo( |
| 365 | 384 | () => |
| 366 | 385 | buildModelOptions( |
| 367 | 386 | selectedEnvironmentServerConfig, |
| 368 | selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, | |
| 387 | draftModelSelection ?? projectDefaultModelSelection, | |
| 369 | 388 | ), |
| 370 | [ | |
| 371 | selectedEnvironmentServerConfig, | |
| 372 | selectedProject?.defaultModelSelection, | |
| 373 | selectedProjectDraft.modelSelection, | |
| 374 | ], | |
| 389 | [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], | |
| 375 | 390 | ); |
| 376 | 391 | |
| 377 | 392 | const selectedModel = |
| 378 | selectedProjectDraft.modelSelection ?? | |
| 379 | selectedProject?.defaultModelSelection ?? | |
| 393 | draftModelSelection ?? | |
| 394 | projectDefaultModelSelection ?? | |
| 380 | 395 | modelOptions.find((option) => option.isDefault)?.selection ?? |
| 381 | 396 | modelOptions[0]?.selection ?? |
| 382 | 397 | null; |
| ... | ... | @@ -675,12 +690,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 675 | 690 | } |
| 676 | 691 | const draft = getComposerDraftSnapshot(selectedProjectDraftKey); |
| 677 | 692 | const text = draft.text.trim(); |
| 678 | const draftModelSelection = draft.modelSelection ?? selectedModel; | |
| 693 | // Same availability gate the composer display applies: a stored | |
| 694 | // selection targeting a disabled provider must not ride into the queue. | |
| 695 | const draftModelSelection = | |
| 696 | resolveSelectableModelSelection( | |
| 697 | selectedEnvironmentServerConfig, | |
| 698 | draft.modelSelection ?? null, | |
| 699 | ) ?? selectedModel; | |
| 679 | 700 | if (text.length === 0 || !draftModelSelection) { |
| 680 | 701 | return null; |
| 681 | 702 | } |
| 682 | 703 | const workspaceSelection = draft.workspaceSelection; |
| 683 | const mode = workspaceSelection?.mode ?? "local"; | |
| 704 | // Fall back to the resolved mode (server default) so queued tasks drain | |
| 705 | // with the same mode the composer displayed. | |
| 706 | const mode = workspaceSelection?.mode ?? workspaceMode; | |
| 684 | 707 | // When the selection is the stand-in built from the queued snapshot, |
| 685 | 708 | // persist the original (possibly absent) snapshot values — the |
| 686 | 709 | // stand-in's placeholder title/workspaceRoot must never be written back |
| ... | ... | @@ -722,10 +745,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 722 | 745 | [ |
| 723 | 746 | editingPendingProject, |
| 724 | 747 | editingPendingTask, |
| 748 | selectedEnvironmentServerConfig, | |
| 725 | 749 | selectedModel, |
| 726 | 750 | selectedProject, |
| 727 | 751 | selectedProjectDraftKey, |
| 728 | 752 | startFromOrigin, |
| 753 | workspaceMode, | |
| 729 | 754 | ], |
| 730 | 755 | ); |
| 731 | 756 |
apps/mobile/src/lib/modelOptions.test.ts+43-1| ... | ... | @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; |
| 4 | 4 | |
| 5 | import { buildModelOptions } from "./modelOptions"; | |
| 5 | import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions"; | |
| 6 | 6 | |
| 7 | 7 | describe("mobile model options", () => { |
| 8 | 8 | it("normalizes a legacy fallback selection against current capabilities", () => { |
| ... | ... | @@ -49,4 +49,46 @@ describe("mobile model options", () => { |
| 49 | 49 | expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); |
| 50 | 50 | expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); |
| 51 | 51 | }); |
| 52 | ||
| 53 | it("rejects stored selections whose provider is not usable", () => { | |
| 54 | const config = { | |
| 55 | providers: [ | |
| 56 | { | |
| 57 | instanceId: "codex", | |
| 58 | driver: "codex", | |
| 59 | enabled: true, | |
| 60 | installed: true, | |
| 61 | auth: { status: "authenticated" }, | |
| 62 | models: [], | |
| 63 | }, | |
| 64 | { | |
| 65 | instanceId: "claudeAgent", | |
| 66 | driver: "claudeAgent", | |
| 67 | enabled: false, | |
| 68 | installed: true, | |
| 69 | auth: { status: "authenticated" }, | |
| 70 | models: [], | |
| 71 | }, | |
| 72 | ], | |
| 73 | } as unknown as ServerConfig; | |
| 74 | ||
| 75 | const usable = { | |
| 76 | instanceId: ProviderInstanceId.make("codex"), | |
| 77 | model: "gpt-5.6-sol", | |
| 78 | }; | |
| 79 | const disabled = { | |
| 80 | instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 81 | model: "claude-sonnet-5", | |
| 82 | }; | |
| 83 | const removed = { | |
| 84 | instanceId: ProviderInstanceId.make("codex_personal"), | |
| 85 | model: "gpt-5.6-sol", | |
| 86 | }; | |
| 87 | ||
| 88 | expect(resolveSelectableModelSelection(config, usable)).toBe(usable); | |
| 89 | expect(resolveSelectableModelSelection(config, disabled)).toBeNull(); | |
| 90 | expect(resolveSelectableModelSelection(config, removed)).toBeNull(); | |
| 91 | // No config (environment offline) — nothing to validate against. | |
| 92 | expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); | |
| 93 | }); | |
| 52 | 94 | }); |
apps/mobile/src/lib/modelOptions.ts+25| ... | ... | @@ -58,6 +58,31 @@ function normalizeSelectionOptions( |
| 58 | 58 | }; |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | /** | |
| 62 | * A stored model selection is only usable when its provider instance is | |
| 63 | * currently enabled, installed, and authenticated on the server. Returns the | |
| 64 | * selection unchanged when usable, otherwise `null` so callers fall through to | |
| 65 | * the server's default model. A missing config (environment offline) cannot be | |
| 66 | * validated, so stored selections pass through untouched. | |
| 67 | */ | |
| 68 | export function resolveSelectableModelSelection( | |
| 69 | config: T3ServerConfig | null | undefined, | |
| 70 | selection: ModelSelection | null, | |
| 71 | ): ModelSelection | null { | |
| 72 | if (!selection || !config) { | |
| 73 | return selection; | |
| 74 | } | |
| 75 | const provider = config.providers.find( | |
| 76 | (candidate) => candidate.instanceId === selection.instanceId, | |
| 77 | ); | |
| 78 | return provider && | |
| 79 | provider.enabled && | |
| 80 | provider.installed && | |
| 81 | provider.auth.status !== "unauthenticated" | |
| 82 | ? selection | |
| 83 | : null; | |
| 84 | } | |
| 85 | ||
| 61 | 86 | export function buildModelOptions( |
| 62 | 87 | config: T3ServerConfig | null | undefined, |
| 63 | 88 | fallbackModelSelection: ModelSelection | null, |
apps/mobile/src/state/use-composer-drafts.test.ts+30| ... | ... | @@ -119,6 +119,36 @@ describe("mobile composer drafts", () => { |
| 119 | 119 | }); |
| 120 | 120 | }); |
| 121 | 121 | |
| 122 | it("drops the workspace selection when clearing a sent new-task draft", () => { | |
| 123 | const draftKey = "new-task:environment-1:project-1"; | |
| 124 | const draft: ComposerDraft = { | |
| 125 | text: "send this", | |
| 126 | attachments: [], | |
| 127 | modelSelection: { | |
| 128 | instanceId: ProviderInstanceId.make("codex"), | |
| 129 | model: "gpt-5.4", | |
| 130 | }, | |
| 131 | workspaceSelection: { | |
| 132 | mode: "worktree", | |
| 133 | branch: "main", | |
| 134 | worktreePath: null, | |
| 135 | startFromOrigin: false, | |
| 136 | }, | |
| 137 | }; | |
| 138 | ||
| 139 | expect( | |
| 140 | clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { | |
| 141 | clearWorkspaceSelection: true, | |
| 142 | }), | |
| 143 | ).toEqual({ | |
| 144 | [draftKey]: { | |
| 145 | modelSelection: draft.modelSelection, | |
| 146 | text: "", | |
| 147 | attachments: [], | |
| 148 | }, | |
| 149 | }); | |
| 150 | }); | |
| 151 | ||
| 122 | 152 | it("reads the latest selector state synchronously for send", () => { |
| 123 | 153 | const draftKey = "environment-1:thread-1"; |
| 124 | 154 | const selectedDraft: ComposerDraft = { |
apps/mobile/src/state/use-composer-drafts.ts+10-3| ... | ... | @@ -372,14 +372,18 @@ export function updateComposerDraftSettings( |
| 372 | 372 | export function clearComposerDraftContentState( |
| 373 | 373 | current: Record<string, ComposerDraft>, |
| 374 | 374 | draftKey: string, |
| 375 | options?: { readonly clearWorkspaceSelection?: boolean }, | |
| 375 | 376 | ): Record<string, ComposerDraft> { |
| 376 | 377 | const existing = current[draftKey]; |
| 377 | 378 | if (!existing) { |
| 378 | 379 | return current; |
| 379 | 380 | } |
| 380 | const { importedShareIds: _importedShareIds, ...retained } = existing; | |
| 381 | const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; | |
| 381 | 382 | const draft = { |
| 382 | 383 | ...retained, |
| 384 | ...(options?.clearWorkspaceSelection || workspaceSelection === undefined | |
| 385 | ? {} | |
| 386 | : { workspaceSelection }), | |
| 383 | 387 | text: "", |
| 384 | 388 | attachments: [], |
| 385 | 389 | }; |
| ... | ... | @@ -526,8 +530,11 @@ export async function restoreComposerDraftSnapshot( |
| 526 | 530 | await persistenceQueue.run(() => writePersistedComposerDrafts(next)); |
| 527 | 531 | } |
| 528 | 532 | |
| 529 | export function clearComposerDraftContent(draftKey: string): void { | |
| 530 | updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); | |
| 533 | export function clearComposerDraftContent( | |
| 534 | draftKey: string, | |
| 535 | options?: { readonly clearWorkspaceSelection?: boolean }, | |
| 536 | ): void { | |
| 537 | updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); | |
| 531 | 538 | } |
| 532 | 539 | |
| 533 | 540 | export function clearComposerDraft(draftKey: string): void { |