From 48c336e823ad798ef6ac9be15b821b4da424a37e Mon Sep 17 00:00:00 2001 From: Imamuzzaki Abu Salam Date: Mon, 2 Nov 2026 11:57:04 +0700 Subject: [PATCH 1/5] feat(scheduled-tasks): expose thread binding, runtime, interaction, and model options in web + mobile editors Web Settings editor gains a Destination picker (new/existing thread with search), a Permissions select (provider-compatible runtime modes), a Build/Plan mode toggle, and a TraitsPicker for model options. Bound tasks force a root workspace; project/environment switches reset the binding. Mobile editor gains a Destination section with a thread picker route, Permissions and Mode rows, and persists threadId/interactionMode instead of silently preserving them. Fixes #29049 --- apps/mobile/src/Stack.tsx | 5 + .../SettingsScheduledTasksRouteScreen.tsx | 333 ++++++++++++++++- .../settings/scheduledTaskDraft.test.ts | 40 ++ .../features/settings/scheduledTaskDraft.ts | 14 + .../ScheduledTasksSettings.browser.test.tsx | 12 + .../settings/ScheduledTasksSettings.tsx | 347 ++++++++++++++++-- .../scheduledTasksSettings.logic.test.ts | 7 + .../settings/scheduledTasksSettings.logic.ts | 3 + 8 files changed, 735 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 872ca142c8aaa..3deb6058878fd 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -110,6 +110,7 @@ import { SettingsScheduledTasksRouteScreen, SettingsScheduledTaskNewRouteScreen, SettingsScheduledTaskEditRouteScreen, + ScheduledTaskThreadPickerRouteScreen, } from "./features/settings/SettingsScheduledTasksRouteScreen"; import { ScheduledTaskModelPickerRouteScreen, @@ -415,6 +416,10 @@ const SETTINGS_SCREENS = { }), }, }), + SettingsScheduledTaskThread: createNativeStackScreen({ + screen: ScheduledTaskThreadPickerRouteScreen, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Destination thread" }, + }), SettingsClientStorage: createNativeStackScreen({ screen: SettingsClientStorageRouteScreen, linking: "settings/client-storage", diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index fdf352c2744e3..ae72e200aadc0 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -8,9 +8,14 @@ import { readEnvironmentScope } from "../../state/session"; import type { EnvironmentId, ProjectId, + RuntimeMode, + ProviderInteractionMode, ScheduledTask, ScheduledTaskUpsertInput, + ThreadId, } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { runtimeModeChoicesForSupportedModes } from "../threads/thread-settings-options"; import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES, MAX_WEBHOOK_FILTERS, @@ -38,6 +43,7 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { AppState, Platform, Pressable, TextInput as RNTextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import * as Haptics from "expo-haptics"; import { Button } from "../../components/ui/Button"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -55,7 +61,13 @@ import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { usePreparedConnection } from "../../state/session"; import { buildModelOptions } from "../../lib/modelOptions"; import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; +import { + useProjects, + useEnvironmentServerConfig, + useQuietThreadShell, + useThreadShells, +} from "../../state/entities"; +import { useDebouncedValue, useThreadSearch } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -85,6 +97,8 @@ import { scheduledTaskEditorSessionAtom, } from "./scheduled-task-editor-state"; import { appAtomRegistry } from "../../state/atom-registry"; +import { AsyncResult } from "effect/reactivity"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { formatNextScheduledTaskRun, formatScheduledTaskInterval, @@ -95,6 +109,7 @@ type ScheduledTaskRoutes = { SettingsScheduledTaskEdit: undefined; SettingsScheduledTaskModel: undefined; SettingsScheduledTaskBranch: undefined; + SettingsScheduledTaskThread: undefined; }; const DAYS = [ @@ -373,6 +388,266 @@ function PickerRow(props: { ); } +function TaskDestinationSection({ + environmentId, + draft, + disabled, + setDraft, +}: { + readonly environmentId: EnvironmentId; + readonly draft: Draft; + readonly disabled: boolean; + readonly setDraft: (draft: Draft) => void; +}) { + const navigation = useNavigation>(); + const ref = + draft.destinationMode === "existing_thread" && draft.threadId + ? scopeThreadRef(environmentId, draft.threadId) + : null; + const shell = useQuietThreadShell(ref); + const boundLabel = shell?.title.trim() || (ref ? `Thread ${ref.threadId}` : "Select a thread…"); + return ( + + + + setDraft({ + ...draft, + destinationMode: mode, + ...(mode === "new_thread" ? { threadId: null } : {}), + }) + } + /> + + {draft.destinationMode === "existing_thread" ? ( + <> + navigation.navigate("SettingsScheduledTaskThread")} + disabled={disabled} + /> + {draft.threadId ? ( + setDraft({ ...draft, threadId: null })} + className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70 disabled:opacity-50" + > + + Clear + + + ) : null} + + {draft.threadId + ? "Each run continues this conversation. A bound run follows the thread's workspace and current model; deleting the thread deletes its bound tasks." + : "Pick the thread each run posts into. Saving without a thread starts a new one."} + + + ) : ( + + Each run starts a new thread with the workspace and model below. + + )} + + ); +} + +function TaskRuntimeRow({ + borderTop, + draft, + provider, + setDraft, +}: { + readonly borderTop?: boolean; + readonly draft: Draft; + readonly provider: { supportedRuntimeModes?: ReadonlyArray } | null; + readonly setDraft: (draft: Draft) => void; +}) { + const choices = runtimeModeChoicesForSupportedModes(provider?.supportedRuntimeModes); + return ( + choice.mode === draft.runtimeMode)?.label ?? "Choose permissions" + } + actions={choices.map((choice) => ({ + id: choice.mode, + title: choice.label, + state: choice.mode === draft.runtimeMode ? ("on" as const) : undefined, + }))} + onSelect={(id) => { + if ( + id === "approval-required" || + id === "auto-accept-edits" || + id === "auto" || + id === "full-access" + ) + setDraft({ ...draft, runtimeMode: id }); + }} + /> + ); +} + +function TaskInteractionRow({ + borderTop, + draft, + planModeEnabled, + provider, + disabled, + setDraft, +}: { + readonly borderTop?: boolean; + readonly draft: Draft; + readonly planModeEnabled: boolean; + readonly provider: { showInteractionModeToggle?: boolean } | null; + readonly disabled?: boolean; + readonly setDraft: (draft: Draft) => void; +}) { + const available = planModeEnabled && provider?.showInteractionModeToggle !== false; + return ( + + + + Mode + + + {available + ? "Plan drafts a proposal before acting." + : "Plan is unavailable for this provider."} + + + { + if (!disabled && available) setDraft({ ...draft, interactionMode: mode }); + }} + /> + + ); +} + +export function ScheduledTaskThreadPickerRouteScreen() { + const navigation = useNavigation(); + const { editor, setEditor } = useScheduledTaskEditor(); + const [query, setQuery] = useState(""); + const debouncedQuery = useDebouncedValue(query, 150); + const shells = useThreadShells(); + const projects = useProjects(); + const environmentId = editor?.environmentId ?? null; + // Mounts older settled threads matching the query, which shells may leave out. + useThreadSearch(environmentId ? [environmentId] : [], debouncedQuery, { titlesOnly: true }); + const projectNames = new Map( + projects + .filter((project) => project.environmentId === environmentId) + .map((project) => [project.id, project.title]), + ); + if (!editor || !environmentId) return ; + const search = debouncedQuery.trim().toLocaleLowerCase(); + const candidates = shells + .filter( + (thread) => + thread.environmentId === environmentId && + thread.archivedAt === null && + (editor.draft.projectId === null || thread.projectId === editor.draft.projectId) && + `${thread.title} ${projectNames.get(thread.projectId) ?? ""}` + .toLocaleLowerCase() + .includes(search), + ) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return ( + + + + + {candidates.length === 0 ? ( + No active threads found. + ) : ( + candidates.map((thread, index) => ( + { + void Haptics.selectionAsync(); + setEditor((current) => + current + ? { + ...current, + draft: { + ...current.draft, + destinationMode: "existing_thread", + threadId: thread.id as ThreadId, + }, + } + : current, + ); + navigation.goBack(); + }} + className={ + index === 0 + ? "min-h-14 flex-row items-center gap-3 px-4 py-3 active:opacity-70" + : "min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" + } + > + + + {thread.title || "Untitled thread"} + + + {projectNames.get(thread.projectId) ?? ""} + + + {editor.draft.threadId === thread.id ? ( + Current + ) : null} + + )) + )} + + + + ); +} + +function MissingDraftScreen() { + return ( + + Open a scheduled task form first. + + ); +} + export function SettingsScheduledTasksRouteScreen() { const [now, setNow] = useState(Date.now); useFocusEffect(() => { @@ -717,6 +992,13 @@ function TaskForm({ const offersOnce = config?.environment.capabilities.oneTimeSchedules === true || draft.task?.schedule.type === "once"; + const providerForDraft = + config?.providers.find( + (provider) => provider.instanceId === draft.modelSelection?.instanceId, + ) ?? null; + const preferences = useAtomValue(mobilePreferencesAtom); + const planModeEnabled = + AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; const save = async () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || @@ -781,6 +1063,28 @@ function TaskForm({ Dialog.alert("Project unavailable", "Choose a project in this environment."); return; } + const boundToThread = draft.destinationMode === "existing_thread" && draft.threadId !== null; + if (draft.destinationMode === "existing_thread" && !boundToThread) { + Dialog.alert( + "Destination thread is required", + "Pick the thread each run posts into, or switch back to a new thread.", + ); + return; + } + const runtimeChoices = runtimeModeChoicesForSupportedModes( + providerForDraft?.supportedRuntimeModes, + ); + const runtimeMode = runtimeChoices.some((choice) => choice.mode === draft.runtimeMode) + ? draft.runtimeMode + : runtimeChoices[0]?.mode; + if (!runtimeMode) { + Dialog.alert("Permissions unavailable", "This provider offers no runtime modes."); + return; + } + const interactionMode = + planModeEnabled && providerForDraft?.showInteractionModeToggle !== false + ? draft.interactionMode + : "default"; const input: ScheduledTaskUpsertInput = { ...(draft.task ? { id: draft.task.id, requireExisting: true } : {}), title: draft.title.trim(), @@ -789,9 +1093,9 @@ function TaskForm({ modelSelection: draft.modelSelection, schedule, enabled: draft.enabled, - threadId: draft.task?.threadId ?? null, + threadId: boundToThread ? draft.threadId : null, workspaceStrategy: - draft.workspace === "root" + boundToThread || draft.workspace === "root" ? { type: "root" } : draft.workspace === "existing_worktree" ? { type: "existing_worktree", worktreePath: draft.checkoutPath.trim() } @@ -800,8 +1104,8 @@ function TaskForm({ baseRef: draft.baseRef.trim() || "main", startFromOrigin: draft.startFromOrigin, }, - runtimeMode: draft.runtimeMode, - interactionMode: draft.task?.interactionMode ?? "default", + runtimeMode, + interactionMode, creationSource: draft.task?.creationSource ?? "mobile", }; submissionPending.current = true; @@ -874,6 +1178,13 @@ function TaskForm({ {promptField} + + navigation.navigate("SettingsScheduledTaskModel")} disabled={saving || dictationPending || environmentUnavailable} /> + + diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 7359403a67dce..0cad0b3207a30 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -9,6 +9,8 @@ import { import { scheduledTaskDefaultModel, DEFAULT_SCHEDULE, + createDraft, + editDraft, scheduleDraftForTask, scheduleFromDraft, } from "./scheduledTaskDraft"; @@ -207,3 +209,41 @@ it("rejects dedupe keys without valid request placeholders", () => { scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", coalesceKey: "{{body.bad" }), ).toBeNull(); }); + +describe("scheduled task destination and modes", () => { + it("starts new drafts unbound with default modes", () => { + const draft = createDraft(null, null); + expect(draft.destinationMode).toBe("new_thread"); + expect(draft.threadId).toBeNull(); + expect(draft.runtimeMode).toBe("full-access"); + expect(draft.interactionMode).toBe("default"); + }); + + it("opens bound tasks on the existing-thread destination", () => { + const draft = editDraft({ + threadId: "thread-1", + projectId: "project-1", + title: "t", + prompt: "p", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "project-model" }, + runtimeMode: "auto", + interactionMode: "plan", + createdBy: "user", + creationSource: "mobile", + createdAt: "2026-09-17T00:00:00.000Z", + updatedAt: "2026-09-17T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, + } as never); + expect(draft.destinationMode).toBe("existing_thread"); + expect(draft.threadId).toBe("thread-1"); + expect(draft.runtimeMode).toBe("auto"); + expect(draft.interactionMode).toBe("plan"); + }); +}); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index d89b004a0dc6f..11bed18c66315 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -1,12 +1,14 @@ import { isWebhookDedupeTemplate } from "@t3tools/contracts"; import type { ModelSelection, + ProviderInteractionMode, ServerConfig, ProjectId, RuntimeMode, ScheduledTask, ScheduledTaskUpsertSchedule, ScheduledTaskWebhookSignature, + ThreadId, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; @@ -201,6 +203,8 @@ export type ScheduledTaskDraft = { readonly title: string; readonly prompt: string; readonly projectId: ProjectId | null; + readonly threadId: ThreadId | null; + readonly destinationMode: "new_thread" | "existing_thread"; readonly modelSelection: ModelSelection | null; readonly modelSelectionIsExplicit: boolean; readonly schedule: ScheduleDraft; @@ -210,6 +214,7 @@ export type ScheduledTaskDraft = { readonly enabled: boolean; readonly startFromOrigin: boolean; readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; }; function draftSignature(draft: ScheduledTaskDraft): string { @@ -238,6 +243,9 @@ function draftSignature(draft: ScheduledTaskDraft): string { draft.enabled, draft.startFromOrigin, draft.runtimeMode, + draft.interactionMode, + draft.destinationMode, + draft.threadId, ]); } @@ -257,6 +265,8 @@ export function createDraft( title: "", prompt: "", projectId, + threadId: null, + destinationMode: "new_thread", modelSelection, modelSelectionIsExplicit: false, schedule: DEFAULT_SCHEDULE, @@ -266,6 +276,7 @@ export function createDraft( enabled: true, startFromOrigin: true, runtimeMode: "full-access", + interactionMode: "default", }; } @@ -275,6 +286,8 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft { title: task.title, prompt: task.prompt, projectId: task.projectId, + threadId: task.threadId, + destinationMode: task.threadId ? "existing_thread" : "new_thread", modelSelection: task.modelSelection, modelSelectionIsExplicit: true, schedule: scheduleDraftForTask(task), @@ -290,5 +303,6 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft { ? (task.workspaceStrategy.startFromOrigin ?? false) : true, runtimeMode: task.runtimeMode, + interactionMode: task.interactionMode, }; } diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index ca8192dd894e3..093cb7e7de0a0 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -215,4 +215,16 @@ describe("ScheduledTasksSettings", () => { await expect.element(dialog.getByRole("heading", { name: "New task" })).toBeInTheDocument(); await expect.element(name).toHaveValue("My draft"); }); + + it("exposes destination, permissions, and mode controls when editing", async () => { + setup(); + await using screen = await render(); + const dialog = page.getByRole("dialog"); + await expect.element(dialog.getByText("Fix login bug")).toBeVisible(); + // Destination toggle with bound thread shown; permissions + Build/Plan mode controls. + await expect.element(dialog.getByLabelText("Destination")).toBeVisible(); + await expect.element(dialog.getByLabelText("Permissions")).toBeVisible(); + await expect.element(dialog.getByLabelText("Interaction mode")).toBeVisible(); + await screen.rerender(); + }); }); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 9489e4162cba2..01b552c69eab0 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -11,6 +11,7 @@ import { PlusIcon, Trash2Icon, } from "lucide-react"; +import { createModelSelection } from "@t3tools/shared/model"; import { type ReactNode, useRef, useState } from "react"; import type { EnvironmentId, @@ -59,10 +60,11 @@ import { useEnvironmentHttpBaseUrl, type EnvironmentPresentation, } from "../../state/environments"; -import { useProjects, useThreadShell } from "../../state/entities"; +import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query"; +import { useThreadSearch } from "../../state/queries"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { threadFamilies } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -86,12 +88,22 @@ import { scheduledTaskDefaultModel, taskToDraft, type DraftState, + type DestinationMode, type ScheduleMode, type WorkspaceMode, } from "./scheduledTasksSettings.logic"; import { Label } from "../ui/label"; import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/menu"; import { ToggleGroup, Toggle } from "../ui/toggle-group"; +import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; +import { MessageSquareIcon } from "lucide-react"; +import { + resolveCompatibleRuntimeMode, + runtimeModeConfig, + runtimeModeOptions, +} from "../chat/runtimeModeConfig"; +import { resolveComposerInteractionMode } from "../ChatView.logic"; +import { TraitsPicker } from "../chat/TraitsPicker"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "../ui/empty"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { Badge } from "../ui/badge"; @@ -142,6 +154,7 @@ const EMPTY_DRAFT: DraftState = { weekdays: new Set([1, 2, 3, 4, 5]), projectId: "", threadId: "", + destinationMode: "new_thread", workspaceMode: "worktree", baseRef: "main", startFromOrigin: true, @@ -1134,36 +1147,194 @@ function ScheduledTaskEditorDialog({ function ScheduledTaskDestination({ environmentId, - threadId, + projectId, + draft, + onChange, + disabled, }: { readonly environmentId: EnvironmentId; - readonly threadId: ThreadId | null; + readonly projectId: string; + readonly draft: Pick; + readonly onChange: (patch: Partial>) => void; + readonly disabled: boolean; }) { - const ref = threadId === null ? null : scopeThreadRef(environmentId, threadId); + const [pickerOpen, setPickerOpen] = useState(false); + const ref = + draft.destinationMode === "existing_thread" && draft.threadId + ? scopeThreadRef(environmentId, draft.threadId as ThreadId) + : null; 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 = + shell ?? family.data?.threads.find((row) => row.id === (draft.threadId as ThreadId)); + const boundLabel = thread?.title.trim() || (ref ? `Thread ${ref.threadId}` : "Select a thread…"); return ( - {ref === null ? ( +
+ { + const mode = values[0] as DestinationMode | undefined; + if (mode === "new_thread" || mode === "existing_thread") + onChange({ + destinationMode: mode, + ...(mode === "new_thread" ? { threadId: "" } : {}), + }); + }} + > + + New thread + + + Existing thread + + + {draft.destinationMode === "existing_thread" ? ( + + ) : null} + {draft.destinationMode === "existing_thread" && draft.threadId ? ( + + ) : null} +
+ {draft.destinationMode === "new_thread" ? (

Each run starts a new thread with the workspace and model below.

) : (
-

{thread?.title.trim() || `Thread ${ref.threadId}`}

-

- Each run continues this conversation, in its workspace and with its current model. -

+ {draft.threadId ? ( + <> +

{boundLabel}

+

+ Each run continues this conversation. A bound run follows the thread's workspace and + current model; deleting the thread deletes its bound tasks. +

+ + ) : ( +

+ Pick the thread each run posts into. Saving without a thread starts a new one. +

+ )}
)} + {pickerOpen ? ( + { + onChange({ destinationMode: "existing_thread", threadId }); + setPickerOpen(false); + }} + onClose={() => setPickerOpen(false)} + /> + ) : null}
); } +function ScheduledTaskThreadPicker({ + environmentId, + projectId, + selectedThreadId, + onSelect, + onClose, +}: { + readonly environmentId: EnvironmentId; + readonly projectId: string; + readonly selectedThreadId: ThreadId | null; + readonly onSelect: (threadId: string) => void; + readonly onClose: () => void; +}) { + const threads = useThreadShells(); + const projects = useProjects(); + const [query, setQuery] = useState(""); + // Mounts older settled threads matching the query, which shells may leave out. + useThreadSearch([environmentId], query, { titlesOnly: true }); + const projectNames = new Map( + projects + .filter((project) => project.environmentId === environmentId) + .map((project) => [project.id, project.title]), + ); + const search = query.trim().toLocaleLowerCase(); + const candidates = threads + .filter( + (thread) => + thread.environmentId === environmentId && + thread.archivedAt === null && + (projectId === "" || thread.projectId === projectId) && + `${thread.title} ${projectNames.get(thread.projectId) ?? ""}` + .toLocaleLowerCase() + .includes(search), + ) + .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return ( + { + if (!open) onClose(); + }} + > + + Choose a destination thread + + + + {candidates.length === 0 ? ( +
+ No active threads found. +
+ ) : ( + candidates.map((thread) => ( + onSelect(thread.id)} + > + + + {thread.title || "Untitled thread"} + + {projectNames.get(thread.projectId)} + + + {thread.id === selectedThreadId ? ( + Current + ) : null} + + )) + )} +
+
+
+
+ ); +} + function ScheduledTaskEditorForm({ initialEnvironmentId, task, @@ -1239,20 +1410,46 @@ function ScheduledTaskEditorForm({ const firstInstance = instanceEntries[0]; const draftSelection = draft.modelKey ? splitModelKey(draft.modelKey) : null; + // Provider options (reasoning, speed) live on the stored selection; the + // compact modelKey round-trips them through baseModelSelection. + const draftSelectionWithOptions = + draftSelection !== null && + draft.baseModelSelection !== null && + draft.baseModelSelection.instanceId === draftSelection.instanceId && + draft.baseModelSelection.model === draftSelection.model + ? draft.baseModelSelection + : draftSelection; const activeSelection = - draftSelection && - instanceEntries.some((entry) => entry.instanceId === draftSelection.instanceId && entry.enabled) - ? draftSelection + draftSelectionWithOptions && + instanceEntries.some( + (entry) => entry.instanceId === draftSelectionWithOptions.instanceId && entry.enabled, + ) + ? draftSelectionWithOptions : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries); const activeInstanceId = activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId); const activeModel = activeSelection?.model ?? ""; + const activeEntry = + instanceEntries.find((entry) => entry.instanceId === activeInstanceId) ?? null; const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, providers, activeInstanceId, activeModel, ); + const { modes: compatibleRuntimeModes } = resolveCompatibleRuntimeMode( + draft.runtimeMode, + activeEntry?.snapshot.supportedRuntimeModes, + ); + const compatibleRuntimeModeOptions = runtimeModeOptions.filter((mode) => + compatibleRuntimeModes.includes(mode), + ); + const { enabled: planModeUiEnabled, interactionMode: compatibleInteractionMode } = + resolveComposerInteractionMode({ + planModeEnabled: settings.planModeEnabled, + provider: activeEntry?.snapshot ?? null, + interactionMode: draft.interactionMode, + }); const reportFailure = (title: string, error: unknown) => { toastManager.add( @@ -1328,8 +1525,21 @@ function ScheduledTaskEditorForm({ draft.baseModelSelection.model === selection.model ? draft.baseModelSelection : selection; - const workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = - draft.workspaceMode === "root" + const boundToThread = draft.destinationMode === "existing_thread" && draft.threadId !== ""; + if (draft.destinationMode === "existing_thread" && !boundToThread) { + reportFailure( + "Destination thread is required", + "Pick the thread each run posts into, or switch back to a new thread.", + ); + return; + } + const runtimeMode = compatibleRuntimeModes.includes(draft.runtimeMode) + ? draft.runtimeMode + : (compatibleRuntimeModes[0] ?? draft.runtimeMode); + const interactionMode = planModeUiEnabled ? compatibleInteractionMode : "default"; + const workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = boundToThread + ? { type: "root" } + : draft.workspaceMode === "root" ? { type: "root" } : draft.workspaceMode === "existing_worktree" ? { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() } @@ -1345,11 +1555,11 @@ function ScheduledTaskEditorForm({ enabled: draft.enabled, schedule, projectId: selectedProjectId as ProjectId, - threadId: draft.threadId ? (draft.threadId as ThreadId) : null, + threadId: boundToThread ? (draft.threadId as ThreadId) : null, workspaceStrategy, modelSelection, - runtimeMode: draft.runtimeMode, - interactionMode: draft.interactionMode, + runtimeMode, + interactionMode, creationSource: "web", }; // Lock before React renders, and keep successful creates locked until the form closes. @@ -1394,6 +1604,9 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, projectId: defaultScheduledTaskProjectId(projectsOn(next.environmentId), scope), + // Bindings don't cross environments; start unbound on the new one. + destinationMode: "new_thread", + threadId: "", modelKey: "", baseModelSelection: null, baseRef: "main", @@ -1468,7 +1681,10 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, ...patch }))} />
@@ -1476,7 +1692,13 @@ function ScheduledTaskEditorForm({ + setDraft((current) => ({ + ...current, + runtimeMode: (value ?? "full-access") as DraftState["runtimeMode"], + })) + } + > + + {runtimeModeConfig[draft.runtimeMode].label} + + + {compatibleRuntimeModeOptions.map((mode) => { + const option = runtimeModeConfig[mode]; + return ( + + {option.label} + + {option.description} + + + ); + })} + + +

+ {runtimeModeConfig[draft.runtimeMode].description} +

+ + + + { + const mode = values[0] as DraftState["interactionMode"] | undefined; + if (mode === "default" || mode === "plan") + setDraft((current) => ({ ...current, interactionMode: mode })); + }} + > + + Build + + + Plan + + +

+ {planModeUiEnabled + ? "Plan drafts a proposal before acting." + : "Plan is unavailable for this provider."} +

+
+
+
{task?.schedule.type === "interval" && task.schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS ? ( diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index c4c66c1cfbae0..bee29ccaedefd 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -168,6 +168,13 @@ describe("editing scheduled task branch settings", () => { expect(draft.baseRef).toBe("release"); expect(draft.startFromOrigin).toBe(false); }); + + it("round-trips the destination through the draft", () => { + expect(taskToDraft(legacyTask).destinationMode).toBe("new_thread"); + expect(taskToDraft({ ...legacyTask, threadId: "thread-1" as never }).destinationMode).toBe( + "existing_thread", + ); + }); }); describe("one-time scheduled tasks", () => { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 9e0d0dad27cb9..204625174a515 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -54,6 +54,7 @@ export function validateScheduledTasksSearch(raw: Record) { export type ScheduleMode = "fixed" | "once" | "interval" | "webhook"; export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; +export type DestinationMode = "new_thread" | "existing_thread"; export interface DraftState { readonly editingId: string | null; @@ -70,6 +71,7 @@ export interface DraftState { readonly weekdays: ReadonlySet; readonly projectId: string; readonly threadId: string; + readonly destinationMode: DestinationMode; readonly workspaceMode: WorkspaceMode; readonly baseRef: string; readonly startFromOrigin: boolean; @@ -254,6 +256,7 @@ export function taskToDraft(task: ScheduledTask): DraftState { weekdays, projectId: task.projectId, threadId: task.threadId ?? "", + destinationMode: task.threadId ? "existing_thread" : "new_thread", workspaceMode: task.workspaceStrategy.type, baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main", startFromOrigin: From e26c675c1806db96f7284ffb148afed31b052e30 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 12 Nov 2026 00:43:50 +0000 Subject: [PATCH 2/5] fix(scheduled-tasks): preserve mode edits and resolve supported policy controls --- .../SettingsScheduledTasksRouteScreen.tsx | 39 +++++++++++-------- .../scheduled-task-editor-state.test.ts | 25 ++++++------ .../features/settings/scheduledTaskDraft.ts | 1 + .../settings/ScheduledTasksSettings.tsx | 10 +---- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 5ed7f1f6b3f31..c09e2b8994192 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -1,6 +1,7 @@ -import { runtimeModeChoicesForSupportedModes } from "../threads/thread-settings-options"; -import { AsyncResult } from "effect/reactivity"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { + runtimeModeChoicesForSupportedModes, + withManagedRuntimeModeLimits, +} from "../threads/thread-settings-options"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { @@ -14,6 +15,7 @@ import type { EnvironmentId, ProjectId, RuntimeMode, + ManagedPolicySummary, ProviderInteractionMode, ScheduledTask, ScheduledTaskThreadMode, @@ -395,25 +397,30 @@ function TaskRuntimeRow({ borderTop, draft, provider, + managedPolicy, setDraft, }: { readonly borderTop?: boolean; readonly draft: Draft; readonly provider: { supportedRuntimeModes?: ReadonlyArray } | null; + readonly managedPolicy: ManagedPolicySummary | undefined; readonly setDraft: (draft: Draft) => void; }) { - const choices = runtimeModeChoicesForSupportedModes(provider?.supportedRuntimeModes); + const choices = withManagedRuntimeModeLimits( + runtimeModeChoicesForSupportedModes(provider?.supportedRuntimeModes), + [managedPolicy], + ).filter((choice) => choice.disabledReason === undefined); + const runtimeMode = + clampManagedRuntimeMode(managedPolicy, draft.runtimeMode) ?? draft.runtimeMode; return ( choice.mode === draft.runtimeMode)?.label ?? "Choose permissions" - } + value={choices.find((choice) => choice.mode === runtimeMode)?.label ?? "Choose permissions"} actions={choices.map((choice) => ({ id: choice.mode, title: choice.label, - state: choice.mode === draft.runtimeMode ? ("on" as const) : undefined, + state: choice.mode === runtimeMode ? ("on" as const) : undefined, }))} onSelect={(id) => { if ( @@ -797,9 +804,7 @@ function TaskForm({ config?.providers.find( (provider) => provider.instanceId === draft.modelSelection?.instanceId, ) ?? null; - const preferences = useAtomValue(mobilePreferencesAtom); - const planModeEnabled = - AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; + const planModeEnabled = providerForDraft?.showInteractionModeToggle !== false; const boundToThread = draft.threadId !== null; const save = async () => { if ( @@ -872,10 +877,7 @@ function TaskForm({ Dialog.alert("Permissions unavailable", "Choose permissions supported by this provider."); return; } - const interactionMode = - planModeEnabled && providerForDraft?.showInteractionModeToggle !== false - ? draft.interactionMode - : "default"; + const interactionMode = planModeEnabled ? draft.interactionMode : "default"; const input: ScheduledTaskUpsertInput = { ...(draft.task ? { id: draft.task.id, requireExisting: true } : {}), title: draft.title.trim(), @@ -1075,7 +1077,12 @@ function TaskForm({ ) : ( - + { expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)).toBeNull(); }); - it("keeps unsaved changes until the editor's removal guard discards them", () => { - startScheduledTaskEditor(editing); - const edited = { ...editing, draft: { ...editing.draft, prompt: "unsaved" } }; - updateScheduledTaskEditor(edited, null); - - takeScheduledTaskNewLink(newTaskLink); - expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBe(edited); - - discardScheduledTaskEditor(); - expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)).toBeNull(); - }); + it.each([{ prompt: "unsaved" }, { interactionMode: "plan" as const }])( + "keeps unsaved %j changes until the editor's removal guard discards them", + (patch) => { + startScheduledTaskEditor(editing); + const edited = { ...editing, draft: { ...editing.draft, ...patch } }; + updateScheduledTaskEditor(edited, null); + + takeScheduledTaskNewLink(newTaskLink); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBe(edited); + + discardScheduledTaskEditor(); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)).toBeNull(); + }, + ); it("starts the new-task screen fresh after the edited task was saved", () => { startScheduledTaskEditor(editing); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 24b54d4abc0ed..c5c3227bbc91d 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -246,6 +246,7 @@ function draftSignature(draft: ScheduledTaskDraft): string { draft.enabled, draft.startFromOrigin, draft.runtimeMode, + draft.interactionMode, ]); } diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index d8056b2ad35c5..56270691821af 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -122,7 +122,6 @@ import { runtimeModeConfig, runtimeModeOptions, } from "../chat/runtimeModeConfig"; -import { resolveComposerInteractionMode } from "../ChatView.logic"; import { TraitsPicker } from "../chat/TraitsPicker"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "../ui/empty"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; @@ -1454,12 +1453,8 @@ function ScheduledTaskEditorForm({ const compatibleRuntimeModeOptions = runtimeModeOptions.filter((mode) => compatibleRuntimeModes.includes(mode), ); - const { enabled: planModeUiEnabled, interactionMode: compatibleInteractionMode } = - resolveComposerInteractionMode({ - planModeEnabled: settings.planModeEnabled, - provider: activeEntry?.snapshot ?? null, - interactionMode: draft.interactionMode, - }); + const planModeUiEnabled = activeEntry?.snapshot.showInteractionModeToggle !== false; + const compatibleInteractionMode = planModeUiEnabled ? draft.interactionMode : "default"; const runtimeMode = clampManagedRuntimeMode(environment?.serverConfig?.managedPolicy, providerRuntimeMode) ?? @@ -1826,7 +1821,6 @@ function ScheduledTaskEditorForm({ model={activeSelection.model} modelOptions={activeSelection.options ?? []} allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} disabled={saving || !connected} triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} onModelOptionsChange={(options) => From 5800404874f55421b7fe633f86a16e7554f34e81 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 12 Nov 2026 00:46:36 +0000 Subject: [PATCH 3/5] fix(scheduled-tasks): clamp permissions within provider modes --- .../settings/SettingsScheduledTasksRouteScreen.tsx | 12 ++++++++++-- .../components/settings/ScheduledTasksSettings.tsx | 7 +++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index c09e2b8994192..6b4fe910c86b0 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -411,7 +411,11 @@ function TaskRuntimeRow({ [managedPolicy], ).filter((choice) => choice.disabledReason === undefined); const runtimeMode = - clampManagedRuntimeMode(managedPolicy, draft.runtimeMode) ?? draft.runtimeMode; + clampManagedRuntimeMode( + managedPolicy, + draft.runtimeMode, + choices.map((choice) => choice.mode), + ) ?? draft.runtimeMode; return ( choice.mode), + ) ?? draft.runtimeMode, interactionMode: boundToThread ? draft.interactionMode : interactionMode, creationSource: draft.task?.creationSource ?? "mobile", }; diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 56270691821af..67298b666874a 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1457,8 +1457,11 @@ function ScheduledTaskEditorForm({ const compatibleInteractionMode = planModeUiEnabled ? draft.interactionMode : "default"; const runtimeMode = - clampManagedRuntimeMode(environment?.serverConfig?.managedPolicy, providerRuntimeMode) ?? - providerRuntimeMode; + clampManagedRuntimeMode( + environment?.serverConfig?.managedPolicy, + providerRuntimeMode, + compatibleRuntimeModes, + ) ?? providerRuntimeMode; const boundToThread = draft.threadId !== ""; const interactionMode = planModeUiEnabled ? compatibleInteractionMode : "default"; From c3a5baa938b6884e247f5ca9a9d4a750f15b7cc7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 12 Nov 2026 00:57:41 +0000 Subject: [PATCH 4/5] fix(mobile): validate the displayed scheduled-task permission --- .../SettingsScheduledTasksRouteScreen.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 6b4fe910c86b0..c811b5cf3287f 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -877,7 +877,13 @@ function TaskForm({ const runtimeChoices = runtimeModeChoicesForSupportedModes( providerForDraft?.supportedRuntimeModes, ); - if (!boundToThread && !runtimeChoices.some((choice) => choice.mode === draft.runtimeMode)) { + const runtimeMode = + clampManagedRuntimeMode( + config?.managedPolicy, + draft.runtimeMode, + runtimeChoices.map((choice) => choice.mode), + ) ?? draft.runtimeMode; + if (!boundToThread && !runtimeChoices.some((choice) => choice.mode === runtimeMode)) { Dialog.alert("Permissions unavailable", "Choose permissions supported by this provider."); return; } @@ -902,12 +908,7 @@ function TaskForm({ baseRef: draft.baseRef.trim() || "main", startFromOrigin: draft.startFromOrigin, }, - runtimeMode: - clampManagedRuntimeMode( - config?.managedPolicy, - draft.runtimeMode, - runtimeChoices.map((choice) => choice.mode), - ) ?? draft.runtimeMode, + runtimeMode, interactionMode: boundToThread ? draft.interactionMode : interactionMode, creationSource: draft.task?.creationSource ?? "mobile", }; From 55a57e55dd6adc2482043a0a1a79c1e7f634acad Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 12 Nov 2026 01:04:56 +0000 Subject: [PATCH 5/5] fix(mobile): preserve bound scheduled-task permissions --- .../src/features/settings/SettingsScheduledTasksRouteScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index c811b5cf3287f..b6ca3dc41cc21 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -908,7 +908,7 @@ function TaskForm({ baseRef: draft.baseRef.trim() || "main", startFromOrigin: draft.startFromOrigin, }, - runtimeMode, + runtimeMode: boundToThread ? draft.runtimeMode : runtimeMode, interactionMode: boundToThread ? draft.interactionMode : interactionMode, creationSource: draft.task?.creationSource ?? "mobile", };