diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index b8feea241641..12ae305669ad 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -293,6 +293,13 @@ function CronJobSidebarRow({ })} {renderActionItem(kit, { icon: 'watch', key: 'manage', label: c.manage, onSelect: onManage })} + {renderActionItem(kit, { + icon: 'settings', + key: 'change-model', + label: c.changeModel, + onSelect: onManage + })} + {renderActionItem(kit, { icon: 'trash', key: 'delete', diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts index 9b800aea7053..46b8c6053553 100644 --- a/apps/desktop/src/app/cron/cron-job-model.ts +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -45,6 +45,14 @@ export interface CronEditorSaveValues { schedule: string } +/** Build the narrow update payload used by the detail-panel model picker. */ +export function cronModelUpdates(provider: string, model: string): CronJobUpdates { + return { + model: model.trim() || null, + provider: provider.trim() || null + } +} + export function parseCronDeliveryTargets(value: string): string[] { const targets = value .split(',') diff --git a/apps/desktop/src/app/cron/cron-model-switcher.test.ts b/apps/desktop/src/app/cron/cron-model-switcher.test.ts new file mode 100644 index 000000000000..e02ede661cbe --- /dev/null +++ b/apps/desktop/src/app/cron/cron-model-switcher.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { cronEditorUpdates, cronModelUpdates } from './cron-job-model' + +describe('cron model switcher — updateCronJob payload', () => { + it('pins a model and provider when both are provided', () => { + const updates = cronModelUpdates('anthropic', 'claude-sonnet-4') + + expect(updates.model).toBe('claude-sonnet-4') + expect(updates.provider).toBe('anthropic') + }) + + it('clears a previous pin when model is empty', () => { + const updates = cronModelUpdates('', '') + + expect(updates.model).toBeNull() + expect(updates.provider).toBeNull() + }) + + it('normalizes an empty provider when only a model is selected', () => { + const updates = cronModelUpdates(' ', ' gpt-4o ') + + expect(updates.model).toBe('gpt-4o') + expect(updates.provider).toBeNull() + }) +}) + +describe('cronEditorUpdates — model switcher integration', () => { + it('writes the model override for agent jobs (same path as the switcher)', () => { + const updates = cronEditorUpdates( + { + deliver: 'local', + model: 'deepseek-chat', + name: 'Daily summary', + prompt: 'go', + provider: 'deepseek', + schedule: '0 9 * * *' + }, + { scriptOnlyJob: false } + ) + + expect(updates.model).toBe('deepseek-chat') + expect(updates.provider).toBe('deepseek') + }) + + it('clears a previous pin when the switcher resets 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('uses a cheaper model for low-effort jobs and a powerful model for heavy jobs', () => { + // Low-effort summarising job + const cheapUpdates = cronEditorUpdates( + { + deliver: 'local', + model: 'deepseek-chat', + name: 'Feed summariser', + prompt: 'Summarise the feed', + provider: 'deepseek', + schedule: '0 * * * *' + }, + { scriptOnlyJob: false } + ) + + expect(cheapUpdates.model).toBe('deepseek-chat') + + // Heavy reasoning job + const heavyUpdates = cronEditorUpdates( + { + deliver: 'local', + model: 'claude-sonnet-4', + name: 'Weekly analysis', + prompt: 'Analyse the week', + provider: 'anthropic', + schedule: '0 9 * * 1' + }, + { scriptOnlyJob: false } + ) + + expect(heavyUpdates.model).toBe('claude-sonnet-4') + }) +}) diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index ee7f0af5aa2f..63470f901436 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ModelPickerDialog } from '@/components/model-picker' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -77,6 +78,7 @@ import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, init import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions' import { cronEditorUpdates, + cronModelUpdates, jobIsScriptOnly, parseCronDeliveryTargets, toggleCronDeliveryTarget, @@ -701,6 +703,19 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt busy={busyJobTokens.has(selectedJob.id) || triggeringJobKeys.has(`${profile}:${selectedJob.id}`)} c={c} job={selectedJob} + onModelChange={async (provider, model) => { + const { refreshError, stale } = await mutateAndRefreshCronJobs(profile, () => + updateCronJob(selectedJob.id, cronModelUpdates(provider, model)) + ) + + if (stale) { + return + } + + if (refreshError) { + notifyError(refreshError, c.failedLoad) + } + }} onOpenSession={onOpenSession} onPauseResume={() => void handlePauseResume(selectedJob)} onTrigger={() => void handleTrigger(selectedJob)} @@ -781,6 +796,7 @@ function CronJobDetail({ busy, c, job, + onModelChange, onOpenSession, onPauseResume, onTrigger @@ -788,6 +804,7 @@ function CronJobDetail({ busy: boolean c: Translations['cron'] job: CronJob + onModelChange?: (provider: string, model: string) => Promise onOpenSession?: (sessionId: string) => void onPauseResume: () => void onTrigger: () => void @@ -797,6 +814,9 @@ function CronJobDetail({ const deliver = jobDeliver(job) const prompt = jobPrompt(job) const modelOverride = jobModel(job) + const providerOverride = jobProvider(job) + const [modelPickerOpen, setModelPickerOpen] = useState(false) + const [modelBusy, setModelBusy] = useState(false) return ( @@ -813,6 +833,11 @@ function CronJobDetail({ {c.triggerNow} + {onModelChange && !jobIsScriptOnly(job) && ( + setModelPickerOpen(true)}> + {modelOverride || c.modelSwitcher} + + )} @@ -842,6 +867,28 @@ function CronJobDetail({ ) : null} + + {onModelChange && !jobIsScriptOnly(job) && ( + { + setModelBusy(true) + + try { + await onModelChange(provider, model) + notify({ kind: 'success', title: c.modelUpdated, message: truncate(jobTitle(job), 60) }) + } catch { + notifyError(new Error(c.failedUpdate), c.failedUpdate) + } finally { + setModelBusy(false) + } + }} + open={modelPickerOpen} + /> + )} ) } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 474effdc28ff..8d4b4f1c5b8e 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1604,7 +1604,10 @@ export const ar = defineLocale({ optional: 'اختياري', promptScheduleRequired: 'الرسالة والجدول مطلوبان', saveChanges: 'حفظ التغييرات', - createAction: 'إنشاء' + createAction: 'إنشاء', + modelSwitcher: 'النموذج', + modelUpdated: 'تم تحديث النموذج', + changeModel: 'تغيير النموذج' }, artifacts: { search: 'بحث', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index aa1114eae62c..597972e848a0 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2045,6 +2045,9 @@ export const en: Translations = { scriptOnlyEditHint: 'Script-only job (no AI prompt). Job id:', saveChanges: 'Save changes', createAction: 'Create cron', + modelSwitcher: 'Model', + modelUpdated: 'Model updated', + changeModel: 'Change model', tabs: { jobs: 'Jobs', blueprints: 'Blueprints' diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 721f2b0c03ff..96a1b8840fb4 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1757,6 +1757,9 @@ export const ja = defineLocale({ scriptOnlyEditHint: 'スクリプトのみのジョブ(AI プロンプトなし)。ジョブ ID:', saveChanges: '変更を保存', createAction: 'Cron を作成', + modelSwitcher: 'モデル', + modelUpdated: 'モデルを更新しました', + changeModel: 'モデルを変更', tabs: { jobs: 'ジョブ', blueprints: 'ブレーンプリント' diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 777e6df7a4b2..ce2a5abcbd90 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1718,6 +1718,9 @@ export interface Translations { scriptOnlyEditHint: string saveChanges: string createAction: string + modelSwitcher: string + modelUpdated: string + changeModel: string tabs: { jobs: string blueprints: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 24aac3e97807..fdd06d2b5da5 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1694,6 +1694,9 @@ export const zhHant = defineLocale({ scriptOnlyEditHint: '僅腳本任務(無 AI 提示詞)。任務 ID:', saveChanges: '儲存變更', createAction: '建立排程工作', + modelSwitcher: '模型', + modelUpdated: '模型已更新', + changeModel: '變更模型', tabs: { jobs: '工作', blueprints: '藍圖' diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index edb2d9c2d553..bd6b318c82d0 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2227,6 +2227,9 @@ export const zh: Translations = { scriptOnlyEditHint: '仅脚本任务(无 AI 提示词)。任务 ID:', saveChanges: '保存更改', createAction: '创建定时任务', + modelSwitcher: '模型', + modelUpdated: '模型已更新', + changeModel: '更改模型', tabs: { jobs: '任务', blueprints: '蓝图'