diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 8eeaf2e567ed1..d71fbc8fd9d24 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -83,7 +83,11 @@ import { type ScheduledTaskDraft as Draft, } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; -import { useScheduledTaskDestinationLabel, useScheduledTaskEditor } from "./scheduled-task-editor"; +import { + useScheduledTaskDestinationLabel, + useScheduledTaskEditor, + useScheduledTaskThread, +} from "./scheduled-task-editor"; import { discardScheduledTaskEditor, markScheduledTaskEditorSaved, @@ -693,6 +697,10 @@ function TaskForm({ .sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null)); const config = useEnvironmentServerConfig(environmentId); const destinationLabel = useScheduledTaskDestinationLabel(environmentId, draft.threadId); + const boundThread = useScheduledTaskThread(environmentId, draft.threadId); + // Bound runs use the thread's current model, so the locked row shows that one. + const shownSelection = + draft.threadId === null ? draft.modelSelection : (boundThread?.modelSelection ?? null); const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); // Existing webhook tasks keep the option so they can still switch to a timer. const webhookDenial = managedCapabilityDenial(config?.managedPolicy, "webhookTasks"); @@ -929,14 +937,17 @@ function TaskForm({ value={ modelOptions.find( (option) => - option.selection.instanceId === draft.modelSelection?.instanceId && - option.selection.model === draft.modelSelection?.model, + option.selection.instanceId === shownSelection?.instanceId && + option.selection.model === shownSelection?.model, )?.label ?? - draft.modelSelection?.model ?? + shownSelection?.model ?? (modelOptions.length ? "Choose model" : "No models available") } onPress={() => navigation.navigate("SettingsScheduledTaskModel")} - disabled={saving || dictationPending || environmentUnavailable} + // The saved selection only seeds new threads; bound runs ignore it. + disabled={ + saving || dictationPending || environmentUnavailable || draft.threadId !== null + } /> diff --git a/apps/mobile/src/features/settings/scheduled-task-editor.tsx b/apps/mobile/src/features/settings/scheduled-task-editor.tsx index e99600ae533b8..d79ef40896e87 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor.tsx +++ b/apps/mobile/src/features/settings/scheduled-task-editor.tsx @@ -21,22 +21,29 @@ import { export const NEW_THREAD_DESTINATION_LABEL = "New thread each run"; -export function useScheduledTaskDestinationLabel( +export function useScheduledTaskThread( environmentId: EnvironmentId | null, threadId: ThreadId | null, -): string { +) { const ref = environmentId === null || threadId === null ? null : scopeThreadRef(environmentId, threadId); const shell = useThreadShell(ref); - // The shell can omit older settled threads; their family read still carries the title. + // The shell can omit older settled threads; their family read still carries the row. const family = useEnvironmentQuery( ref !== null && shell === null ? threadFamilies.familyAtom(ref.environmentId, ref.threadId) : null, ); - const thread = shell ?? family.data?.threads.find((row) => row.id === threadId); - if (ref === null) return NEW_THREAD_DESTINATION_LABEL; - return thread?.title.trim() || `Thread ${ref.threadId}`; + return shell ?? family.data?.threads.find((row) => row.id === threadId) ?? null; +} + +export function useScheduledTaskDestinationLabel( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): string { + const thread = useScheduledTaskThread(environmentId, threadId); + if (environmentId === null || threadId === null) return NEW_THREAD_DESTINATION_LABEL; + return thread?.title.trim() || `Thread ${threadId}`; } /** The form draft lives in an atom, so it survives while its native picker routes are on top. */ diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e2976652ea872..de655f374af5e 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -45,7 +45,13 @@ const task: ScheduledTask = { // Stands in for the thread family read the editor makes when the shell lacks the bound thread. const threadFamilyRead = { snapshotSequence: 1, - threads: [{ id: task.threadId, title: "Fix login bug" }], + threads: [ + { + id: task.threadId, + title: "Fix login bug", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }, + }, + ], }; const state = vi.hoisted(() => ({ @@ -244,6 +250,19 @@ describe("ScheduledTasksSettings", () => { await expect.element(name).toHaveValue("Review stale issues"); }); + it("offers no model choice for a task bound to a thread, whose runs use the thread's model", async () => { + setup(); + await using _screen = await render(); + await expect.element(page.getByRole("dialog").getByText("Fix login bug")).toBeVisible(); + const enabledPicker = document.querySelector( + '[role="dialog"] [data-chat-provider-model-picker]:not(:disabled)', + ); + expect(enabledPicker).toBeNull(); + await expect + .element(page.getByRole("dialog").getByRole("button", { name: "gpt-5.5", exact: true })) + .toBeDisabled(); + }); + it("keeps an editor the user opened when the linked task loads later", async () => { setup(); state.tasksLoaded = false; diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index b5b01ddd58b51..5e83dd1baadd9 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1188,6 +1188,16 @@ const THREAD_MODE_LABELS: Record = { fresh_session: "One thread, fresh session each run", }; +function useScheduledTaskThread(environmentId: EnvironmentId, threadId: ThreadId | null) { + const ref = threadId === null ? null : scopeThreadRef(environmentId, threadId); + const shell = useThreadShell(ref); + // The shell can omit older settled threads; their family read still carries the row. + const family = useEnvironmentQuery( + ref !== null && shell === null ? threadFamilies.familyAtom(environmentId, ref.threadId) : null, + ); + return shell ?? family.data?.threads.find((row) => row.id === threadId) ?? null; +} + function ScheduledTaskDestination({ environmentId, projectId, @@ -1209,12 +1219,7 @@ function ScheduledTaskDestination({ const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const ref = threadId === null ? null : scopeThreadRef(environmentId, threadId); - const shell = useThreadShell(ref); - // The shell can omit older settled threads; their family read still carries the title. - const family = useEnvironmentQuery( - ref !== null && shell === null ? threadFamilies.familyAtom(environmentId, ref.threadId) : null, - ); - const thread = shell ?? family.data?.threads.find((row) => row.id === threadId); + const thread = useScheduledTaskThread(environmentId, threadId); const currentLabel = ref !== null ? thread?.title.trim() || `Thread ${ref.threadId}` @@ -1428,11 +1433,21 @@ function ScheduledTaskEditorForm({ const activeInstanceId = activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId); const activeModel = activeSelection?.model ?? ""; + const boundThread = useScheduledTaskThread( + environmentId, + draft.threadId ? (draft.threadId as ThreadId) : null, + ); + // Bound runs use the thread's current model, so the locked picker shows that one. + const shownSelection = draft.threadId + ? (boundThread?.modelSelection ?? null) + : { instanceId: activeInstanceId, model: activeModel }; + const shownInstanceId = shownSelection?.instanceId ?? activeInstanceId; + const shownModel = shownSelection?.model ?? ""; const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, providers, - activeInstanceId, - activeModel, + shownInstanceId, + shownModel, ); const reportFailure = (title: string, error: unknown) => { @@ -1777,9 +1792,10 @@ function ScheduledTaskEditorForm({