From a1e8faddce5543ff62013149dbb78ab732b48dff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 09:34:20 +0000 Subject: [PATCH 1/3] test(web): bound scheduled tasks offer no model their runs ignore Failing repro for #36619. A task bound to a thread runs on the thread's current model, but the editor still shows an enabled Model picker. Co-Authored-By: Claude Opus 5.5 --- .../settings/ScheduledTasksSettings.browser.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e2976652ea872..82e8bd7c40332 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -244,6 +244,16 @@ 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(); + }); + it("keeps an editor the user opened when the linked task loads later", async () => { setup(); state.tasksLoaded = false; From 03bffc56f85a33ad707981350a4c1afa417f9a70 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 09:40:53 +0000 Subject: [PATCH 2/3] fix(web,mobile): bound scheduled tasks no longer offer a model their runs ignore A scheduled task bound to a thread runs on that thread's current model; the task's saved selection only seeds a new thread. The editor's Model picker stayed enabled anyway, so it saved a value no bound run uses. Disable it while the draft is bound, on web and mobile. Fixes #36619 Co-Authored-By: Claude Opus 5.5 --- .../features/settings/SettingsScheduledTasksRouteScreen.tsx | 5 ++++- apps/web/src/components/settings/ScheduledTasksSettings.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 3f552c1f0c46c..e4fac14eaba76 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -904,7 +904,10 @@ function TaskForm({ (modelOptions.length ? "Choose model" : "No models available") } onPress={() => navigation.navigate("SettingsScheduledTaskModel")} - disabled={saving || dictationPending || environmentUnavailable} + // Bound runs use the thread's current model; the saved selection only seeds new threads. + disabled={ + saving || dictationPending || environmentUnavailable || draft.threadId !== null + } /> diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index b5b01ddd58b51..0685d0c9384dc 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1777,7 +1777,8 @@ function ScheduledTaskEditorForm({
Date: Sat, 14 Nov 2026 11:44:08 +0000 Subject: [PATCH 3/3] fix(web,mobile): locked model field shows the bound thread's model The disabled Model field still showed the task's saved selection, which bound runs ignore. While a task is bound to a thread, show that thread's current model instead. The saved selection is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../SettingsScheduledTasksRouteScreen.tsx | 18 ++++++--- .../settings/scheduled-task-editor.tsx | 19 +++++++--- .../ScheduledTasksSettings.browser.test.tsx | 11 +++++- .../settings/ScheduledTasksSettings.tsx | 37 +++++++++++++------ 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index e4fac14eaba76..6fcfe3fe229d1 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, @@ -666,6 +670,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"); @@ -897,14 +905,14 @@ 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")} - // Bound runs use the thread's current model; the saved selection only seeds new threads. + // 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 82e8bd7c40332..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(() => ({ @@ -252,6 +258,9 @@ describe("ScheduledTasksSettings", () => { '[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 () => { diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 0685d0c9384dc..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,10 +1792,10 @@ function ScheduledTaskEditorForm({