diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index f4604ca4a2f6d..b6ca3dc41cc21 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -1,3 +1,7 @@ +import { + runtimeModeChoicesForSupportedModes, + withManagedRuntimeModeLimits, +} from "../threads/thread-settings-options"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { @@ -10,6 +14,9 @@ import { readEnvironmentScope } from "../../state/session"; import type { EnvironmentId, ProjectId, + RuntimeMode, + ManagedPolicySummary, + ProviderInteractionMode, ScheduledTask, ScheduledTaskThreadMode, ScheduledTaskUpsertInput, @@ -386,6 +393,101 @@ function PickerRow(props: { ); } +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 = withManagedRuntimeModeLimits( + runtimeModeChoicesForSupportedModes(provider?.supportedRuntimeModes), + [managedPolicy], + ).filter((choice) => choice.disabledReason === undefined); + const runtimeMode = + clampManagedRuntimeMode( + managedPolicy, + draft.runtimeMode, + choices.map((choice) => choice.mode), + ) ?? draft.runtimeMode; + return ( + choice.mode === runtimeMode)?.label ?? "Choose permissions"} + actions={choices.map((choice) => ({ + id: choice.mode, + title: choice.label, + state: choice.mode === 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 SettingsScheduledTasksRouteScreen() { const [now, setNow] = useState(Date.now); useFocusEffect(() => { @@ -702,6 +804,12 @@ function TaskForm({ config?.environment.capabilities.oneTimeSchedules === true || draft.task?.schedule.type === "once"; const offersThreadModes = config?.environment.capabilities.scheduledTaskThreadModes === true; + const providerForDraft = + config?.providers.find( + (provider) => provider.instanceId === draft.modelSelection?.instanceId, + ) ?? null; + const planModeEnabled = providerForDraft?.showInteractionModeToggle !== false; + const boundToThread = draft.threadId !== null; const save = async () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || @@ -766,6 +874,20 @@ function TaskForm({ Dialog.alert("Project unavailable", "Choose a project in this environment."); return; } + const runtimeChoices = runtimeModeChoicesForSupportedModes( + providerForDraft?.supportedRuntimeModes, + ); + 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; + } + const interactionMode = planModeEnabled ? draft.interactionMode : "default"; const input: ScheduledTaskUpsertInput = { ...(draft.task ? { id: draft.task.id, requireExisting: true } : {}), title: draft.title.trim(), @@ -786,10 +908,8 @@ function TaskForm({ baseRef: draft.baseRef.trim() || "main", startFromOrigin: draft.startFromOrigin, }, - // Tasks have no mode picker; a mode above the administrator's ceiling is refused. - runtimeMode: - clampManagedRuntimeMode(config?.managedPolicy, draft.runtimeMode) ?? draft.runtimeMode, - interactionMode: draft.task?.interactionMode ?? "default", + runtimeMode: boundToThread ? draft.runtimeMode : runtimeMode, + interactionMode: boundToThread ? draft.interactionMode : interactionMode, creationSource: draft.task?.creationSource ?? "mobile", }; submissionPending.current = true; @@ -960,6 +1080,29 @@ function TaskForm({ ) : null} + {boundToThread ? ( + + Runs use the destination thread's workspace, permissions, and mode. + + ) : ( + + + + + )} + { 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 1ede30d84072b..c5c3227bbc91d 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -1,6 +1,7 @@ import { isWebhookDedupeTemplate, scheduledTaskThreadMode } from "@t3tools/contracts"; import type { ModelSelection, + ProviderInteractionMode, ServerConfig, ProjectId, RuntimeMode, @@ -214,6 +215,7 @@ export type ScheduledTaskDraft = { readonly enabled: boolean; readonly startFromOrigin: boolean; readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; }; function draftSignature(draft: ScheduledTaskDraft): string { @@ -244,6 +246,7 @@ function draftSignature(draft: ScheduledTaskDraft): string { draft.enabled, draft.startFromOrigin, draft.runtimeMode, + draft.interactionMode, ]); } @@ -306,6 +309,7 @@ export function createDraft( enabled: true, startFromOrigin: true, runtimeMode: "full-access", + interactionMode: "default", }; } @@ -332,5 +336,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.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 848b88130f48a..67298b666874a 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -13,6 +13,7 @@ import { PlusIcon, Trash2Icon, } from "lucide-react"; +import { createModelSelection } from "@t3tools/shared/model"; import { type ReactNode, useRef, useState } from "react"; import type { EnvironmentId, @@ -116,6 +117,12 @@ import { import { Label } from "../ui/label"; import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/menu"; import { ToggleGroup, Toggle } from "../ui/toggle-group"; +import { + resolveCompatibleRuntimeMode, + runtimeModeConfig, + runtimeModeOptions, +} from "../chat/runtimeModeConfig"; +import { TraitsPicker } from "../chat/TraitsPicker"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "../ui/empty"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { Badge } from "../ui/badge"; @@ -1412,20 +1419,51 @@ 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, mode: providerRuntimeMode } = resolveCompatibleRuntimeMode( + draft.runtimeMode, + activeEntry?.snapshot.supportedRuntimeModes, + ); + const compatibleRuntimeModeOptions = runtimeModeOptions.filter((mode) => + compatibleRuntimeModes.includes(mode), + ); + const planModeUiEnabled = activeEntry?.snapshot.showInteractionModeToggle !== false; + const compatibleInteractionMode = planModeUiEnabled ? draft.interactionMode : "default"; + + const runtimeMode = + clampManagedRuntimeMode( + environment?.serverConfig?.managedPolicy, + providerRuntimeMode, + compatibleRuntimeModes, + ) ?? providerRuntimeMode; + const boundToThread = draft.threadId !== ""; + const interactionMode = planModeUiEnabled ? compatibleInteractionMode : "default"; const reportFailure = (title: string, error: unknown) => { toastManager.add( @@ -1522,11 +1560,8 @@ function ScheduledTaskEditorForm({ ...(offersThreadModes ? { threadMode: draft.threadMode } : {}), workspaceStrategy, modelSelection, - // Tasks have no mode picker; a mode above the administrator's ceiling would fail every run. - runtimeMode: - clampManagedRuntimeMode(environment?.serverConfig?.managedPolicy, draft.runtimeMode) ?? - draft.runtimeMode, - interactionMode: draft.interactionMode, + runtimeMode: boundToThread ? draft.runtimeMode : runtimeMode, + interactionMode: boundToThread ? draft.interactionMode : interactionMode, creationSource: "web", }; // Lock before React renders, and keep successful creates locked until the form closes. @@ -1766,7 +1801,7 @@ function ScheduledTaskEditorForm({ -
+
({ ...current, modelKey: `${instanceId}:${model}` })) } /> + {activeSelection && activeEntry ? ( + + setDraft((current) => ({ + ...current, + modelKey: `${activeSelection.instanceId}:${activeSelection.model}`, + baseModelSelection: createModelSelection( + activeSelection.instanceId, + activeSelection.model, + options, + ), + })) + } + /> + ) : null}
+ {boundToThread ? ( +

+ Runs use the destination thread's workspace, permissions, and mode. +

+ ) : ( +
+ + +

+ {runtimeModeConfig[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 ? (