authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 21:21:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 21:21:39-07:00
loge259dd23c7ef67a7f215389762a1ef5622c79088
tree4bcae525c9094cae664e8fce2189d872653ae1c1
parentdf78cda8bf9c0971300e1bf35251774d9fbc833a
signature Signed by PGP key B5690EEEBB952194

fix(mobile): server model, worktree, and origin preferences now apply to new tasks (#5064)

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,7 +42,8 @@ import {
42 restoreComposerDraftSnapshot,42 restoreComposerDraftSnapshot,
43 type ComposerDraft,43 type ComposerDraft,
44} from "../../state/use-composer-drafts";44} from "../../state/use-composer-drafts";
45import { useProjects } from "../../state/entities";45import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
46import { resolveSelectableModelSelection } from "../../lib/modelOptions";
46import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";47import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
47import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";48import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
48import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";49import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
...@@ -90,6 +91,9 @@ export function NewTaskDraftScreen(props: {...@@ -90,6 +91,9 @@ export function NewTaskDraftScreen(props: {
90 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);91 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
91 const { logicalProjects, selectedProject, setProject } = flow;92 const { logicalProjects, selectedProject, setProject } = flow;
92 const { connectedEnvironments } = useRemoteConnectionStatus();93 const { connectedEnvironments } = useRemoteConnectionStatus();
94 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
95 selectedProject?.environmentId ?? null,
96 );
93 const environmentConnected =97 const environmentConnected =
94 selectedProject !== null &&98 selectedProject !== null &&
95 connectedEnvironments.find(99 connectedEnvironments.find(
...@@ -795,7 +799,14 @@ export function NewTaskDraftScreen(props: {...@@ -795,7 +799,14 @@ export function NewTaskDraftScreen(props: {
795 return;799 return;
796 }800 }
797 const draft = getComposerDraftSnapshot(draftKey);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 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;810 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
800 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;811 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
801 const selectedWorktreePath =812 const selectedWorktreePath =
...@@ -847,7 +858,10 @@ export function NewTaskDraftScreen(props: {...@@ -847,7 +858,10 @@ export function NewTaskDraftScreen(props: {
847 if (editingPendingTask) {858 if (editingPendingTask) {
848 flow.finishEditingPendingTask();859 flow.finishEditingPendingTask();
849 } else {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 navigation.getParent()?.goBack();866 navigation.getParent()?.goBack();
853 return;867 return;
...@@ -905,7 +919,7 @@ export function NewTaskDraftScreen(props: {...@@ -905,7 +919,7 @@ export function NewTaskDraftScreen(props: {
905 }919 }
906 flow.finishEditingPendingTask();920 flow.finishEditingPendingTask();
907 } else {921 } else {
908 clearComposerDraftContent(draftKey);922 clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });
909 }923 }
910 navigation.dispatch(924 navigation.dispatch(
911 StackActions.replace("Thread", {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,7 +22,11 @@ import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../
22import type { TurnCommandMetadata } from "../../lib/commandMetadata";22import type { TurnCommandMetadata } from "../../lib/commandMetadata";
23import type { DraftComposerImageAttachment } from "../../lib/composerImages";23import type { DraftComposerImageAttachment } from "../../lib/composerImages";
24import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";24import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
25import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";25import {
26 buildModelOptions,
27 groupByProvider,
28 resolveSelectableModelSelection,
29} from "../../lib/modelOptions";
26import { groupProjectsByRepository } from "../../lib/repositoryGroups";30import { groupProjectsByRepository } from "../../lib/repositoryGroups";
27import { scopedProjectKey } from "../../lib/scopedEntities";31import { scopedProjectKey } from "../../lib/scopedEntities";
28import { appAtomRegistry } from "../../state/atom-registry";32import { appAtomRegistry } from "../../state/atom-registry";
...@@ -347,7 +351,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -347,7 +351,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
347 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);351 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
348 const prompt = selectedProjectDraft.text;352 const prompt = selectedProjectDraft.text;
349 const attachments = selectedProjectDraft.attachments;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 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;359 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
352 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;360 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
353 // Keep the user's explicit choice separate from the resolved display value:361 // Keep the user's explicit choice separate from the resolved display value:
...@@ -361,22 +369,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -361,22 +369,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
361 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;369 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
362 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;370 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
363371
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 const modelOptions = useMemo(383 const modelOptions = useMemo(
365 () =>384 () =>
366 buildModelOptions(385 buildModelOptions(
367 selectedEnvironmentServerConfig,386 selectedEnvironmentServerConfig,
368 selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null,387 draftModelSelection ?? projectDefaultModelSelection,
369 ),388 ),
370 [389 [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],
371 selectedEnvironmentServerConfig,
372 selectedProject?.defaultModelSelection,
373 selectedProjectDraft.modelSelection,
374 ],
375 );390 );
376391
377 const selectedModel =392 const selectedModel =
378 selectedProjectDraft.modelSelection ??393 draftModelSelection ??
379 selectedProject?.defaultModelSelection ??394 projectDefaultModelSelection ??
380 modelOptions.find((option) => option.isDefault)?.selection ??395 modelOptions.find((option) => option.isDefault)?.selection ??
381 modelOptions[0]?.selection ??396 modelOptions[0]?.selection ??
382 null;397 null;
...@@ -675,12 +690,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -675,12 +690,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
675 }690 }
676 const draft = getComposerDraftSnapshot(selectedProjectDraftKey);691 const draft = getComposerDraftSnapshot(selectedProjectDraftKey);
677 const text = draft.text.trim();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 if (text.length === 0 || !draftModelSelection) {700 if (text.length === 0 || !draftModelSelection) {
680 return null;701 return null;
681 }702 }
682 const workspaceSelection = draft.workspaceSelection;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 // When the selection is the stand-in built from the queued snapshot,707 // When the selection is the stand-in built from the queued snapshot,
685 // persist the original (possibly absent) snapshot values — the708 // persist the original (possibly absent) snapshot values — the
686 // stand-in's placeholder title/workspaceRoot must never be written back709 // stand-in's placeholder title/workspaceRoot must never be written back
...@@ -722,10 +745,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -722,10 +745,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
722 [745 [
723 editingPendingProject,746 editingPendingProject,
724 editingPendingTask,747 editingPendingTask,
748 selectedEnvironmentServerConfig,
725 selectedModel,749 selectedModel,
726 selectedProject,750 selectedProject,
727 selectedProjectDraftKey,751 selectedProjectDraftKey,
728 startFromOrigin,752 startFromOrigin,
753 workspaceMode,
729 ],754 ],
730 );755 );
731756
apps/mobile/src/lib/modelOptions.test.ts+43-1
...@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
3import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";3import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
44
5import { buildModelOptions } from "./modelOptions";5import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions";
66
7describe("mobile model options", () => {7describe("mobile model options", () => {
8 it("normalizes a legacy fallback selection against current capabilities", () => {8 it("normalizes a legacy fallback selection against current capabilities", () => {
...@@ -49,4 +49,46 @@ describe("mobile model options", () => {...@@ -49,4 +49,46 @@ describe("mobile model options", () => {
49 expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");49 expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");
50 expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]);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,6 +58,31 @@ function normalizeSelectionOptions(
58 };58 };
59}59}
6060
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 */
68export 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
61export function buildModelOptions(86export function buildModelOptions(
62 config: T3ServerConfig | null | undefined,87 config: T3ServerConfig | null | undefined,
63 fallbackModelSelection: ModelSelection | null,88 fallbackModelSelection: ModelSelection | null,
apps/mobile/src/state/use-composer-drafts.test.ts+30
...@@ -119,6 +119,36 @@ describe("mobile composer drafts", () => {...@@ -119,6 +119,36 @@ describe("mobile composer drafts", () => {
119 });119 });
120 });120 });
121121
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 it("reads the latest selector state synchronously for send", () => {152 it("reads the latest selector state synchronously for send", () => {
123 const draftKey = "environment-1:thread-1";153 const draftKey = "environment-1:thread-1";
124 const selectedDraft: ComposerDraft = {154 const selectedDraft: ComposerDraft = {
apps/mobile/src/state/use-composer-drafts.ts+10-3
...@@ -372,14 +372,18 @@ export function updateComposerDraftSettings(...@@ -372,14 +372,18 @@ export function updateComposerDraftSettings(
372export function clearComposerDraftContentState(372export function clearComposerDraftContentState(
373 current: Record<string, ComposerDraft>,373 current: Record<string, ComposerDraft>,
374 draftKey: string,374 draftKey: string,
375 options?: { readonly clearWorkspaceSelection?: boolean },
375): Record<string, ComposerDraft> {376): Record<string, ComposerDraft> {
376 const existing = current[draftKey];377 const existing = current[draftKey];
377 if (!existing) {378 if (!existing) {
378 return current;379 return current;
379 }380 }
380 const { importedShareIds: _importedShareIds, ...retained } = existing;381 const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing;
381 const draft = {382 const draft = {
382 ...retained,383 ...retained,
384 ...(options?.clearWorkspaceSelection || workspaceSelection === undefined
385 ? {}
386 : { workspaceSelection }),
383 text: "",387 text: "",
384 attachments: [],388 attachments: [],
385 };389 };
...@@ -526,8 +530,11 @@ export async function restoreComposerDraftSnapshot(...@@ -526,8 +530,11 @@ export async function restoreComposerDraftSnapshot(
526 await persistenceQueue.run(() => writePersistedComposerDrafts(next));530 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
527}531}
528532
529export function clearComposerDraftContent(draftKey: string): void {533export function clearComposerDraftContent(
530 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey));534 draftKey: string,
535 options?: { readonly clearWorkspaceSelection?: boolean },
536): void {
537 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
531}538}
532539
533export function clearComposerDraft(draftKey: string): void {540export function clearComposerDraft(draftKey: string): void {