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 {
4242 restoreComposerDraftSnapshot,
4343 type ComposerDraft,
4444} from "../../state/use-composer-drafts";
45import { useProjects } from "../../state/entities";
45import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
46import { resolveSelectableModelSelection } from "../../lib/modelOptions";
4647import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
4748import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
4849import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
......@@ -90,6 +91,9 @@ export function NewTaskDraftScreen(props: {
9091 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
9192 const { logicalProjects, selectedProject, setProject } = flow;
9293 const { connectedEnvironments } = useRemoteConnectionStatus();
94 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
95 selectedProject?.environmentId ?? null,
96 );
9397 const environmentConnected =
9498 selectedProject !== null &&
9599 connectedEnvironments.find(
......@@ -795,7 +799,14 @@ export function NewTaskDraftScreen(props: {
795799 return;
796800 }
797801 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;
799810 const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
800811 const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
801812 const selectedWorktreePath =
......@@ -847,7 +858,10 @@ export function NewTaskDraftScreen(props: {
847858 if (editingPendingTask) {
848859 flow.finishEditingPendingTask();
849860 } 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 });
851865 }
852866 navigation.getParent()?.goBack();
853867 return;
......@@ -905,7 +919,7 @@ export function NewTaskDraftScreen(props: {
905919 }
906920 flow.finishEditingPendingTask();
907921 } else {
908 clearComposerDraftContent(draftKey);
922 clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });
909923 }
910924 navigation.dispatch(
911925 StackActions.replace("Thread", {
apps/mobile/src/features/threads/new-task-flow-provider.tsx+37-12
......@@ -22,7 +22,11 @@ import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../
2222import type { TurnCommandMetadata } from "../../lib/commandMetadata";
2323import type { DraftComposerImageAttachment } from "../../lib/composerImages";
2424import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
25import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
25import {
26 buildModelOptions,
27 groupByProvider,
28 resolveSelectableModelSelection,
29} from "../../lib/modelOptions";
2630import { groupProjectsByRepository } from "../../lib/repositoryGroups";
2731import { scopedProjectKey } from "../../lib/scopedEntities";
2832import { appAtomRegistry } from "../../state/atom-registry";
......@@ -347,7 +351,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
347351 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
348352 const prompt = selectedProjectDraft.text;
349353 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;
351359 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
352360 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
353361 // Keep the user's explicit choice separate from the resolved display value:
......@@ -361,22 +369,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
361369 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
362370 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 );
364383 const modelOptions = useMemo(
365384 () =>
366385 buildModelOptions(
367386 selectedEnvironmentServerConfig,
368 selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null,
387 draftModelSelection ?? projectDefaultModelSelection,
369388 ),
370 [
371 selectedEnvironmentServerConfig,
372 selectedProject?.defaultModelSelection,
373 selectedProjectDraft.modelSelection,
374 ],
389 [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],
375390 );
376391
377392 const selectedModel =
378 selectedProjectDraft.modelSelection ??
379 selectedProject?.defaultModelSelection ??
393 draftModelSelection ??
394 projectDefaultModelSelection ??
380395 modelOptions.find((option) => option.isDefault)?.selection ??
381396 modelOptions[0]?.selection ??
382397 null;
......@@ -675,12 +690,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
675690 }
676691 const draft = getComposerDraftSnapshot(selectedProjectDraftKey);
677692 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;
679700 if (text.length === 0 || !draftModelSelection) {
680701 return null;
681702 }
682703 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;
684707 // When the selection is the stand-in built from the queued snapshot,
685708 // persist the original (possibly absent) snapshot values — the
686709 // stand-in's placeholder title/workspaceRoot must never be written back
......@@ -722,10 +745,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
722745 [
723746 editingPendingProject,
724747 editingPendingTask,
748 selectedEnvironmentServerConfig,
725749 selectedModel,
726750 selectedProject,
727751 selectedProjectDraftKey,
728752 startFromOrigin,
753 workspaceMode,
729754 ],
730755 );
731756
apps/mobile/src/lib/modelOptions.test.ts+43-1
......@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
33import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
44
5import { buildModelOptions } from "./modelOptions";
5import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions";
66
77describe("mobile model options", () => {
88 it("normalizes a legacy fallback selection against current capabilities", () => {
......@@ -49,4 +49,46 @@ describe("mobile model options", () => {
4949 expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");
5050 expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]);
5151 });
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 });
5294});
apps/mobile/src/lib/modelOptions.ts+25
......@@ -58,6 +58,31 @@ function normalizeSelectionOptions(
5858 };
5959}
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
6186export function buildModelOptions(
6287 config: T3ServerConfig | null | undefined,
6388 fallbackModelSelection: ModelSelection | null,
apps/mobile/src/state/use-composer-drafts.test.ts+30
......@@ -119,6 +119,36 @@ describe("mobile composer drafts", () => {
119119 });
120120 });
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
122152 it("reads the latest selector state synchronously for send", () => {
123153 const draftKey = "environment-1:thread-1";
124154 const selectedDraft: ComposerDraft = {
apps/mobile/src/state/use-composer-drafts.ts+10-3
......@@ -372,14 +372,18 @@ export function updateComposerDraftSettings(
372372export function clearComposerDraftContentState(
373373 current: Record<string, ComposerDraft>,
374374 draftKey: string,
375 options?: { readonly clearWorkspaceSelection?: boolean },
375376): Record<string, ComposerDraft> {
376377 const existing = current[draftKey];
377378 if (!existing) {
378379 return current;
379380 }
380 const { importedShareIds: _importedShareIds, ...retained } = existing;
381 const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing;
381382 const draft = {
382383 ...retained,
384 ...(options?.clearWorkspaceSelection || workspaceSelection === undefined
385 ? {}
386 : { workspaceSelection }),
383387 text: "",
384388 attachments: [],
385389 };
......@@ -526,8 +530,11 @@ export async function restoreComposerDraftSnapshot(
526530 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
527531}
528532
529export function clearComposerDraftContent(draftKey: string): void {
530 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey));
533export function clearComposerDraftContent(
534 draftKey: string,
535 options?: { readonly clearWorkspaceSelection?: boolean },
536): void {
537 updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
531538}
532539
533540export function clearComposerDraft(draftKey: string): void {