Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,13 @@ function CronJobSidebarRow({
})}
{renderActionItem(kit, { icon: 'watch', key: 'manage', label: c.manage, onSelect: onManage })}
<kit.Separator />
{renderActionItem(kit, {
icon: 'settings',
key: 'change-model',
label: c.changeModel,
onSelect: onManage
})}
<kit.Separator />
{renderActionItem(kit, {
icon: 'trash',
key: 'delete',
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/app/cron/cron-job-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(',')
Expand Down
87 changes: 87 additions & 0 deletions apps/desktop/src/app/cron/cron-model-switcher.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
47 changes: 47 additions & 0 deletions apps/desktop/src/app/cron/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -77,6 +78,7 @@ import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, init
import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions'
import {
cronEditorUpdates,
cronModelUpdates,
jobIsScriptOnly,
parseCronDeliveryTargets,
toggleCronDeliveryTarget,
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -781,13 +796,15 @@ function CronJobDetail({
busy,
c,
job,
onModelChange,
onOpenSession,
onPauseResume,
onTrigger
}: {
busy: boolean
c: Translations['cron']
job: CronJob
onModelChange?: (provider: string, model: string) => Promise<void>
onOpenSession?: (sessionId: string) => void
onPauseResume: () => void
onTrigger: () => void
Expand All @@ -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 (
<PanelDetail>
Expand All @@ -813,6 +833,11 @@ function CronJobDetail({
<PanelAction disabled={busy} icon="zap" onClick={onTrigger} primary>
{c.triggerNow}
</PanelAction>
{onModelChange && !jobIsScriptOnly(job) && (
<PanelAction disabled={busy || modelBusy} icon="settings" onClick={() => setModelPickerOpen(true)}>
{modelOverride || c.modelSwitcher}
</PanelAction>
)}
</div>
</div>

Expand Down Expand Up @@ -842,6 +867,28 @@ function CronJobDetail({
) : null}

<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />

{onModelChange && !jobIsScriptOnly(job) && (
<ModelPickerDialog
contentClassName="z-[100]"
currentModel={modelOverride}
currentProvider={providerOverride}
onOpenChange={setModelPickerOpen}
onSelect={async ({ provider, model }) => {
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}
/>
)}
</PanelDetail>
)
}
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1604,7 +1604,10 @@ export const ar = defineLocale({
optional: 'اختياري',
promptScheduleRequired: 'الرسالة والجدول مطلوبان',
saveChanges: 'حفظ التغييرات',
createAction: 'إنشاء'
createAction: 'إنشاء',
modelSwitcher: 'النموذج',
modelUpdated: 'تم تحديث النموذج',
changeModel: 'تغيير النموذج'
},
artifacts: {
search: 'بحث',
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1757,6 +1757,9 @@ export const ja = defineLocale({
scriptOnlyEditHint: 'スクリプトのみのジョブ(AI プロンプトなし)。ジョブ ID:',
saveChanges: '変更を保存',
createAction: 'Cron を作成',
modelSwitcher: 'モデル',
modelUpdated: 'モデルを更新しました',
changeModel: 'モデルを変更',
tabs: {
jobs: 'ジョブ',
blueprints: 'ブレーンプリント'
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1718,6 +1718,9 @@ export interface Translations {
scriptOnlyEditHint: string
saveChanges: string
createAction: string
modelSwitcher: string
modelUpdated: string
changeModel: string
tabs: {
jobs: string
blueprints: string
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,9 @@ export const zhHant = defineLocale({
scriptOnlyEditHint: '僅腳本任務(無 AI 提示詞)。任務 ID:',
saveChanges: '儲存變更',
createAction: '建立排程工作',
modelSwitcher: '模型',
modelUpdated: '模型已更新',
changeModel: '變更模型',
tabs: {
jobs: '工作',
blueprints: '藍圖'
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2227,6 +2227,9 @@ export const zh: Translations = {
scriptOnlyEditHint: '仅脚本任务(无 AI 提示词)。任务 ID:',
saveChanges: '保存更改',
createAction: '创建定时任务',
modelSwitcher: '模型',
modelUpdated: '模型已更新',
changeModel: '更改模型',
tabs: {
jobs: '任务',
blueprints: '蓝图'
Expand Down
Loading