{formatJobMetaSummary(job, formatDate, t, {
- showTarget: assistantWorkspaceMode,
resolveSessionLabel: sessionId => sessionLabelById.get(sessionId),
})}
diff --git a/src/web-ui/src/app/components/scheduled-jobs/cronJobCountsStore.test.ts b/src/web-ui/src/app/components/scheduled-jobs/cronJobCountsStore.test.ts
new file mode 100644
index 0000000000..54ce6ee96c
--- /dev/null
+++ b/src/web-ui/src/app/components/scheduled-jobs/cronJobCountsStore.test.ts
@@ -0,0 +1,88 @@
+/**
+ * The nav badge is the only scheduled-job signal visible without opening a
+ * panel, so what it counts has to match what the panel lists. A job the agent
+ * created with "enabled": false used to disappear from the badge entirely.
+ */
+
+import { describe, expect, it } from 'vitest';
+import type { CronJob } from '@/infrastructure/api';
+import { computeCronJobCounts } from './cronJobCountsStore';
+
+function sessionJob(id: string, sessionId: string, workspaceId: string, enabled = true): CronJob {
+ return {
+ id,
+ name: id,
+ schedule: { kind: 'every', everyMs: 60_000 },
+ payload: { text: 'hello' },
+ enabled,
+ target: {
+ kind: 'session',
+ sessionId,
+ workspace: { workspaceId, workspacePath: '/tmp/workspace' },
+ },
+ createdAtMs: 0,
+ configUpdatedAtMs: 0,
+ updatedAtMs: 0,
+ state: { consecutiveFailures: 0, coalescedRunCount: 0 },
+ };
+}
+
+function workspaceJob(id: string, workspaceId: string): CronJob {
+ return {
+ id,
+ name: id,
+ schedule: { kind: 'every', everyMs: 60_000 },
+ payload: { text: 'hello' },
+ enabled: true,
+ target: {
+ kind: 'workspace',
+ workspace: { workspaceId, workspacePath: '/tmp/workspace' },
+ launch: { agentType: 'Standard' },
+ },
+ createdAtMs: 0,
+ configUpdatedAtMs: 0,
+ updatedAtMs: 0,
+ state: { consecutiveFailures: 0, coalescedRunCount: 0 },
+ };
+}
+
+describe('computeCronJobCounts', () => {
+ it('counts a session job towards its workspace and its session', () => {
+ const counts = computeCronJobCounts([
+ sessionJob('cron_a', 'session_1', 'ws_1'),
+ sessionJob('cron_b', 'session_1', 'ws_1'),
+ sessionJob('cron_c', 'session_2', 'ws_1'),
+ ]);
+
+ expect(counts.byWorkspaceId.get('ws_1')).toBe(3);
+ expect(counts.bySessionId.get('session_1')).toBe(2);
+ expect(counts.bySessionId.get('session_2')).toBe(1);
+ });
+
+ it('counts a workspace job without attributing it to a session', () => {
+ const counts = computeCronJobCounts([workspaceJob('cron_d', 'ws_1')]);
+
+ expect(counts.byWorkspaceId.get('ws_1')).toBe(1);
+ expect(counts.bySessionId.size).toBe(0);
+ });
+
+ it('keeps disabled jobs counted, matching the panel list', () => {
+ const counts = computeCronJobCounts([
+ sessionJob('cron_e', 'session_1', 'ws_1', false),
+ sessionJob('cron_f', 'session_1', 'ws_1'),
+ ]);
+
+ expect(counts.byWorkspaceId.get('ws_1')).toBe(2);
+ expect(counts.bySessionId.get('session_1')).toBe(2);
+ });
+
+ it('ignores jobs whose workspace has no id', () => {
+ const job = sessionJob('cron_g', 'session_1', 'ws_1');
+ job.target.workspace = { workspacePath: '/tmp/workspace' };
+
+ const counts = computeCronJobCounts([job]);
+
+ expect(counts.byWorkspaceId.size).toBe(0);
+ expect(counts.bySessionId.get('session_1')).toBe(1);
+ });
+});
diff --git a/src/web-ui/src/app/components/scheduled-jobs/cronJobCountsStore.ts b/src/web-ui/src/app/components/scheduled-jobs/cronJobCountsStore.ts
new file mode 100644
index 0000000000..1d4c0eecf2
--- /dev/null
+++ b/src/web-ui/src/app/components/scheduled-jobs/cronJobCountsStore.ts
@@ -0,0 +1,131 @@
+/**
+ * Scheduled-job counts for navigation badges.
+ *
+ * This module owns the single backend subscription for `cron://jobs-changed`
+ * (agent Cron tool calls, the scheduler, session cleanup). It re-reads the job
+ * list, publishes per-workspace / per-session counts, and mirrors the hint onto
+ * the existing browser event so every mounted scheduled-job view also reloads.
+ * Nav rows read the counts through `useSyncExternalStore` without each row
+ * owning a Tauri listener.
+ */
+
+import { cronAPI } from '@/infrastructure/api';
+import { notifyScheduledJobsChanged, SCHEDULED_JOBS_CHANGED_EVENT } from './scheduledJobDraft';
+import { createLogger } from '@/shared/utils/logger';
+import type { CronJob } from '@/infrastructure/api';
+
+const log = createLogger('CronJobCountsStore');
+
+export interface CronJobCountsSnapshot {
+ /** Count per workspace id. */
+ byWorkspaceId: ReadonlyMap
;
+ /** Count per session id (session-targeted jobs only). */
+ bySessionId: ReadonlyMap;
+}
+
+const EMPTY_SNAPSHOT: CronJobCountsSnapshot = {
+ byWorkspaceId: new Map(),
+ bySessionId: new Map(),
+};
+
+let snapshot: CronJobCountsSnapshot = EMPTY_SNAPSHOT;
+const listeners = new Set<() => void>();
+let loadInFlight = false;
+let listenerRegistered = false;
+
+function publish(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function countsEqual(
+ left: CronJobCountsSnapshot,
+ right: CronJobCountsSnapshot,
+): boolean {
+ if (left.byWorkspaceId.size !== right.byWorkspaceId.size) return false;
+ if (left.bySessionId.size !== right.bySessionId.size) return false;
+ for (const [key, value] of left.byWorkspaceId) {
+ if (right.byWorkspaceId.get(key) !== value) return false;
+ }
+ for (const [key, value] of left.bySessionId) {
+ if (right.bySessionId.get(key) !== value) return false;
+ }
+ return true;
+}
+
+/**
+ * Counts per workspace and per session. Mirrors what the scheduled-jobs views
+ * list, disabled jobs included: a paused job the agent just created must still
+ * be visible in the nav.
+ */
+export function computeCronJobCounts(jobs: CronJob[]): CronJobCountsSnapshot {
+ const byWorkspaceId = new Map();
+ const bySessionId = new Map();
+ for (const job of jobs) {
+ const workspaceId = job.target.workspace.workspaceId;
+ if (workspaceId) {
+ byWorkspaceId.set(workspaceId, (byWorkspaceId.get(workspaceId) ?? 0) + 1);
+ }
+ if (job.target.kind === 'session') {
+ bySessionId.set(job.target.sessionId, (bySessionId.get(job.target.sessionId) ?? 0) + 1);
+ }
+ }
+ return { byWorkspaceId, bySessionId };
+}
+
+function applyJobs(jobs: CronJob[]): void {
+ const next = computeCronJobCounts(jobs);
+ if (countsEqual(snapshot, next)) return;
+ snapshot = next;
+ publish();
+}
+
+async function reload(): Promise {
+ if (loadInFlight) return;
+ loadInFlight = true;
+ try {
+ const jobs = await cronAPI.listJobs({});
+ applyJobs(jobs);
+ } catch (error) {
+ // Badges are a hint; keep the previous counts and let the next change
+ // signal retry.
+ log.warn('Failed to load scheduled job counts', { error });
+ } finally {
+ loadInFlight = false;
+ }
+}
+
+/** Subscribe the badge store; idempotent and safe to call from an effect. */
+export function ensureCronJobCountsListener(): void {
+ if (listenerRegistered) return;
+ listenerRegistered = true;
+
+ // Local editor saves, plus the backend hints mirrored below.
+ window.addEventListener(SCHEDULED_JOBS_CHANGED_EVENT, () => {
+ void reload();
+ });
+
+ try {
+ // Backend hints are only a signal; the payload carries no job state. The
+ // source id matches no view instance, so every mounted view reloads.
+ cronAPI.onJobsChanged(() => {
+ notifyScheduledJobsChanged('backend');
+ });
+ } catch (error) {
+ // Badges still track local edits and the initial load below.
+ log.error('Failed to subscribe scheduled job changes', { error });
+ }
+
+ void reload();
+}
+
+/** useSyncExternalStore snapshot: stable between changes. */
+export function getCronJobCountsSnapshot(): CronJobCountsSnapshot {
+ return snapshot;
+}
+
+export function subscribeCronJobCounts(notify: () => void): () => void {
+ listeners.add(notify);
+ return () => { listeners.delete(notify); };
+}
diff --git a/src/web-ui/src/app/scenes/todos/TodosScene.tsx b/src/web-ui/src/app/scenes/todos/TodosScene.tsx
index 3a5efc7869..096f8361b6 100644
--- a/src/web-ui/src/app/scenes/todos/TodosScene.tsx
+++ b/src/web-ui/src/app/scenes/todos/TodosScene.tsx
@@ -173,7 +173,9 @@ const TodosScene: React.FC = () => {
useEffect(() => { void loadJobs(); }, [loadJobs]);
// Another view (a workspace editor, or the agent's own scheduling tool)
- // changed a job — reload so the panel is never stale.
+ // changed a job — reload so the panel is never stale. Backend-originated
+ // changes arrive through the cron://jobs-changed subscription owned by
+ // cronJobCountsStore, which mirrors them onto this same browser event.
useEffect(() => {
const handleChanged = (event: Event) => {
const sourceId = (event as CustomEvent<{ sourceId?: string }>).detail?.sourceId;
diff --git a/src/web-ui/src/flow_chat/tool-cards/CronToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CronToolCard.tsx
new file mode 100644
index 0000000000..679897dbfc
--- /dev/null
+++ b/src/web-ui/src/flow_chat/tool-cards/CronToolCard.tsx
@@ -0,0 +1,273 @@
+import React, { useMemo, useState } from 'react';
+import { CronToolCard as CronToolCardView } from '@openbitfun/ui/flow-chat';
+import { useI18n } from '@/infrastructure/i18n';
+import type { CronSchedule } from '@/infrastructure/api';
+import { formatDateTime, formatScheduleSummary } from '@/app/scenes/todos/todoPresentation';
+import type { ToolCardProps } from '../types/flow-chat';
+import { useToolCardHeightContract } from './useToolCardHeightContract';
+
+type CronAction = 'get_time' | 'list' | 'add' | 'update' | 'remove' | 'run';
+
+/**
+ * Schedule shape emitted by the backend `Cron` tool.
+ *
+ * It intentionally differs from the `CronJob` wire shape used by the scheduled
+ * job APIs: the tool speaks seconds and ISO strings so a model can write them,
+ * while the service stores milliseconds.
+ */
+interface CronToolSchedule {
+ kind?: 'at' | 'every' | 'cron';
+ at?: string;
+ every?: number;
+ anchor?: string;
+ expr?: string;
+ tz?: string;
+}
+
+interface CronToolJobState {
+ nextRunAtMs?: number | null;
+ pendingTriggerAtMs?: number | null;
+ retryAtMs?: number | null;
+ lastRunStatus?: string | null;
+ lastError?: string | null;
+}
+
+interface CronToolJob {
+ id?: string;
+ name?: string;
+ schedule?: CronToolSchedule;
+ payload?: string;
+ enabled?: boolean;
+ sessionId?: string;
+ workspacePath?: string;
+ state?: CronToolJobState;
+}
+
+interface CronToolJobInput {
+ name?: string;
+ schedule?: CronToolSchedule;
+ payload?: string;
+ enabled?: boolean;
+}
+
+interface CronToolInput {
+ action?: CronAction;
+ session_id?: string;
+ job_id?: string;
+ job?: CronToolJobInput;
+ patch?: CronToolJobInput;
+}
+
+interface CronToolResult {
+ success?: boolean;
+ action?: CronAction;
+ now?: string;
+ workspace?: string;
+ session_id?: string;
+ job_id?: string;
+ count?: number;
+ deleted?: boolean;
+ job?: CronToolJob;
+ jobs?: CronToolJob[];
+}
+
+function parseData(value: unknown): T | null {
+ if (!value) return null;
+
+ try {
+ return typeof value === 'string' ? JSON.parse(value) as T : value as T;
+ } catch {
+ return null;
+ }
+}
+
+/** Convert the tool's seconds/ISO schedule into the service wire shape. */
+function toCronSchedule(schedule: CronToolSchedule | undefined): CronSchedule | null {
+ if (!schedule) return null;
+
+ if (schedule.kind === 'at') {
+ return schedule.at ? { kind: 'at', at: schedule.at } : null;
+ }
+ if (schedule.kind === 'every') {
+ if (!Number.isFinite(schedule.every)) return null;
+ const anchorMs = schedule.anchor ? new Date(schedule.anchor).getTime() : null;
+ return {
+ kind: 'every',
+ everyMs: Number(schedule.every) * 1000,
+ anchorMs: Number.isFinite(anchorMs) ? anchorMs : null,
+ };
+ }
+ if (schedule.kind === 'cron') {
+ return schedule.expr ? { kind: 'cron', expr: schedule.expr, tz: schedule.tz ?? null } : null;
+ }
+ return null;
+}
+
+/** Timestamp the scheduler will actually act on next, mirroring getNextExecutionAtMs. */
+function nextExecutionAtMs(state: CronToolJobState | undefined): number | null {
+ if (!state) return null;
+ return state.pendingTriggerAtMs ?? state.retryAtMs ?? state.nextRunAtMs ?? null;
+}
+
+export const CronToolCard: React.FC = React.memo(({ toolItem }) => {
+ const { t, formatDate } = useI18n('flow-chat');
+ const { t: tTodos } = useI18n('scenes/todos');
+ const { toolCall, toolResult, status } = toolItem;
+ const [isExpanded, setIsExpanded] = useState(false);
+ const toolId = toolItem.id ?? toolCall?.id;
+ const { cardRootRef, applyExpandedState } = useToolCardHeightContract({
+ toolId,
+ toolName: toolItem.toolName,
+ });
+
+ const inputData = useMemo(
+ () => parseData(toolCall?.input) ?? {},
+ [toolCall?.input]
+ );
+
+ const resultData = useMemo(
+ () => parseData(toolResult?.result),
+ [toolResult?.result]
+ );
+
+ const action = resultData?.action ?? inputData.action ?? 'list';
+ const job = resultData?.job;
+ const jobs = useMemo(
+ () => (Array.isArray(resultData?.jobs) ? resultData.jobs : []),
+ [resultData?.jobs]
+ );
+ const jobId = job?.id ?? resultData?.job_id ?? inputData.job_id;
+ const jobName = job?.name
+ ?? inputData.job?.name
+ ?? inputData.patch?.name
+ ?? jobId
+ ?? t('toolCards.cron.unknownJob');
+ const payload = job?.payload ?? inputData.job?.payload ?? inputData.patch?.payload;
+ const workspace = job?.workspacePath ?? resultData?.workspace;
+ const sessionId = job?.sessionId ?? resultData?.session_id ?? inputData.session_id;
+ const jobCount = resultData?.count ?? jobs.length;
+
+ const describeSchedule = useMemo(() => (schedule: CronToolSchedule | undefined): string | null => {
+ const converted = toCronSchedule(schedule);
+ return converted ? formatScheduleSummary(converted, tTodos, formatDate) : null;
+ }, [formatDate, tTodos]);
+
+ const scheduleText = describeSchedule(job?.schedule ?? inputData.job?.schedule ?? inputData.patch?.schedule);
+ const nextRunMs = nextExecutionAtMs(job?.state);
+ const nextRunText = nextRunMs != null ? formatDateTime(nextRunMs, formatDate) : null;
+ const enabled = job?.enabled ?? inputData.job?.enabled ?? inputData.patch?.enabled;
+ const lastRunStatus = job?.state?.lastRunStatus ?? null;
+ const lastError = job?.state?.lastError ?? null;
+
+ const fields = useMemo(() => {
+ if (action === 'get_time') {
+ return resultData?.now
+ ? [{ label: `${t('toolCards.cron.currentTime')}:`, value: resultData.now }]
+ : [];
+ }
+
+ if (action === 'list') {
+ return jobs.map((item) => ({
+ label: `${item.name ?? item.id ?? t('toolCards.cron.unknownJob')}:`,
+ value: describeSchedule(item.schedule) ?? item.id ?? '-',
+ }));
+ }
+
+ return [
+ scheduleText ? { label: `${t('toolCards.cron.schedule')}:`, value: scheduleText } : null,
+ nextRunText ? { label: `${t('toolCards.cron.nextRun')}:`, value: nextRunText } : null,
+ enabled !== undefined ? {
+ label: `${t('toolCards.cron.enabled')}:`,
+ value: enabled ? t('toolCards.cron.enabledYes') : t('toolCards.cron.enabledNo'),
+ } : null,
+ lastRunStatus ? { label: `${t('toolCards.cron.lastRunStatus')}:`, value: lastRunStatus } : null,
+ lastError ? { label: `${t('toolCards.cron.lastError')}:`, value: lastError } : null,
+ jobId ? { label: `${t('toolCards.cron.jobId')}:`, value: jobId } : null,
+ sessionId ? { label: `${t('toolCards.cron.sessionId')}:`, value: sessionId } : null,
+ workspace ? { label: `${t('toolCards.cron.workspace')}:`, value: workspace } : null,
+ ].filter((field): field is NonNullable => Boolean(field));
+ }, [
+ action,
+ describeSchedule,
+ enabled,
+ jobId,
+ jobs,
+ lastError,
+ lastRunStatus,
+ nextRunText,
+ resultData?.now,
+ scheduleText,
+ sessionId,
+ t,
+ workspace,
+ ]);
+
+ const emptyState = action === 'list' && jobs.length === 0 && status === 'completed'
+ ? t('toolCards.cron.noJobs')
+ : undefined;
+ const message = action === 'add' || action === 'update' ? payload : undefined;
+ const hasDetails = Boolean(fields.length || emptyState || message || toolResult?.error);
+
+ const renderSummary = () => {
+ if (status === 'error' || status === 'cancelled') {
+ return t('toolCards.cron.actionFailed');
+ }
+
+ const running = status === 'running' || status === 'streaming';
+
+ switch (action) {
+ case 'get_time':
+ if (status === 'completed') {
+ return t('toolCards.cron.gotTime', { time: resultData?.now ?? '' });
+ }
+ return running ? t('toolCards.cron.gettingTime') : t('toolCards.cron.preparingGetTime');
+ case 'add':
+ if (status === 'completed') return t('toolCards.cron.addedJob', { name: jobName });
+ return running
+ ? t('toolCards.cron.addingJob', { name: jobName })
+ : t('toolCards.cron.preparingAdd', { name: jobName });
+ case 'update':
+ if (status === 'completed') return t('toolCards.cron.updatedJob', { name: jobName });
+ return running
+ ? t('toolCards.cron.updatingJob', { name: jobName })
+ : t('toolCards.cron.preparingUpdate', { name: jobName });
+ case 'remove':
+ if (status === 'completed') {
+ return resultData?.deleted === false
+ ? t('toolCards.cron.jobNotFound', { name: jobName })
+ : t('toolCards.cron.removedJob', { name: jobName });
+ }
+ return running
+ ? t('toolCards.cron.removingJob', { name: jobName })
+ : t('toolCards.cron.preparingRemove', { name: jobName });
+ case 'run':
+ if (status === 'completed') return t('toolCards.cron.ranJob', { name: jobName });
+ return running
+ ? t('toolCards.cron.runningJob', { name: jobName })
+ : t('toolCards.cron.preparingRun', { name: jobName });
+ case 'list':
+ default:
+ if (status === 'completed') return t('toolCards.cron.listedJobs', { count: jobCount });
+ return running ? t('toolCards.cron.listingJobs') : t('toolCards.cron.preparingList');
+ }
+ };
+
+ return (
+
+ applyExpandedState(isExpanded, !isExpanded, setIsExpanded)
+ : undefined}
+ action={`${t('toolCards.cron.title')}:`}
+ summary={renderSummary()}
+ fields={fields}
+ message={message}
+ messageLabel={message ? t('toolCards.cron.payload') : undefined}
+ emptyState={emptyState}
+ error={toolResult?.error}
+ />
+
+ );
+});
diff --git a/src/web-ui/src/flow_chat/tool-cards/index.test.ts b/src/web-ui/src/flow_chat/tool-cards/index.test.ts
index 1533263852..9109271777 100644
--- a/src/web-ui/src/flow_chat/tool-cards/index.test.ts
+++ b/src/web-ui/src/flow_chat/tool-cards/index.test.ts
@@ -61,6 +61,7 @@ describe('tool card registry', () => {
'GetFileDiff',
'SessionControl',
'SessionMessage',
+ 'Cron',
'RunCode',
'ExecCommand',
'WriteStdin',
diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts
index 7a4bc3fa31..cb74472b77 100644
--- a/src/web-ui/src/flow_chat/tool-cards/index.ts
+++ b/src/web-ui/src/flow_chat/tool-cards/index.ts
@@ -57,6 +57,7 @@ import { ReviewSessionSummaryCard } from './ReviewSessionSummaryCard';
import { SessionControlToolCard } from './SessionControlToolCard';
import { SessionMessageToolCard } from './SessionMessageToolCard';
import { ComputerUseToolCard } from './ComputerUseToolCard';
+import { CronToolCard } from './CronToolCard';
import { ViewImageToolCard } from './ViewImageToolCard';
import { OpenBitFunControlToolCard } from './OpenBitFunControlToolCard';
@@ -104,6 +105,9 @@ export const STANDARD_TOOL_CARD_ADAPTERS = {
'SessionControl': SessionControlToolCard,
'SessionMessage': SessionMessageToolCard,
+ // Scheduled jobs
+ 'Cron': CronToolCard,
+
// Code-mode agents: one program per step instead of one card per action
'RunCode': RunCodeToolCard,
diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts
index 26c770ed12..bac786b56e 100644
--- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts
+++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts
@@ -28,6 +28,7 @@ const AMBIENT_TOOL_CARD_NAMES = new Set([
'TerminalControl',
'SessionControl',
'SessionMessage',
+ 'Cron',
'RunCode',
'ComputerUse',
'view_image',
@@ -351,6 +352,17 @@ const TOOL_CARD_DEFINITIONS: Record = {
primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.assistantAction
},
+ 'Cron': {
+ toolName: 'Cron',
+ displayName: 'Scheduled Job',
+ icon: 'CRON',
+ requiresConfirmation: false,
+ resultDisplayType: 'summary',
+ description: 'Create, update, list, or run scheduled jobs',
+ displayMode: 'compact',
+ primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.assistantAction
+ },
+
// Code-mode agents (e.g. DeepSeek Harness's PTC preset) answer a step by
// writing one program instead of calling one tool per action.
'RunCode': {
@@ -644,6 +656,7 @@ export const DEDICATED_TOOL_CARD_NAMES = new Set([
'CreatePlan',
'SessionControl',
'SessionMessage',
+ 'Cron',
'RunCode',
'ExecCommand',
'WriteStdin',
diff --git a/src/web-ui/src/infrastructure/api/service-api/CronAPI.ts b/src/web-ui/src/infrastructure/api/service-api/CronAPI.ts
index c9ac857d77..30ef65f993 100644
--- a/src/web-ui/src/infrastructure/api/service-api/CronAPI.ts
+++ b/src/web-ui/src/infrastructure/api/service-api/CronAPI.ts
@@ -107,6 +107,19 @@ export interface UpdateCronJobRequest {
}
export class CronAPI {
+ /**
+ * Register a listener for backend scheduled-job change hints. The backend
+ * emits these when the job set or a job's run state changes (agent tool
+ * calls, scheduler runs, session cleanup); the payload is only a hint, so
+ * consumers re-read the job list. Returns an unlisten function.
+ */
+ onJobsChanged(callback: (payload: { reason?: string; jobId?: string | null }) => void): () => void {
+ return api.listen<{ reason?: string; jobId?: string | null }>(
+ 'cron://jobs-changed',
+ callback,
+ );
+ }
+
async notifyHostReady(): Promise {
if (!isTauriRuntime()) {
return;
diff --git a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.test.ts b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.test.ts
index 4442214eaf..dcb6c84c05 100644
--- a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.test.ts
+++ b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.test.ts
@@ -24,6 +24,7 @@ describe('isSurfaceScopedEvent', () => {
expect(isSurfaceScopedEvent('permission://event')).toBe(true);
expect(isSurfaceScopedEvent('session_title_generated')).toBe(true);
expect(isSurfaceScopedEvent('workspace-catalog-changed')).toBe(true);
+ expect(isSurfaceScopedEvent('cron://jobs-changed')).toBe(true);
});
it('leaves control-plane events unscoped so they always pass', () => {
diff --git a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts
index 5cb254fb01..3f1213d428 100644
--- a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts
+++ b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts
@@ -43,6 +43,7 @@ const SURFACE_SCOPED_EVENTS = new Set([
'ai://model-catalog-updated',
'session_title_generated',
'workspace-catalog-changed',
+ 'cron://jobs-changed',
]);
const SURFACE_SCOPED_PREFIXES = ['agentic://', 'backend-event-'];
diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json
index 545c877420..fe035b5838 100644
--- a/src/web-ui/src/locales/en-US/common.json
+++ b/src/web-ui/src/locales/en-US/common.json
@@ -456,6 +456,7 @@
"title": "Scheduled Jobs",
"open": "Scheduled jobs",
"shortLabel": "Schedule",
+ "badgeTooltip": "{{count}} scheduled job(s)",
"listTitle": "Jobs",
"assistantFallback": "Current assistant",
"loading": "Loading scheduled jobs...",
@@ -504,8 +505,7 @@
}
},
"targets": {
- "session": "Session",
- "newSession": "New session"
+ "session": "Session"
},
"status": {
"idle": "Idle",
diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json
index 904be6727f..ed94122d7b 100644
--- a/src/web-ui/src/locales/en-US/flow-chat.json
+++ b/src/web-ui/src/locales/en-US/flow-chat.json
@@ -2220,6 +2220,43 @@
"agentType": "Agent type",
"message": "Message"
},
+ "cron": {
+ "title": "Scheduled job",
+ "unknownJob": "Unknown job",
+ "preparingGetTime": "Preparing to read the current time",
+ "gettingTime": "Reading the current time",
+ "gotTime": "Current time {{time}}",
+ "preparingList": "Preparing to list scheduled jobs",
+ "listingJobs": "Listing scheduled jobs",
+ "listedJobs": "Found {{count}} scheduled jobs",
+ "noJobs": "No scheduled jobs found",
+ "preparingAdd": "Preparing to create {{name}}",
+ "addingJob": "Creating {{name}}",
+ "addedJob": "Created {{name}}",
+ "preparingUpdate": "Preparing to update {{name}}",
+ "updatingJob": "Updating {{name}}",
+ "updatedJob": "Updated {{name}}",
+ "preparingRemove": "Preparing to delete {{name}}",
+ "removingJob": "Deleting {{name}}",
+ "removedJob": "Deleted {{name}}",
+ "jobNotFound": "No scheduled job found for {{name}}",
+ "preparingRun": "Preparing to run {{name}}",
+ "runningJob": "Triggering {{name}}",
+ "ranJob": "Triggered {{name}}",
+ "actionFailed": "Scheduled job operation failed",
+ "schedule": "Schedule",
+ "nextRun": "Next run",
+ "enabled": "Enabled",
+ "enabledYes": "Yes",
+ "enabledNo": "No",
+ "lastRunStatus": "Last run status",
+ "lastError": "Last error",
+ "jobId": "Job ID",
+ "sessionId": "Session ID",
+ "workspace": "Workspace path",
+ "payload": "Payload",
+ "currentTime": "Current time"
+ },
"contextCompression": {
"beforeUserMessage": "Before user message",
"toolBatchComplete": "Tool batch complete",
diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json
index 219195a1b7..b8ed7da5c0 100644
--- a/src/web-ui/src/locales/zh-CN/common.json
+++ b/src/web-ui/src/locales/zh-CN/common.json
@@ -456,6 +456,7 @@
"title": "定时任务",
"open": "定时任务",
"shortLabel": "定时任务",
+ "badgeTooltip": "{{count}} 个定时任务",
"listTitle": "任务列表",
"assistantFallback": "当前助理",
"loading": "正在加载定时任务...",
@@ -504,8 +505,7 @@
}
},
"targets": {
- "session": "会话",
- "newSession": "新会话"
+ "session": "会话"
},
"status": {
"idle": "待命",
diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json
index 682f39e8e5..3ce8e9f6d6 100644
--- a/src/web-ui/src/locales/zh-CN/flow-chat.json
+++ b/src/web-ui/src/locales/zh-CN/flow-chat.json
@@ -2220,6 +2220,43 @@
"agentType": "智能体类型",
"message": "消息内容"
},
+ "cron": {
+ "title": "定时任务",
+ "unknownJob": "未知任务",
+ "preparingGetTime": "准备读取当前时间",
+ "gettingTime": "正在读取当前时间",
+ "gotTime": "当前时间 {{time}}",
+ "preparingList": "准备列出定时任务",
+ "listingJobs": "正在列出定时任务",
+ "listedJobs": "找到 {{count}} 个定时任务",
+ "noJobs": "未找到定时任务",
+ "preparingAdd": "准备创建 {{name}}",
+ "addingJob": "正在创建 {{name}}",
+ "addedJob": "已创建 {{name}}",
+ "preparingUpdate": "准备更新 {{name}}",
+ "updatingJob": "正在更新 {{name}}",
+ "updatedJob": "已更新 {{name}}",
+ "preparingRemove": "准备删除 {{name}}",
+ "removingJob": "正在删除 {{name}}",
+ "removedJob": "已删除 {{name}}",
+ "jobNotFound": "未找到定时任务 {{name}}",
+ "preparingRun": "准备运行 {{name}}",
+ "runningJob": "正在触发 {{name}}",
+ "ranJob": "已触发 {{name}}",
+ "actionFailed": "定时任务操作失败",
+ "schedule": "调度规则",
+ "nextRun": "下次执行",
+ "enabled": "已启用",
+ "enabledYes": "是",
+ "enabledNo": "否",
+ "lastRunStatus": "上次执行状态",
+ "lastError": "上次错误",
+ "jobId": "任务 ID",
+ "sessionId": "会话 ID",
+ "workspace": "工作空间",
+ "payload": "任务内容",
+ "currentTime": "当前时间"
+ },
"contextCompression": {
"beforeUserMessage": "用户消息前",
"toolBatchComplete": "工具批次完成",
diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json
index b47062ee67..db9f557b91 100644
--- a/src/web-ui/src/locales/zh-TW/common.json
+++ b/src/web-ui/src/locales/zh-TW/common.json
@@ -456,6 +456,7 @@
"title": "定時任務",
"open": "定時任務",
"shortLabel": "定時任務",
+ "badgeTooltip": "{{count}} 個定時任務",
"listTitle": "任務列表",
"assistantFallback": "目前助理",
"loading": "正在載入定時任務...",
@@ -504,8 +505,7 @@
}
},
"targets": {
- "session": "會話",
- "newSession": "新會話"
+ "session": "會話"
},
"status": {
"idle": "待命",
diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json
index d3c9b64561..cbe3e5f1ce 100644
--- a/src/web-ui/src/locales/zh-TW/flow-chat.json
+++ b/src/web-ui/src/locales/zh-TW/flow-chat.json
@@ -2220,6 +2220,43 @@
"agentType": "智能體類型",
"message": "消息內容"
},
+ "cron": {
+ "title": "定時任務",
+ "unknownJob": "未知任務",
+ "preparingGetTime": "準備讀取當前時間",
+ "gettingTime": "正在讀取當前時間",
+ "gotTime": "當前時間 {{time}}",
+ "preparingList": "準備列出定時任務",
+ "listingJobs": "正在列出定時任務",
+ "listedJobs": "找到 {{count}} 個定時任務",
+ "noJobs": "未找到定時任務",
+ "preparingAdd": "準備創建 {{name}}",
+ "addingJob": "正在創建 {{name}}",
+ "addedJob": "已創建 {{name}}",
+ "preparingUpdate": "準備更新 {{name}}",
+ "updatingJob": "正在更新 {{name}}",
+ "updatedJob": "已更新 {{name}}",
+ "preparingRemove": "準備刪除 {{name}}",
+ "removingJob": "正在刪除 {{name}}",
+ "removedJob": "已刪除 {{name}}",
+ "jobNotFound": "未找到定時任務 {{name}}",
+ "preparingRun": "準備執行 {{name}}",
+ "runningJob": "正在觸發 {{name}}",
+ "ranJob": "已觸發 {{name}}",
+ "actionFailed": "定時任務操作失敗",
+ "schedule": "調度規則",
+ "nextRun": "下次執行",
+ "enabled": "已啟用",
+ "enabledYes": "是",
+ "enabledNo": "否",
+ "lastRunStatus": "上次執行狀態",
+ "lastError": "上次錯誤",
+ "jobId": "任務 ID",
+ "sessionId": "會話 ID",
+ "workspace": "工作空間",
+ "payload": "任務內容",
+ "currentTime": "當前時間"
+ },
"contextCompression": {
"beforeUserMessage": "用戶消息前",
"toolBatchComplete": "工具批次完成",