diff --git a/apps/desktop/src/app/cron/cron-job-model.test.ts b/apps/desktop/src/app/cron/cron-job-model.test.ts index 14873e299877..a506f418fd42 100644 --- a/apps/desktop/src/app/cron/cron-job-model.test.ts +++ b/apps/desktop/src/app/cron/cron-job-model.test.ts @@ -35,7 +35,7 @@ describe('cronEditorUpdates', () => { it('omits prompt when saving a script-only job with an empty prompt', () => { expect( cronEditorUpdates( - { deliver: 'local', name: 'Weekly', prompt: '', schedule: '0 9 * * 1' }, + { deliver: 'local', model: '', name: 'Weekly', prompt: '', provider: '', schedule: '0 9 * * 1' }, { scriptOnlyJob: true } ) ).toEqual({ @@ -48,9 +48,46 @@ describe('cronEditorUpdates', () => { it('includes prompt when the user typed one on a script-only job', () => { expect( cronEditorUpdates( - { deliver: 'email', name: 'Weekly', prompt: 'note', schedule: '0 9 * * 1' }, + { deliver: 'email', model: '', name: 'Weekly', prompt: 'note', provider: '', schedule: '0 9 * * 1' }, { scriptOnlyJob: true } ).prompt ).toBe('note') }) + + it('writes the model override for agent jobs', () => { + const updates = cronEditorUpdates( + { + deliver: 'local', + model: 'claude-sonnet-4', + name: 'Daily', + prompt: 'go', + provider: 'anthropic', + schedule: '0 9 * * *' + }, + { scriptOnlyJob: false } + ) + + expect(updates.model).toBe('claude-sonnet-4') + expect(updates.provider).toBe('anthropic') + }) + + it('clears a previous pin when the override is reset to default', () => { + const updates = cronEditorUpdates( + { deliver: 'local', model: '', name: 'Daily', prompt: 'go', provider: '', schedule: '0 9 * * *' }, + { scriptOnlyJob: false } + ) + + expect(updates.model).toBe(null) + expect(updates.provider).toBe(null) + }) + + it('never touches model fields on script-only jobs', () => { + const updates = cronEditorUpdates( + { deliver: 'local', model: 'x', name: 'Weekly', prompt: '', provider: 'y', schedule: '0 9 * * 1' }, + { scriptOnlyJob: true } + ) + + expect('model' in updates).toBe(false) + expect('provider' in updates).toBe(false) + }) }) diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts index 0caf6bf2b8b5..bd7c5de5ba9b 100644 --- a/apps/desktop/src/app/cron/cron-job-model.ts +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -36,8 +36,12 @@ export function validateCronEditor(input: CronEditorValidationInput): CronEditor export interface CronEditorSaveValues { deliver: string + /** Per-job model override ('' = follow the global default at fire time). */ + model: string name: string prompt: string + /** Provider for the model override ('' = none). Always paired with model. */ + provider: string schedule: string } @@ -55,5 +59,14 @@ export function cronEditorUpdates(values: CronEditorSaveValues, options: { scrip updates.prompt = trimmedPrompt } + // Script-only jobs never run an agent, so the scheduler ignores model + // overrides — leave whatever is stored untouched. For agent jobs, always + // write both axes so resetting to "default" clears a previous pin (the + // backend normalizes null/'' to "no override"). + if (!options.scriptOnlyJob) { + updates.model = values.model.trim() || null + updates.provider = values.provider.trim() || null + } + return updates } diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 0defd4e62a7d..eb9e4257cf2e 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -1,4 +1,5 @@ import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -14,7 +15,15 @@ import { DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue +} from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { createCronJob, @@ -30,6 +39,7 @@ import { } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { AlertTriangle } from '@/lib/icons' +import { requestModelOptions } from '@/lib/model-options' import { asText } from '@/lib/text' import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron' import { notify, notifyError } from '@/store/notifications' @@ -59,6 +69,10 @@ import { jobState, jobTitle, STATE_DOT } from './job-state' const DEFAULT_DELIVER = 'local' +// Radix rejects empty-string values, so the "no override" row in +// the model picker carries this sentinel and is mapped back to '' on save. +const MODEL_DEFAULT_VALUE = '__default__' + const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email'] const SCHEDULE_OPTIONS: ReadonlyArray = [ @@ -103,6 +117,14 @@ function jobDeliver(job: CronJob): string { return asText(job.deliver) || DEFAULT_DELIVER } +function jobModel(job: CronJob): string { + return asText(job.model).trim() +} + +function jobProvider(job: CronJob): string { + return asText(job.provider).trim() +} + function cronParts(expr: string): null | string[] { const parts = expr.trim().replace(/\s+/g, ' ').split(' ') @@ -391,7 +413,8 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt prompt: values.prompt, schedule: values.schedule, name: values.name || undefined, - deliver: values.deliver || DEFAULT_DELIVER + deliver: values.deliver || DEFAULT_DELIVER, + ...(values.model.trim() ? { model: values.model.trim(), provider: values.provider.trim() || undefined } : {}) }) updateCronJobs(rows => [...rows, created]) @@ -550,6 +573,7 @@ function CronJobDetail({ const isPaused = state === 'paused' const deliver = jobDeliver(job) const prompt = jobPrompt(job) + const modelOverride = jobModel(job) return ( @@ -574,7 +598,8 @@ function CronJobDetail({ { label: c.frequencyLabel, value: jobScheduleDisplay(job) }, { label: c.last.replace(/:$/, ''), value: formatTime(job.last_run_at) }, { label: c.next.replace(/:$/, ''), value: formatTime(job.next_run_at) }, - { label: c.deliverLabel, value: c.deliveryLabels[deliver] ?? deliver } + { label: c.deliverLabel, value: c.deliveryLabels[deliver] ?? deliver }, + ...(modelOverride ? [{ label: c.modelLabel, value: modelOverride }] : []) ]} /> @@ -717,9 +742,21 @@ function CronEditorDialog({ const [schedule, setSchedule] = useState('') const [schedulePreset, setSchedulePreset] = useState('daily') const [deliver, setDeliver] = useState(DEFAULT_DELIVER) + // Per-job model override, encoded as `${providerSlug}:${model}` (split on the + // first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default. + const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + // Same catalog the chat model picker uses: configured providers and their + // actually-available models only. Script-only jobs never run an agent, so + // skip the fetch entirely for them. + const modelOptions = useQuery({ + queryKey: ['model-options', 'global'], + queryFn: () => requestModelOptions({}), + enabled: open && !scriptOnlyJob + }) + useEffect(() => { if (!open) { return @@ -730,6 +767,7 @@ function CronEditorDialog({ setSchedule(initial ? jobScheduleExpr(initial) : (SCHEDULE_OPTIONS[0].expr ?? '')) setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily') setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER) + setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE) setError(null) setSaving(false) }, [initial, open]) @@ -752,6 +790,19 @@ function CronEditorDialog({ const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c) + // Configured providers with at least one available model — mirrors the chat + // model picker's gate so only actually-selectable models are offered. + const modelProviders = (modelOptions.data?.providers ?? []).filter( + provider => provider.authenticated !== false && (provider.models ?? []).length > 0 + ) + + // A previously pinned model that has since left the catalog (provider + // removed / model retired) would render Radix's blank trigger. Keep the + // stored pin visible and re-selectable rather than silently dropping it. + const modelChoiceKnown = + modelChoice === MODEL_DEFAULT_VALUE || + modelProviders.some(provider => (provider.models ?? []).some(model => `${provider.slug}:${model}` === modelChoice)) + async function handleSubmit(event: React.FormEvent) { event.preventDefault() @@ -773,14 +824,22 @@ function CronEditorDialog({ return } + // Decode `${providerSlug}:${model}` — the model half may itself contain + // ':' (e.g. openrouter 'anthropic/claude-sonnet-4:beta'), so split once. + const overrideIndex = modelChoice === MODEL_DEFAULT_VALUE ? -1 : modelChoice.indexOf(':') + const overrideProvider = overrideIndex >= 0 ? modelChoice.slice(0, overrideIndex) : '' + const overrideModel = overrideIndex >= 0 ? modelChoice.slice(overrideIndex + 1) : '' + setSaving(true) setError(null) try { await onSave({ deliver, + model: overrideModel, name: name.trim(), prompt: prompt.trim(), + provider: overrideProvider, schedule: schedule.trim() }) } catch (err) { @@ -857,6 +916,38 @@ function CronEditorDialog({ + {!scriptOnlyJob && ( + + + + )} + {schedulePreset === 'custom' ? ( ) { + return +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + function SelectItem({ className, children, ...props }: React.ComponentProps) { return (