feat(cron): complete smart scheduling flow from chat + polish cron management UI (#2943)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YellowSnnowmann
2026-05-30 08:25:50 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent dd1439e2dc
commit fb8068e92e
33 changed files with 2755 additions and 104 deletions
@@ -0,0 +1,359 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// ── Mock i18n ───────────────────────────────────────────────────────────
// Stable identity so useT-derived useCallback deps don't churn (would
// otherwise re-fire loadCoreCronJobsOnly's useEffect every render and
// stomp coreError back to null mid-test).
const stableI18n = { t: (k: string) => k };
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => stableI18n }));
// ── Mock navigation ─────────────────────────────────────────────────────
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
// ── Mock SettingsHeader ─────────────────────────────────────────────────
vi.mock('../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
}));
// ── Mock CronJobFormModal ───────────────────────────────────────────────
// The modal is independently tested; here we just verify it opens/closes
// and the callbacks fire.
const mockModalOnCreate = vi.fn();
const mockModalOnUpdate = vi.fn();
vi.mock('./cron/CronJobFormModal', () => ({
default: ({
open,
mode,
job,
onClose,
onCreate,
onUpdate,
}: {
open: boolean;
mode: string;
job?: { id: string };
onClose: () => void;
onCreate: (p: unknown) => Promise<void>;
onUpdate: (id: string, p: unknown) => Promise<void>;
}) => {
if (!open) return null;
// Capture the callbacks on each render so tests can invoke them
mockModalOnCreate.mockImplementation(onCreate);
mockModalOnUpdate.mockImplementation(onUpdate);
return (
<div data-testid={`cron-form-modal-${mode}`}>
<span data-testid="modal-job-id">{job?.id ?? ''}</span>
<button data-testid="modal-close" onClick={onClose}>
close
</button>
</div>
);
},
}));
// ── Mock tauriCommands ──────────────────────────────────────────────────
const cronAddMock = vi.fn();
const cronListMock = vi.fn();
const cronUpdateMock = vi.fn();
const cronRemoveMock = vi.fn();
const cronRunMock = vi.fn();
const cronRunsMock = vi.fn();
vi.mock('../../../utils/tauriCommands', () => ({
openhumanCronAdd: (...args: unknown[]) => cronAddMock(...args),
openhumanCronList: () => cronListMock(),
openhumanCronUpdate: (...args: unknown[]) => cronUpdateMock(...args),
openhumanCronRemove: (...args: unknown[]) => cronRemoveMock(...args),
openhumanCronRun: (...args: unknown[]) => cronRunMock(...args),
openhumanCronRuns: (...args: unknown[]) => cronRunsMock(...args),
}));
// ── Helpers ─────────────────────────────────────────────────────────────
const sampleJob = {
id: 'job-1',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
name: 'Daily Briefing',
job_type: 'agent',
session_target: 'isolated',
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-05-01T00:00:00.000Z',
next_run: '2026-06-01T09:00:00.000Z',
prompt: 'Summarise the news',
};
async function importPanel() {
vi.resetModules();
const mod = await import('./CronJobsPanel');
return mod.default;
}
describe('<CronJobsPanel />', () => {
beforeEach(() => {
[
cronListMock,
cronAddMock,
cronUpdateMock,
cronRemoveMock,
cronRunMock,
cronRunsMock,
mockModalOnCreate,
mockModalOnUpdate,
].forEach(fn => fn.mockReset());
cronListMock.mockResolvedValue({ result: [sampleJob] });
cronAddMock.mockResolvedValue({ result: { ...sampleJob, id: 'job-new' } });
cronUpdateMock.mockResolvedValue({ result: sampleJob });
cronRemoveMock.mockResolvedValue({ result: { job_id: 'job-1', removed: true } });
cronRunMock.mockResolvedValue({ result: {} });
cronRunsMock.mockResolvedValue({ result: [] });
});
it('renders the "+ New Scheduled Job" button', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
expect(screen.getByTestId('cron-new-job')).toBeInTheDocument();
});
it('clicking "+ New Scheduled Job" opens create modal', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
fireEvent.click(screen.getByTestId('cron-new-job'));
expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument();
});
it('onCreate triggers openhumanCronAdd and refresh', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
// Open create modal
fireEvent.click(screen.getByTestId('cron-new-job'));
await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument());
// Invoke create via captured mock callback
const params = {
schedule: { kind: 'cron', expr: '0 9 * * *' },
job_type: 'agent',
prompt: 'hi',
};
await mockModalOnCreate(params);
await waitFor(() => expect(cronAddMock).toHaveBeenCalledWith(params));
// List should be refreshed (at least 2 calls total: initial + refresh)
expect(cronListMock.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it('edit button click opens edit modal with the correct job', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
// The mock CoreJobList renders are replaced here — we need to simulate the
// onEditCoreJob callback by clicking the edit button rendered by the real CoreJobList.
// Since CoreJobList is NOT mocked, it renders actual buttons.
const editBtn = await screen.findByTestId('cron-job-edit-job-1');
fireEvent.click(editBtn);
await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument());
expect(screen.getByTestId('modal-job-id')).toHaveTextContent('job-1');
});
it('onUpdate triggers openhumanCronUpdate and refresh', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
// Open edit modal via edit button
const editBtn = await screen.findByTestId('cron-job-edit-job-1');
fireEvent.click(editBtn);
await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument());
const patch = { name: 'Updated', schedule: { kind: 'cron', expr: '0 9 * * *' } };
await mockModalOnUpdate('job-1', patch);
await waitFor(() => expect(cronUpdateMock).toHaveBeenCalledWith('job-1', patch));
expect(cronListMock.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it('surfaces errorLoadList when openhumanCronList rejects', async () => {
cronListMock.mockRejectedValueOnce(new Error('boom'));
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => {
expect(screen.getByText('settings.cron.jobs.errorLoadList')).toBeInTheDocument();
});
});
it('surfaces errorToggle when openhumanCronUpdate rejects on toggle', async () => {
cronUpdateMock.mockRejectedValueOnce(new Error('nope'));
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
const toggle = await screen.findByTestId('cron-job-toggle-job-1');
fireEvent.click(toggle);
await waitFor(() => {
expect(screen.getByText('settings.cron.jobs.errorToggle')).toBeInTheDocument();
});
});
it('successful toggle replaces job in state', async () => {
cronUpdateMock.mockResolvedValueOnce({ result: { ...sampleJob, enabled: false } });
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
const toggle = await screen.findByTestId('cron-job-toggle-job-1');
fireEvent.click(toggle);
await waitFor(() => expect(cronUpdateMock).toHaveBeenCalledWith('job-1', { enabled: false }));
});
it('runCoreJob invokes cronRun + cronRuns + cronList; surfaces errorRun on failure', async () => {
cronRunsMock.mockResolvedValueOnce({ result: [{ id: 1, status: 'ok' }] });
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
fireEvent.click(await screen.findByTestId('cron-job-run-job-1'));
await waitFor(() => expect(cronRunMock).toHaveBeenCalledWith('job-1'));
await waitFor(() => expect(cronRunsMock).toHaveBeenCalledWith('job-1', 10));
// Then trigger a failure
cronRunMock.mockRejectedValueOnce(new Error('explode'));
fireEvent.click(screen.getByTestId('cron-job-run-job-1'));
await waitFor(() => {
expect(screen.getByText('settings.cron.jobs.errorRun')).toBeInTheDocument();
});
});
it('loadCoreRuns invokes cronRuns; surfaces errorLoadRuns on failure', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
cronRunsMock.mockResolvedValueOnce({ result: [{ id: 9, status: 'ok' }] });
fireEvent.click(await screen.findByTestId('cron-job-view-runs-job-1'));
await waitFor(() => expect(cronRunsMock).toHaveBeenCalledWith('job-1', 10));
cronRunsMock.mockRejectedValueOnce(new Error('runs-failed'));
fireEvent.click(screen.getByTestId('cron-job-view-runs-job-1'));
await waitFor(() => {
expect(screen.getByText('settings.cron.jobs.errorLoadRuns')).toBeInTheDocument();
});
});
it('removeCoreJob removes from list; surfaces errorRemove on failure', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
fireEvent.click(await screen.findByTestId('cron-job-remove-job-1'));
await waitFor(() => expect(cronRemoveMock).toHaveBeenCalledWith('job-1'));
// Re-load and trigger failure path
cronListMock.mockResolvedValueOnce({ result: [sampleJob] });
cronRemoveMock.mockRejectedValueOnce(new Error('remove-failed'));
// Force re-render by refreshing
fireEvent.click(screen.getByTestId('cron-refresh'));
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
const removeBtn = await screen.findByTestId('cron-job-remove-job-1');
fireEvent.click(removeBtn);
await waitFor(() => {
expect(screen.getByText('settings.cron.jobs.errorRemove')).toBeInTheDocument();
});
});
it('handleCreate surfaces errorCreate and re-throws when cronAdd rejects', async () => {
cronAddMock.mockRejectedValueOnce(new Error('add-failed'));
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
fireEvent.click(screen.getByTestId('cron-new-job'));
await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument());
const params = {
schedule: { kind: 'cron', expr: '0 9 * * *' },
job_type: 'agent',
prompt: 'hi',
};
let caught: unknown = null;
try {
await mockModalOnCreate(params);
} catch (e) {
caught = e;
}
expect((caught as Error).message).toBe('add-failed');
await waitFor(() => {
expect(screen.getByText('settings.cron.jobs.errorCreate')).toBeInTheDocument();
});
});
it('handleUpdate surfaces errorUpdate and re-throws when cronUpdate rejects', async () => {
cronUpdateMock.mockRejectedValueOnce(new Error('update-failed'));
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
fireEvent.click(await screen.findByTestId('cron-job-edit-job-1'));
await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument());
const patch = {
name: 'Updated',
schedule: { kind: 'cron', expr: '0 9 * * *' },
session_target: 'isolated',
delete_after_run: false,
};
let caught: unknown = null;
try {
await mockModalOnUpdate('job-1', patch);
} catch (e) {
caught = e;
}
expect((caught as Error).message).toBe('update-failed');
await waitFor(() => {
expect(screen.getByText('settings.cron.jobs.errorUpdate')).toBeInTheDocument();
});
});
it('closing the create modal hides it', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
fireEvent.click(screen.getByTestId('cron-new-job'));
await waitFor(() => expect(screen.getByTestId('cron-form-modal-create')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('modal-close'));
await waitFor(() =>
expect(screen.queryByTestId('cron-form-modal-create')).not.toBeInTheDocument()
);
});
it('closing the edit modal clears editingJob', async () => {
const Panel = await importPanel();
render(<Panel />);
await waitFor(() => expect(cronListMock).toHaveBeenCalled());
fireEvent.click(await screen.findByTestId('cron-job-edit-job-1'));
await waitFor(() => expect(screen.getByTestId('cron-form-modal-edit')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('modal-close'));
await waitFor(() =>
expect(screen.queryByTestId('cron-form-modal-edit')).not.toBeInTheDocument()
);
});
});
@@ -5,6 +5,8 @@ import { useT } from '../../../lib/i18n/I18nContext';
import {
type CoreCronJob,
type CoreCronRun,
type CronAddParams,
openhumanCronAdd,
openhumanCronList,
openhumanCronRemove,
openhumanCronRun,
@@ -14,12 +16,17 @@ import {
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import CoreJobList from './cron/CoreJobList';
import CronJobFormModal from './cron/CronJobFormModal';
const loadCronJobsLog = createDebug('app:settings:CronJobsPanel:loadCronSkills');
const CronJobsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const formatCronError = useCallback(
(key: string, message: string) => t(key).replace('{message}', message),
[t]
);
const [loading, setLoading] = useState(true);
const [coreError, setCoreError] = useState<string | null>(null);
@@ -28,6 +35,10 @@ const CronJobsPanel = () => {
const [coreRunsByJob, setCoreRunsByJob] = useState<Record<string, CoreCronRun[]>>({});
const [coreBusyKey, setCoreBusyKey] = useState<string | null>(null);
// Create / edit modal state
const [formOpen, setFormOpen] = useState(false);
const [editingJob, setEditingJob] = useState<CoreCronJob | null>(null);
const loadCoreCronJobs = useCallback(async () => {
const response = await openhumanCronList();
const sorted = [...response.result].sort((a, b) => {
@@ -49,11 +60,11 @@ const CronJobsPanel = () => {
} catch (err) {
loadCronJobsLog('failure', err);
const message = err instanceof Error ? err.message : String(err);
setCoreError(`Failed to load core cron jobs: ${message}`);
setCoreError(formatCronError('settings.cron.jobs.errorLoadList', message));
} finally {
setLoading(false);
}
}, [loadCoreCronJobs]);
}, [formatCronError, loadCoreCronJobs]);
useEffect(() => {
void loadCoreCronJobsOnly();
@@ -69,7 +80,7 @@ const CronJobsPanel = () => {
setCoreJobs(prev => prev.map(item => (item.id === updated.id ? updated : item)));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setCoreError(`Failed to update core cron job: ${message}`);
setCoreError(formatCronError('settings.cron.jobs.errorToggle', message));
} finally {
setCoreBusyKey(null);
}
@@ -87,7 +98,7 @@ const CronJobsPanel = () => {
await loadCoreCronJobs();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setCoreError(`Failed to run core cron job: ${message}`);
setCoreError(formatCronError('settings.cron.jobs.errorRun', message));
} finally {
setCoreBusyKey(null);
}
@@ -103,12 +114,55 @@ const CronJobsPanel = () => {
setCoreRunsByJob(prev => ({ ...prev, [jobId]: runs.result }));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setCoreError(`Failed to load run history: ${message}`);
setCoreError(formatCronError('settings.cron.jobs.errorLoadRuns', message));
} finally {
setCoreBusyKey(null);
}
};
const handleCreate = async (params: CronAddParams) => {
loadCronJobsLog('handleCreate metadata=%o', {
jobType: params.job_type,
scheduleKind: params.schedule.kind,
hasName: Boolean(params.name),
hasSessionTarget: Boolean(params.session_target),
deleteAfterRun: params.delete_after_run,
});
try {
await openhumanCronAdd(params);
await loadCoreCronJobs();
setFormOpen(false);
loadCronJobsLog('handleCreate success');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
loadCronJobsLog('handleCreate error: %s', message);
setCoreError(formatCronError('settings.cron.jobs.errorCreate', message));
throw err; // Re-throw so modal can surface inline error
}
};
const handleUpdate = async (jobId: string, patch: Record<string, unknown>) => {
const patchSchedule = patch.schedule as { kind?: string } | undefined;
loadCronJobsLog('handleUpdate metadata=%o', {
jobId,
scheduleKind: patchSchedule?.kind ?? 'unknown',
hasName: patch.name !== null,
hasSessionTarget: 'session_target' in patch,
deleteAfterRun: patch.delete_after_run,
});
try {
await openhumanCronUpdate(jobId, patch);
await loadCoreCronJobs();
setEditingJob(null);
loadCronJobsLog('handleUpdate success');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
loadCronJobsLog('handleUpdate error: %s', message);
setCoreError(formatCronError('settings.cron.jobs.errorUpdate', message));
throw err; // Re-throw so modal can surface inline error
}
};
const removeCoreJob = async (jobId: string) => {
const key = `core-remove:${jobId}`;
setCoreBusyKey(key);
@@ -124,7 +178,7 @@ const CronJobsPanel = () => {
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setCoreError(`Failed to remove core cron job: ${message}`);
setCoreError(formatCronError('settings.cron.jobs.errorRemove', message));
} finally {
setCoreBusyKey(null);
}
@@ -147,6 +201,20 @@ const CronJobsPanel = () => {
<p className="text-xs text-stone-400 dark:text-neutral-500">{t('cron.manageCronJobs')}</p>
</section>
{/* "+ New Scheduled Job" button */}
<div>
<button
type="button"
data-testid="cron-new-job"
className="inline-flex items-center rounded-xl border border-primary-700/30 bg-primary-600 px-3.5 py-2 text-sm font-semibold text-white shadow-soft transition-colors hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
onClick={() => {
setEditingJob(null);
setFormOpen(true);
}}>
{t('settings.cron.jobs.createJob')}
</button>
</div>
{coreError && (
<div className="rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
{coreError}
@@ -162,17 +230,43 @@ const CronJobsPanel = () => {
onRunCoreJob={jobId => void runCoreJob(jobId)}
onLoadCoreRuns={jobId => void loadCoreRuns(jobId)}
onRemoveCoreJob={jobId => void removeCoreJob(jobId)}
onEditCoreJob={job => setEditingJob(job)}
/>
<div>
<button
type="button"
data-testid="cron-refresh"
className="btn btn-outline btn-sm"
className="inline-flex items-center rounded-xl border border-stone-300 dark:border-stone-700 bg-white dark:bg-stone-900 px-3.5 py-2 text-sm font-medium text-stone-700 dark:text-stone-200 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
onClick={() => void loadCoreCronJobsOnly()}>
{t('cron.refreshCronJobs')}
</button>
</div>
</div>
{/* Create modal */}
{formOpen && editingJob === null && (
<CronJobFormModal
key="cron-form-create"
mode="create"
open={true}
onClose={() => setFormOpen(false)}
onCreate={params => handleCreate(params)}
onUpdate={handleUpdate}
/>
)}
{/* Edit modal */}
{editingJob !== null && (
<CronJobFormModal
key={`cron-form-edit-${editingJob.id}`}
mode="edit"
job={editingJob}
open={true}
onClose={() => setEditingJob(null)}
onCreate={handleCreate}
onUpdate={handleUpdate}
/>
)}
</div>
);
};
@@ -2,6 +2,7 @@ import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { execute as composioExecute, listConnections } from '../../../lib/composio/composioApi';
import { SCHEDULE_PRESETS } from '../../../lib/cron/schedulePresets';
import { useT } from '../../../lib/i18n/I18nContext';
import {
CoreCronJob,
@@ -42,14 +43,6 @@ interface GhBranch {
name: string;
}
const SCHEDULE_PRESETS = [
{ labelKey: 'settings.devWorkflow.schedule.every30min' as const, value: '*/30 * * * *' },
{ labelKey: 'settings.devWorkflow.schedule.everyHour' as const, value: '0 * * * *' },
{ labelKey: 'settings.devWorkflow.schedule.every2hours' as const, value: '0 */2 * * *' },
{ labelKey: 'settings.devWorkflow.schedule.every6hours' as const, value: '0 */6 * * *' },
{ labelKey: 'settings.devWorkflow.schedule.onceDaily' as const, value: '0 9 * * *' },
];
// ── Component ──────────────────────────────────────────────────────────
const DevWorkflowPanel = () => {
@@ -836,8 +836,8 @@ describe('DevWorkflowPanel', () => {
renderWithProviders(<Panel />);
await waitFor(() => {
// Schedule preset matches — should show the label key
expect(screen.getByText('settings.devWorkflow.schedule.every30min')).toBeInTheDocument();
// Schedule preset matches — should show the shared label key (migrated to cron namespace)
expect(screen.getByText('settings.cron.schedule.every30min')).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import type { CoreCronJob, CoreCronRun } from '../../../../utils/tauriCommands';
@@ -11,10 +11,12 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({
'common.enabled': 'Enabled',
'common.remove': 'Remove',
'settings.cron.jobs.desc': 'Manage cron jobs',
'settings.cron.jobs.edit': 'Edit',
'settings.cron.jobs.lastStatus': 'Last status',
'settings.cron.jobs.nextRun': 'Next run',
'settings.cron.jobs.pause': 'Pause',
'settings.cron.jobs.recentRuns': 'Recent runs',
'settings.cron.jobs.saving': 'Saving…',
'settings.cron.jobs.schedule': 'Schedule',
'settings.cron.jobs.title': 'Scheduled Jobs',
'settings.cron.jobs.viewRuns': 'View Runs',
@@ -75,4 +77,50 @@ describe('CoreJobList stable test hooks', () => {
expect(screen.getByTestId('cron-job-remove-morning_briefing')).toHaveTextContent('Remove');
expect(screen.getByTestId('cron-job-runs-morning_briefing')).toHaveTextContent('success');
});
test('edit button absent when onEditCoreJob prop is not provided', () => {
renderList();
expect(screen.queryByTestId('cron-job-edit-morning_briefing')).not.toBeInTheDocument();
});
test('edit button present and invokes callback with job when onEditCoreJob is provided', () => {
const onEditCoreJob = vi.fn();
render(
<CoreJobList
loading={false}
coreJobs={[job]}
coreRunsByJob={{ [job.id]: [run] }}
coreBusyKey={null}
onToggleCoreJob={vi.fn()}
onRunCoreJob={vi.fn()}
onLoadCoreRuns={vi.fn()}
onRemoveCoreJob={vi.fn()}
onEditCoreJob={onEditCoreJob}
/>
);
const editBtn = screen.getByTestId('cron-job-edit-morning_briefing');
expect(editBtn).toBeInTheDocument();
expect(editBtn).toHaveTextContent('Edit');
fireEvent.click(editBtn);
expect(onEditCoreJob).toHaveBeenCalledOnce();
expect(onEditCoreJob).toHaveBeenCalledWith(job);
});
test('toggle button shows saving label when coreBusyKey targets the toggle', () => {
render(
<CoreJobList
loading={false}
coreJobs={[job]}
coreRunsByJob={{}}
coreBusyKey={`core-toggle:${job.id}`}
onToggleCoreJob={vi.fn()}
onRunCoreJob={vi.fn()}
onLoadCoreRuns={vi.fn()}
onRemoveCoreJob={vi.fn()}
/>
);
expect(screen.getByTestId(`cron-job-toggle-${job.id}`)).toHaveTextContent('Saving…');
});
});
@@ -10,6 +10,8 @@ interface CoreJobListProps {
onRunCoreJob: (jobId: string) => void;
onLoadCoreRuns: (jobId: string) => void;
onRemoveCoreJob: (jobId: string) => void;
/** Optional: when provided, an Edit button is rendered per row. */
onEditCoreJob?: (job: CoreCronJob) => void;
}
const CoreJobList = ({
@@ -21,8 +23,34 @@ const CoreJobList = ({
onRunCoreJob,
onLoadCoreRuns,
onRemoveCoreJob,
onEditCoreJob,
}: CoreJobListProps) => {
const { t } = useT();
const neutralButtonClassName =
'inline-flex min-h-9 items-center justify-center whitespace-nowrap rounded-lg border border-stone-300 dark:border-stone-700 bg-white dark:bg-stone-900 px-3 py-1.5 text-[13px] font-medium leading-5 text-stone-700 dark:text-stone-200 shadow-sm transition-colors hover:bg-stone-100 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-100 focus:outline-none focus:ring-2 focus:ring-primary-500/30 disabled:cursor-not-allowed disabled:opacity-70 disabled:text-stone-500 dark:disabled:text-stone-400';
const removeButtonClassName =
'inline-flex min-h-9 items-center justify-center whitespace-nowrap rounded-lg border border-red-700/40 bg-red-600 px-3 py-1.5 text-[13px] font-medium leading-5 text-white shadow-sm transition-colors hover:bg-red-700 hover:text-white active:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500/30 disabled:cursor-not-allowed disabled:opacity-70';
const toggleButtonLabel = (job: CoreCronJob) => {
if (coreBusyKey === `core-toggle:${job.id}`) {
return t('settings.cron.jobs.saving');
}
return job.enabled ? t('settings.cron.jobs.pause') : t('settings.cron.jobs.resume');
};
const runButtonLabel = (jobId: string) =>
coreBusyKey === `core-run:${jobId}`
? t('settings.cron.jobs.runningNow')
: t('subconscious.runNow');
const viewRunsButtonLabel = (jobId: string) =>
coreBusyKey === `core-runs:${jobId}`
? t('settings.cron.jobs.loadingRuns')
: t('settings.cron.jobs.viewRuns');
const removeButtonLabel = (jobId: string) =>
coreBusyKey === `core-remove:${jobId}` ? t('settings.cron.jobs.removing') : t('common.remove');
return (
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-stone-200 dark:border-neutral-800">
@@ -102,44 +130,43 @@ const CoreJobList = ({
<button
type="button"
data-testid={`cron-job-toggle-${job.id}`}
className="btn btn-sm btn-outline"
className={neutralButtonClassName}
disabled={coreBusyKey === `core-toggle:${job.id}`}
onClick={() => onToggleCoreJob(job)}>
{coreBusyKey === `core-toggle:${job.id}`
? t('settings.cron.jobs.saving')
: job.enabled
? t('settings.cron.jobs.pause')
: t('settings.cron.jobs.resume')}
{toggleButtonLabel(job)}
</button>
<button
type="button"
data-testid={`cron-job-run-${job.id}`}
className="btn btn-sm btn-outline"
className={neutralButtonClassName}
disabled={coreBusyKey === `core-run:${job.id}`}
onClick={() => onRunCoreJob(job.id)}>
{coreBusyKey === `core-run:${job.id}`
? t('settings.cron.jobs.runningNow')
: t('subconscious.runNow')}
{runButtonLabel(job.id)}
</button>
<button
type="button"
data-testid={`cron-job-view-runs-${job.id}`}
className="btn btn-sm btn-outline"
className={neutralButtonClassName}
disabled={coreBusyKey === `core-runs:${job.id}`}
onClick={() => onLoadCoreRuns(job.id)}>
{coreBusyKey === `core-runs:${job.id}`
? t('settings.cron.jobs.loadingRuns')
: t('settings.cron.jobs.viewRuns')}
{viewRunsButtonLabel(job.id)}
</button>
{onEditCoreJob && (
<button
type="button"
data-testid={`cron-job-edit-${job.id}`}
className={neutralButtonClassName}
onClick={() => onEditCoreJob(job)}>
{t('settings.cron.jobs.edit')}
</button>
)}
<button
type="button"
data-testid={`cron-job-remove-${job.id}`}
className="btn btn-sm btn-error"
className={removeButtonClassName}
disabled={coreBusyKey === `core-remove:${job.id}`}
onClick={() => onRemoveCoreJob(job.id)}>
{coreBusyKey === `core-remove:${job.id}`
? t('settings.cron.jobs.removing')
: t('common.remove')}
{removeButtonLabel(job.id)}
</button>
</div>
@@ -0,0 +1,438 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CoreCronJob } from '../../../../utils/tauriCommands';
import CronJobFormModal, { type CronJobFormModalProps } from './CronJobFormModal';
// ── Mock i18n ──────────────────────────────────────────────────────────
vi.mock('../../../../lib/i18n/I18nContext', () => ({
useT: () => ({
t: (key: string, fallback?: string) => {
const map: Record<string, string> = {
'settings.cron.jobs.createJob': 'New Scheduled Job',
'settings.cron.jobs.editJob': 'Edit Scheduled Job',
'settings.cron.jobs.formName': 'Job name',
'settings.cron.jobs.formNamePlaceholder': 'e.g. daily-report, cleanup-task',
'settings.cron.jobs.formJobType': 'Job type',
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI prompt)',
'settings.cron.jobs.formJobTypeShell': 'Shell command',
'settings.cron.jobs.formScheduleType': 'Schedule type',
'settings.cron.jobs.formScheduleCron': 'Recurring (cron)',
'settings.cron.jobs.formScheduleAt': 'One-time (run at)',
'settings.cron.jobs.formScheduleEvery': 'Interval (every N ms)',
'settings.cron.jobs.formCronPreset': 'Preset',
'settings.cron.jobs.formCronCustom': 'Custom expression',
'settings.cron.jobs.formCronCustomPlaceholder': 'e.g. */30 * * * *',
'settings.cron.jobs.formCronPreview': 'Runs: {preview}',
'settings.cron.jobs.formAtLabel': 'Run at',
'settings.cron.jobs.formEveryLabel': 'Interval (milliseconds)',
'settings.cron.jobs.formEveryPlaceholder': 'e.g. 3600000',
'settings.cron.jobs.formPrompt': 'Agent prompt',
'settings.cron.jobs.formPromptPlaceholder': 'What should the agent do each run?',
'settings.cron.jobs.formCommand': 'Shell command',
'settings.cron.jobs.formCommandPlaceholder': 'e.g. curl https://example.com/health',
'settings.cron.jobs.formSessionTarget': 'Session target',
'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)',
'settings.cron.jobs.formSessionMain': 'Main session',
'settings.cron.jobs.formDelivery': 'Delivery mode',
'settings.cron.jobs.formDeliveryNone': 'None (output only)',
'settings.cron.jobs.formDeliveryProactive': 'Proactive (push notification)',
'settings.cron.jobs.formDeleteAfterRun': 'Delete after first run',
'settings.cron.jobs.formCancel': 'Cancel',
'settings.cron.jobs.formSave': 'Save',
'settings.cron.jobs.formCreate': 'Create',
'settings.cron.jobs.formSaving': 'Saving…',
'settings.cron.jobs.formError': 'Failed to save job',
'settings.cron.jobs.custom': 'Custom',
'settings.cron.schedule.every30min': 'Every 30 minutes',
'settings.cron.schedule.everyHour': 'Every hour',
'settings.cron.schedule.every2hours': 'Every 2 hours',
'settings.cron.schedule.every6hours': 'Every 6 hours',
'settings.cron.schedule.onceDaily': 'Once daily (9 AM)',
};
return map[key] ?? fallback ?? key;
},
}),
}));
// ── Mock cronToHuman ────────────────────────────────────────────────────
vi.mock('../../../../lib/cron/cronToHuman', () => ({
cronToHuman: (expr: string) => `Parsed: ${expr}`,
}));
// ── Sample data ─────────────────────────────────────────────────────────
const sampleJob: CoreCronJob = {
id: 'job-abc',
expression: '*/30 * * * *',
schedule: { kind: 'cron', expr: '*/30 * * * *' },
command: '',
name: 'Test Job',
job_type: 'agent',
session_target: 'isolated',
enabled: true,
delivery: { mode: 'proactive', best_effort: true },
delete_after_run: false,
created_at: '2026-05-01T00:00:00.000Z',
next_run: '2026-05-01T01:00:00.000Z',
prompt: 'Do something daily',
};
// ── Helpers ─────────────────────────────────────────────────────────────
function makeProps(overrides: Partial<CronJobFormModalProps> = {}): CronJobFormModalProps {
return {
mode: 'create',
open: true,
onClose: vi.fn(),
onCreate: vi.fn().mockResolvedValue(undefined),
onUpdate: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('<CronJobFormModal />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── Closed state ────────────────────────────────────────────────────
it('renders nothing when open is false', () => {
const props = makeProps({ open: false });
const { container } = render(<CronJobFormModal {...props} />);
expect(container).toBeEmptyDOMElement();
});
// ── Create mode defaults ─────────────────────────────────────────────
it('opens in create mode with agent type and cron schedule by default', () => {
render(<CronJobFormModal {...makeProps()} />);
expect(screen.getByTestId('cron-form-modal')).toBeInTheDocument();
expect(screen.getByText('New Scheduled Job')).toBeInTheDocument();
expect(screen.getByTestId('cron-form-job-type-agent')).toBeChecked();
expect(screen.getByTestId('cron-form-schedule-cron')).toBeChecked();
});
it('submit button is disabled when prompt is empty in create mode', () => {
render(<CronJobFormModal {...makeProps()} />);
// Prompt textarea should be visible for agent type
expect(screen.getByTestId('cron-form-prompt')).toBeInTheDocument();
// Submit disabled without prompt
expect(screen.getByTestId('cron-form-submit')).toBeDisabled();
});
it('enables submit after filling prompt + selecting preset, then calls onCreate with correct shape', async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
render(<CronJobFormModal {...makeProps({ onCreate })} />);
// Fill in the prompt
fireEvent.change(screen.getByTestId('cron-form-prompt'), {
target: { value: 'Send daily report' },
});
// The first preset is already selected so submit should be enabled now
const submit = screen.getByTestId('cron-form-submit');
expect(submit).not.toBeDisabled();
fireEvent.click(submit);
await waitFor(() => {
expect(onCreate).toHaveBeenCalledOnce();
});
const [params] = onCreate.mock.calls[0];
expect(params.job_type).toBe('agent');
expect(params.prompt).toBe('Send daily report');
expect(params.schedule).toMatchObject({ kind: 'cron' });
expect(params.session_target).toBe('isolated');
expect(params.delivery).toMatchObject({ mode: 'proactive', best_effort: true });
});
// ── Switching to shell job type ───────────────────────────────────────
it('switching to shell hides prompt, shows command, and requires command for submit', async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
render(<CronJobFormModal {...makeProps({ onCreate })} />);
fireEvent.click(screen.getByTestId('cron-form-job-type-shell'));
// Prompt should not be rendered
expect(screen.queryByTestId('cron-form-prompt')).not.toBeInTheDocument();
// Command should appear
expect(screen.getByTestId('cron-form-command')).toBeInTheDocument();
// Submit disabled (command empty)
expect(screen.getByTestId('cron-form-submit')).toBeDisabled();
// Fill command
fireEvent.change(screen.getByTestId('cron-form-command'), {
target: { value: 'curl https://example.com/health' },
});
expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled();
fireEvent.click(screen.getByTestId('cron-form-submit'));
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce());
const [params] = onCreate.mock.calls[0];
expect(params.job_type).toBe('shell');
expect(params.command).toBe('curl https://example.com/health');
expect(params).not.toHaveProperty('prompt');
});
// ── Schedule type: at ─────────────────────────────────────────────────
it('switching to "at" schedule shows datetime input and sets deleteAfterRun to true', () => {
render(<CronJobFormModal {...makeProps()} />);
fireEvent.click(screen.getByTestId('cron-form-schedule-at'));
expect(screen.getByTestId('cron-form-at')).toBeInTheDocument();
expect(screen.queryByTestId('cron-form-cron-preset')).not.toBeInTheDocument();
// deleteAfterRun checkbox should be checked
expect(screen.getByTestId('cron-form-delete-after-run')).toBeChecked();
});
// ── Schedule type: every ──────────────────────────────────────────────
it('switching to "every" schedule shows ms input', () => {
render(<CronJobFormModal {...makeProps()} />);
fireEvent.click(screen.getByTestId('cron-form-schedule-every'));
expect(screen.getByTestId('cron-form-every')).toBeInTheDocument();
expect(screen.queryByTestId('cron-form-cron-preset')).not.toBeInTheDocument();
});
// ── Edit mode prefill ──────────────────────────────────────────────────
it('prefills fields from job prop in edit mode', () => {
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: sampleJob })} />);
expect(screen.getByText('Edit Scheduled Job')).toBeInTheDocument();
expect(screen.getByTestId('cron-form-name')).toHaveValue('Test Job');
expect(screen.getByTestId('cron-form-prompt')).toHaveValue('Do something daily');
});
it('disables job type radio in edit mode', () => {
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: sampleJob })} />);
expect(screen.getByTestId('cron-form-job-type-agent')).toBeDisabled();
expect(screen.getByTestId('cron-form-job-type-shell')).toBeDisabled();
});
// ── Edit submit ────────────────────────────────────────────────────────
it('calls onUpdate with job.id and patch on edit submit', async () => {
const onUpdate = vi.fn().mockResolvedValue(undefined);
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: sampleJob, onUpdate })} />);
// Change the name
fireEvent.change(screen.getByTestId('cron-form-name'), { target: { value: 'Updated Name' } });
fireEvent.click(screen.getByTestId('cron-form-submit'));
await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce());
const [jobId, patch] = onUpdate.mock.calls[0];
expect(jobId).toBe('job-abc');
expect(patch).toMatchObject({ schedule: { kind: 'cron' } });
});
// ── Cancel ────────────────────────────────────────────────────────────
it('calls onClose when Cancel button is clicked', () => {
const onClose = vi.fn();
render(<CronJobFormModal {...makeProps({ onClose })} />);
// There are two cancel buttons (header x and footer Cancel)
const cancelButtons = screen.getAllByTestId('cron-form-cancel');
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
expect(onClose).toHaveBeenCalledOnce();
});
// ── Error surfacing ────────────────────────────────────────────────────
it('surfaces error in cron-form-error when onCreate rejects', async () => {
const onCreate = vi.fn().mockRejectedValue(new Error('network error'));
render(<CronJobFormModal {...makeProps({ onCreate })} />);
// Fill prompt so submit is enabled
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'Some prompt' } });
fireEvent.click(screen.getByTestId('cron-form-submit'));
await waitFor(() => {
expect(screen.getByTestId('cron-form-error')).toBeInTheDocument();
});
expect(screen.getByTestId('cron-form-error')).toHaveTextContent('Failed to save job');
});
// ── Create: "at" schedule ───────────────────────────────────────────
it('submits with at-schedule, isoifies datetime input', async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
render(<CronJobFormModal {...makeProps({ onCreate })} />);
fireEvent.click(screen.getByTestId('cron-form-schedule-at'));
fireEvent.change(screen.getByTestId('cron-form-at'), { target: { value: '2030-01-01T09:00' } });
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'go' } });
expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled();
fireEvent.click(screen.getByTestId('cron-form-submit'));
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce());
const [params] = onCreate.mock.calls[0];
expect(params.schedule.kind).toBe('at');
expect(params.schedule.at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(params.delete_after_run).toBe(true);
});
it('submits with every-schedule using parsed ms', async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
render(<CronJobFormModal {...makeProps({ onCreate })} />);
fireEvent.click(screen.getByTestId('cron-form-schedule-every'));
fireEvent.change(screen.getByTestId('cron-form-every'), { target: { value: '60000' } });
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'tick' } });
expect(screen.getByTestId('cron-form-submit')).not.toBeDisabled();
fireEvent.click(screen.getByTestId('cron-form-submit'));
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce());
const [params] = onCreate.mock.calls[0];
expect(params.schedule).toEqual({ kind: 'every', every_ms: 60000 });
});
it('submit disabled when every ms is empty or non-positive', () => {
render(<CronJobFormModal {...makeProps()} />);
fireEvent.click(screen.getByTestId('cron-form-schedule-every'));
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'x' } });
expect(screen.getByTestId('cron-form-submit')).toBeDisabled();
fireEvent.change(screen.getByTestId('cron-form-every'), { target: { value: '0' } });
expect(screen.getByTestId('cron-form-submit')).toBeDisabled();
});
// ── Cron custom expression ──────────────────────────────────────────
it('typing a custom cron expression clears preset and renders preview', () => {
render(<CronJobFormModal {...makeProps()} />);
const preset = screen.getByTestId('cron-form-cron-preset') as HTMLSelectElement;
// Select empty/custom option
fireEvent.change(preset, { target: { value: '' } });
const custom = screen.getByTestId('cron-form-cron-custom');
fireEvent.change(custom, { target: { value: '*/15 * * * *' } });
expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('*/15 * * * *');
});
it('typing a value that matches a preset sets cronPreset', () => {
render(<CronJobFormModal {...makeProps()} />);
fireEvent.change(screen.getByTestId('cron-form-cron-preset'), { target: { value: '' } });
const custom = screen.getByTestId('cron-form-cron-custom');
// value matches a preset
fireEvent.change(custom, { target: { value: '0 9 * * *' } });
// Preview rendered for that expression
expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('0 9 * * *');
});
// ── Session target / delivery / deleteAfterRun ──────────────────────
it('changes session_target and delivery mode in the submitted params', async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
render(<CronJobFormModal {...makeProps({ onCreate })} />);
fireEvent.change(screen.getByTestId('cron-form-prompt'), { target: { value: 'p' } });
fireEvent.change(screen.getByTestId('cron-form-session-target'), { target: { value: 'main' } });
fireEvent.change(screen.getByTestId('cron-form-delivery'), { target: { value: 'none' } });
fireEvent.click(screen.getByTestId('cron-form-delete-after-run'));
fireEvent.click(screen.getByTestId('cron-form-submit'));
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce());
const [params] = onCreate.mock.calls[0];
expect(params.session_target).toBe('main');
expect(params.delivery).toMatchObject({ mode: 'none' });
expect(params.delete_after_run).toBe(true);
});
// ── Edit mode: at and every prefill ─────────────────────────────────
it('edit mode prefills "at" schedule and converts ISO to datetime-local', () => {
const atJob: CoreCronJob = {
...sampleJob,
schedule: { kind: 'at', at: '2030-01-01T09:00:00.000Z' },
};
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: atJob })} />);
const atInput = screen.getByTestId('cron-form-at') as HTMLInputElement;
expect(atInput.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
});
it('edit mode prefills "every" schedule with every_ms as string', () => {
const everyJob: CoreCronJob = { ...sampleJob, schedule: { kind: 'every', every_ms: 120000 } };
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: everyJob })} />);
expect(screen.getByTestId('cron-form-every')).toHaveValue(120000);
});
it('edit mode with a non-preset custom cron expression shows the custom input', () => {
const job: CoreCronJob = { ...sampleJob, schedule: { kind: 'cron', expr: '*/7 * * * *' } };
render(<CronJobFormModal {...makeProps({ mode: 'edit', job })} />);
expect(screen.getByTestId('cron-form-cron-custom')).toHaveValue('*/7 * * * *');
});
it('edit mode handleSubmit builds patch with all fields and clears name to null when blank', async () => {
const onUpdate = vi.fn().mockResolvedValue(undefined);
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: sampleJob, onUpdate })} />);
fireEvent.change(screen.getByTestId('cron-form-name'), { target: { value: '' } });
fireEvent.click(screen.getByTestId('cron-form-submit'));
await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce());
const [, patch] = onUpdate.mock.calls[0];
expect(patch.name).toBeNull();
expect(patch.session_target).toBe('isolated');
expect(patch.delivery).toMatchObject({ mode: 'proactive' });
});
it('edit mode with shell job patches command not prompt', async () => {
const shellJob: CoreCronJob = {
...sampleJob,
job_type: 'shell',
command: 'echo hi',
prompt: '',
};
const onUpdate = vi.fn().mockResolvedValue(undefined);
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: shellJob, onUpdate })} />);
fireEvent.click(screen.getByTestId('cron-form-submit'));
await waitFor(() => expect(onUpdate).toHaveBeenCalledOnce());
const [, patch] = onUpdate.mock.calls[0];
expect(patch.command).toBe('echo hi');
expect(patch).not.toHaveProperty('prompt');
expect(patch).not.toHaveProperty('session_target');
});
// ── Schedule kind switching back to cron resets delete flag ─────────
it('switching from "at" back to "cron" in create mode clears deleteAfterRun', () => {
render(<CronJobFormModal {...makeProps()} />);
fireEvent.click(screen.getByTestId('cron-form-schedule-at'));
expect(screen.getByTestId('cron-form-delete-after-run')).toBeChecked();
fireEvent.click(screen.getByTestId('cron-form-schedule-cron'));
expect(screen.getByTestId('cron-form-delete-after-run')).not.toBeChecked();
});
it('edit mode with an unparseable "at" ISO falls back to empty input', () => {
const badAtJob: CoreCronJob = { ...sampleJob, schedule: { kind: 'at', at: 'not-a-real-date' } };
render(<CronJobFormModal {...makeProps({ mode: 'edit', job: badAtJob })} />);
// The input stays empty when ISO can't be parsed.
expect(screen.getByTestId('cron-form-at')).toHaveValue('');
});
it('changing preset dropdown to a different preset updates the expression and clears custom', () => {
render(<CronJobFormModal {...makeProps()} />);
// Initially first preset is selected. Pick a different preset value.
fireEvent.change(screen.getByTestId('cron-form-cron-preset'), {
target: { value: '0 9 * * *' },
});
// Preview should reflect the newly-picked preset
expect(screen.getByTestId('cron-form-cron-preview')).toHaveTextContent('0 9 * * *');
});
// ── Toggle job type back to agent ───────────────────────────────────
it('toggling back to agent job type restores the prompt field', () => {
render(<CronJobFormModal {...makeProps()} />);
fireEvent.click(screen.getByTestId('cron-form-job-type-shell'));
expect(screen.queryByTestId('cron-form-prompt')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('cron-form-job-type-agent'));
expect(screen.getByTestId('cron-form-prompt')).toBeInTheDocument();
});
});
@@ -0,0 +1,636 @@
/**
* CronJobFormModal — Create / Edit cron job form modal.
*
* Reachable from CronJobsPanel via the "+ New Scheduled Job" button (create)
* or the "Edit" button per job row (edit).
*/
import createDebug from 'debug';
import { useState } from 'react';
import { cronToHuman } from '../../../../lib/cron/cronToHuman';
import { SCHEDULE_PRESET_VALUES, SCHEDULE_PRESETS } from '../../../../lib/cron/schedulePresets';
import { useT } from '../../../../lib/i18n/I18nContext';
import type {
CoreCronJob,
CoreCronSchedule,
CronAddParams,
} from '../../../../utils/tauriCommands/cron';
const log = createDebug('app:settings:CronJobFormModal');
// ── Types ──────────────────────────────────────────────────────────────
type JobType = 'agent' | 'shell';
type ScheduleKind = 'cron' | 'at' | 'every';
type DeliveryMode = 'none' | 'proactive';
type SessionTarget = 'isolated' | 'main';
export interface CronJobFormModalProps {
mode: 'create' | 'edit';
job?: CoreCronJob;
open: boolean;
onClose: () => void;
onCreate: (params: CronAddParams) => Promise<void>;
onUpdate: (jobId: string, patch: Record<string, unknown>) => Promise<void>;
}
// ── Helpers ────────────────────────────────────────────────────────────
function buildSchedule(
kind: ScheduleKind,
cronExpr: string,
atValue: string,
everyMs: string
): CoreCronSchedule | null {
if (kind === 'cron') {
const expr = cronExpr.trim();
if (!expr) return null;
return { kind: 'cron', expr, tz: null };
}
if (kind === 'at') {
if (!atValue) return null;
return { kind: 'at', at: new Date(atValue).toISOString() };
}
if (kind === 'every') {
const ms = parseInt(everyMs, 10);
if (!ms || ms <= 0) return null;
return { kind: 'every', every_ms: ms };
}
return null;
}
function getInitialScheduleKind(job: CoreCronJob): ScheduleKind {
return job.schedule.kind;
}
function getInitialCronExpr(job: CoreCronJob): string {
return job.schedule.kind === 'cron' ? job.schedule.expr : '';
}
function getInitialAtValue(job: CoreCronJob): string {
if (job.schedule.kind === 'at') {
// Convert ISO to datetime-local format (YYYY-MM-DDTHH:MM)
try {
const d = new Date(job.schedule.at);
const offset = d.getTimezoneOffset();
const local = new Date(d.getTime() - offset * 60000);
return local.toISOString().slice(0, 16);
} catch {
return '';
}
}
return '';
}
function getInitialEveryMs(job: CoreCronJob): string {
return job.schedule.kind === 'every' ? String(job.schedule.every_ms) : '';
}
function getInitialDelivery(job: CoreCronJob): DeliveryMode {
return job.delivery.mode === 'proactive' ? 'proactive' : 'none';
}
interface CronJobFormInitialState {
name: string;
jobType: JobType;
scheduleKind: ScheduleKind;
cronPreset: string;
cronCustom: string;
atValue: string;
everyMs: string;
prompt: string;
command: string;
sessionTarget: SessionTarget;
delivery: DeliveryMode;
deleteAfterRun: boolean;
}
function getInitialFormState(mode: 'create' | 'edit', job?: CoreCronJob): CronJobFormInitialState {
if (mode === 'edit' && job) {
const scheduleKind = getInitialScheduleKind(job);
const cronExpr = getInitialCronExpr(job);
const hasPresetCron = scheduleKind === 'cron' && SCHEDULE_PRESET_VALUES.has(cronExpr);
return {
name: job.name ?? '',
jobType: job.job_type === 'shell' ? 'shell' : 'agent',
scheduleKind,
cronPreset:
scheduleKind === 'cron' ? (hasPresetCron ? cronExpr : '') : SCHEDULE_PRESETS[0].value,
cronCustom: scheduleKind === 'cron' && !hasPresetCron ? cronExpr : '',
atValue: scheduleKind === 'at' ? getInitialAtValue(job) : '',
everyMs: scheduleKind === 'every' ? getInitialEveryMs(job) : '',
prompt: job.prompt ?? '',
command: job.command ?? '',
sessionTarget: job.session_target === 'main' ? 'main' : 'isolated',
delivery: getInitialDelivery(job),
deleteAfterRun: job.delete_after_run,
};
}
return {
name: '',
jobType: 'agent',
scheduleKind: 'cron',
cronPreset: SCHEDULE_PRESETS[0].value,
cronCustom: '',
atValue: '',
everyMs: '',
prompt: '',
command: '',
sessionTarget: 'isolated',
delivery: 'proactive',
deleteAfterRun: false,
};
}
// ── Component ──────────────────────────────────────────────────────────
const CronJobFormModal = ({
mode,
job,
open,
onClose,
onCreate,
onUpdate,
}: CronJobFormModalProps) => {
const { t } = useT();
const initialState = getInitialFormState(mode, job);
// ── Form state ─────────────────────────────────────────────────────
const [name, setName] = useState(initialState.name);
const [jobType, setJobType] = useState<JobType>(initialState.jobType);
const [scheduleKind, setScheduleKind] = useState<ScheduleKind>(initialState.scheduleKind);
const [cronPreset, setCronPreset] = useState<string>(initialState.cronPreset);
const [cronCustom, setCronCustom] = useState(initialState.cronCustom);
const [atValue, setAtValue] = useState(initialState.atValue);
const [everyMs, setEveryMs] = useState(initialState.everyMs);
const [prompt, setPrompt] = useState(initialState.prompt);
const [command, setCommand] = useState(initialState.command);
const [sessionTarget, setSessionTarget] = useState<SessionTarget>(initialState.sessionTarget);
const [delivery, setDelivery] = useState<DeliveryMode>(initialState.delivery);
const [deleteAfterRun, setDeleteAfterRun] = useState(initialState.deleteAfterRun);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Effective cron expression: if preset is selected use its value, else custom
const cronExpr = SCHEDULE_PRESET_VALUES.has(cronPreset) ? cronPreset : cronCustom.trim();
const handleScheduleKindChange = (nextKind: ScheduleKind) => {
setScheduleKind(nextKind);
if (nextKind === 'at') {
setDeleteAfterRun(true);
} else if (mode === 'create') {
setDeleteAfterRun(false);
}
};
// ── Validation ──────────────────────────────────────────────────────
const schedule = buildSchedule(scheduleKind, cronExpr, atValue, everyMs);
const isScheduleValid = schedule !== null;
const isPromptValid = jobType !== 'agent' || prompt.trim().length > 0;
const isCommandValid = jobType !== 'shell' || command.trim().length > 0;
const canSubmit = isScheduleValid && isPromptValid && isCommandValid && !saving;
// ── Submit ──────────────────────────────────────────────────────────
const handleSubmit = async () => {
if (!canSubmit || !schedule) return;
setError(null);
setSaving(true);
log(
'[CronJobFormModal] submit mode=%s, jobType=%s, scheduleKind=%s',
mode,
jobType,
scheduleKind
);
try {
if (mode === 'create') {
const params: CronAddParams = {
name: name.trim() || undefined,
schedule,
job_type: jobType,
...(jobType === 'agent' ? { prompt: prompt.trim() } : {}),
...(jobType === 'shell' ? { command: command.trim() } : {}),
...(jobType === 'agent' ? { session_target: sessionTarget } : {}),
...(jobType === 'agent'
? { delivery: { mode: delivery, best_effort: true } }
: { delivery: { mode: 'none', best_effort: false } }),
delete_after_run: deleteAfterRun,
};
log('[CronJobFormModal] calling onCreate metadata=%o', {
mode: 'create',
jobType: params.job_type,
scheduleKind: params.schedule.kind,
hasName: Boolean(params.name),
hasSessionTarget: Boolean(params.session_target),
deleteAfterRun: params.delete_after_run,
});
await onCreate(params);
} else {
if (!job) return;
const patch: Record<string, unknown> = {
name: name.trim() || null,
schedule,
...(jobType === 'agent' ? { prompt: prompt.trim() } : {}),
...(jobType === 'shell' ? { command: command.trim() } : {}),
...(jobType === 'agent' ? { session_target: sessionTarget } : {}),
...(jobType === 'agent'
? { delivery: { mode: delivery, best_effort: true } }
: { delivery: { mode: 'none', best_effort: false } }),
delete_after_run: deleteAfterRun,
};
const patchSchedule = patch.schedule as { kind?: string } | undefined;
log('[CronJobFormModal] calling onUpdate metadata=%o', {
mode: 'edit',
jobId: job.id,
scheduleKind: patchSchedule?.kind ?? 'unknown',
hasName: patch.name !== null,
hasSessionTarget: 'session_target' in patch,
deleteAfterRun: patch.delete_after_run,
});
await onUpdate(job.id, patch);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('[CronJobFormModal] save error: %s', msg);
setError(t('settings.cron.jobs.formError'));
} finally {
setSaving(false);
}
};
if (!open) return null;
// ── Render ──────────────────────────────────────────────────────────
const title =
mode === 'create' ? t('settings.cron.jobs.createJob') : t('settings.cron.jobs.editJob');
const submitLabel = saving
? t('settings.cron.jobs.formSaving')
: mode === 'create'
? t('settings.cron.jobs.formCreate')
: t('settings.cron.jobs.formSave');
return (
<div
data-testid="cron-form-modal"
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label={title}>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 dark:bg-black/60"
onClick={onClose}
aria-hidden="true"
/>
{/* Card */}
<div className="relative z-10 w-full max-w-lg bg-white dark:bg-neutral-900 rounded-2xl shadow-xl border border-stone-200 dark:border-neutral-800 flex flex-col max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-stone-200 dark:border-neutral-800 flex items-center justify-between">
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">{title}</h2>
<button
type="button"
data-testid="cron-form-cancel"
onClick={onClose}
className="text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors text-xl leading-none">
&times;
</button>
</div>
{/* Scrollable body */}
<div className="overflow-y-auto px-6 py-4 flex flex-col gap-4">
{/* Name */}
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formName')}
</label>
<input
data-testid="cron-form-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('settings.cron.jobs.formNamePlaceholder')}
disabled={saving}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
/>
</div>
{/* Job type */}
<div>
<div className="text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1.5">
{t('settings.cron.jobs.formJobType')}
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
<input
data-testid="cron-form-job-type-agent"
type="radio"
name="cron-job-type"
value="agent"
checked={jobType === 'agent'}
onChange={() => setJobType('agent')}
disabled={mode === 'edit' || saving}
className="accent-primary-600"
/>
{t('settings.cron.jobs.formJobTypeAgent')}
</label>
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
<input
data-testid="cron-form-job-type-shell"
type="radio"
name="cron-job-type"
value="shell"
checked={jobType === 'shell'}
onChange={() => setJobType('shell')}
disabled={mode === 'edit' || saving}
className="accent-primary-600"
/>
{t('settings.cron.jobs.formJobTypeShell')}
</label>
</div>
</div>
{/* Schedule type */}
<div>
<div className="text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1.5">
{t('settings.cron.jobs.formScheduleType')}
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
<input
data-testid="cron-form-schedule-cron"
type="radio"
name="cron-schedule-kind"
value="cron"
checked={scheduleKind === 'cron'}
onChange={() => handleScheduleKindChange('cron')}
disabled={saving}
className="accent-primary-600"
/>
{t('settings.cron.jobs.formScheduleCron')}
</label>
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
<input
data-testid="cron-form-schedule-at"
type="radio"
name="cron-schedule-kind"
value="at"
checked={scheduleKind === 'at'}
onChange={() => handleScheduleKindChange('at')}
disabled={saving}
className="accent-primary-600"
/>
{t('settings.cron.jobs.formScheduleAt')}
</label>
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer">
<input
data-testid="cron-form-schedule-every"
type="radio"
name="cron-schedule-kind"
value="every"
checked={scheduleKind === 'every'}
onChange={() => handleScheduleKindChange('every')}
disabled={saving}
className="accent-primary-600"
/>
{t('settings.cron.jobs.formScheduleEvery')}
</label>
</div>
</div>
{/* Cron schedule fields */}
{scheduleKind === 'cron' && (
<div className="flex flex-col gap-2">
{/* Preset dropdown */}
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formCronPreset')}
</label>
<select
data-testid="cron-form-cron-preset"
value={SCHEDULE_PRESET_VALUES.has(cronPreset) ? cronPreset : ''}
onChange={e => {
const val = e.target.value;
if (val) {
setCronPreset(val);
setCronCustom('');
} else {
setCronPreset('');
}
}}
disabled={saving}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
<option value="">{t('settings.cron.jobs.custom')}</option>
{SCHEDULE_PRESETS.map(p => (
<option key={p.value} value={p.value}>
{t(p.labelKey)}
</option>
))}
</select>
</div>
{/* Custom expression — shown when no preset selected or user typed */}
{(!SCHEDULE_PRESET_VALUES.has(cronPreset) || cronCustom) && (
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formCronCustom')}
</label>
<input
data-testid="cron-form-cron-custom"
type="text"
value={cronCustom}
onChange={e => {
const val = e.target.value;
setCronCustom(val);
// Reset preset to custom sentinel
if (!SCHEDULE_PRESET_VALUES.has(val.trim())) {
setCronPreset('');
} else {
setCronPreset(val.trim());
}
}}
placeholder={t('settings.cron.jobs.formCronCustomPlaceholder')}
disabled={saving}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
/>
</div>
)}
{/* Live preview */}
{cronExpr && (
<p
data-testid="cron-form-cron-preview"
className="text-xs text-stone-500 dark:text-neutral-400">
{t('settings.cron.jobs.formCronPreview').replace(
'{preview}',
cronToHuman(cronExpr)
)}
</p>
)}
</div>
)}
{/* At */}
{scheduleKind === 'at' && (
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formAtLabel')}
</label>
<input
data-testid="cron-form-at"
type="datetime-local"
value={atValue}
onChange={e => setAtValue(e.target.value)}
disabled={saving}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
/>
</div>
)}
{/* Every */}
{scheduleKind === 'every' && (
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formEveryLabel')}
</label>
<input
data-testid="cron-form-every"
type="number"
min="1"
value={everyMs}
onChange={e => setEveryMs(e.target.value)}
disabled={saving}
placeholder={t('settings.cron.jobs.formEveryPlaceholder')}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
/>
</div>
)}
{/* Prompt (agent only) */}
{jobType === 'agent' && (
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formPrompt')}
<span className="text-coral-500 ml-0.5">*</span>
</label>
<textarea
data-testid="cron-form-prompt"
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder={t('settings.cron.jobs.formPromptPlaceholder')}
rows={4}
disabled={saving}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 resize-y"
/>
</div>
)}
{/* Command (shell only) */}
{jobType === 'shell' && (
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formCommand')}
<span className="text-coral-500 ml-0.5">*</span>
</label>
<input
data-testid="cron-form-command"
type="text"
value={command}
onChange={e => setCommand(e.target.value)}
placeholder={t('settings.cron.jobs.formCommandPlaceholder')}
disabled={saving}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50"
/>
</div>
)}
{/* Session target (agent only) */}
{jobType === 'agent' && (
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formSessionTarget')}
</label>
<select
data-testid="cron-form-session-target"
value={sessionTarget}
onChange={e => setSessionTarget(e.target.value as SessionTarget)}
disabled={saving}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
<option value="isolated">{t('settings.cron.jobs.formSessionIsolated')}</option>
<option value="main">{t('settings.cron.jobs.formSessionMain')}</option>
</select>
</div>
)}
{/* Delivery mode (agent only) */}
{jobType === 'agent' && (
<div>
<label className="block text-xs font-medium text-stone-700 dark:text-neutral-300 mb-1">
{t('settings.cron.jobs.formDelivery')}
</label>
<select
data-testid="cron-form-delivery"
value={delivery}
onChange={e => setDelivery(e.target.value as DeliveryMode)}
disabled={saving}
className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50">
<option value="proactive">{t('settings.cron.jobs.formDeliveryProactive')}</option>
<option value="none">{t('settings.cron.jobs.formDeliveryNone')}</option>
</select>
</div>
)}
{/* Delete after run */}
<label className="flex items-center gap-2 text-sm text-stone-700 dark:text-neutral-300 cursor-pointer select-none">
<input
data-testid="cron-form-delete-after-run"
type="checkbox"
checked={deleteAfterRun}
onChange={e => setDeleteAfterRun(e.target.checked)}
disabled={saving}
className="accent-primary-600"
/>
{t('settings.cron.jobs.formDeleteAfterRun')}
</label>
{/* Error */}
{error && (
<div
data-testid="cron-form-error"
className="px-3 py-2 rounded-md bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-stone-200 dark:border-neutral-800 flex items-center justify-end gap-3">
<button
type="button"
data-testid="cron-form-cancel"
onClick={onClose}
disabled={saving}
className="px-4 py-2 rounded-md border border-stone-300 dark:border-neutral-700 text-sm text-stone-700 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800 transition-colors disabled:opacity-50">
{t('settings.cron.jobs.formCancel')}
</button>
<button
type="button"
data-testid="cron-form-submit"
onClick={() => void handleSubmit()}
disabled={!canSubmit}
className="px-4 py-2 rounded-md bg-primary-600 hover:bg-primary-500 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{submitLabel}
</button>
</div>
</div>
</div>
);
};
export default CronJobFormModal;
@@ -112,8 +112,8 @@ export default function ScheduledCronCard({
// dashboard's grouped-skill card.
const containerClass = `rounded-2xl border shadow-soft transition-colors ${
isActive
? 'border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10'
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900'
? 'border-sage-200 dark:border-sage-500/30 bg-gradient-to-br from-sage-50 via-white to-sage-100 dark:from-sage-500/10 dark:via-neutral-900 dark:to-sage-500/5'
: 'border-stone-200 dark:border-neutral-800 bg-gradient-to-br from-white via-stone-50 to-stone-100/60 dark:from-neutral-900 dark:via-neutral-900 dark:to-neutral-800/80'
}`;
const headingRow = (
@@ -250,7 +250,7 @@ export default function ScheduledCronCard({
onClick();
}
}}
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 rounded-2xl"
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 cursor-pointer rounded-2xl transition-colors hover:bg-stone-100/80 dark:hover:bg-neutral-800/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
>
{headingRow}
{rightCluster}
+42 -49
View File
@@ -17,6 +17,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import { SCHEDULE_PRESETS } from '../../lib/cron/schedulePresets';
import {
type RunLogSlice,
type ScannedRun,
@@ -97,16 +98,6 @@ interface RunState {
result?: SkillRunStarted;
}
// Cron schedule presets. The exact cron expressions match
// DevWorkflowPanel's set so users see the same options across the two
// panels. Custom expressions land in a future revision.
const SCHEDULE_PRESETS: { labelKey: string; value: string }[] = [
{ labelKey: 'settings.skillsRunner.schedule.every30min', value: '*/30 * * * *' },
{ labelKey: 'settings.skillsRunner.schedule.everyHour', value: '0 * * * *' },
{ labelKey: 'settings.skillsRunner.schedule.every2hours', value: '0 */2 * * *' },
{ labelKey: 'settings.skillsRunner.schedule.every6hours', value: '0 */6 * * *' },
{ labelKey: 'settings.skillsRunner.schedule.onceDaily', value: '0 9 * * *' },
];
/** Name prefix used to identify cron jobs owned by this panel (per-skill). */
const CRON_NAME_PREFIX = 'skill-run-';
@@ -1064,9 +1055,9 @@ export const SkillsRunnerBody = ({ headerText, className }: SkillsRunnerBodyProp
</div>
{/* Schedule (cron-driven recurring) */}
<div className="pt-4 border-t border-stone-200 dark:border-stone-700 space-y-3">
<div className="pt-4 border-t border-stone-200 dark:border-stone-700">
<div>
<h3 className="text-sm font-semibold text-stone-700 dark:text-stone-300">
<h3 className="text-sm font-semibold text-stone-800 dark:text-stone-200">
{t('settings.skillsRunner.schedule.heading')}
</h3>
<p className="text-xs text-stone-500 dark:text-stone-400 mt-1">
@@ -1074,62 +1065,64 @@ export const SkillsRunnerBody = ({ headerText, className }: SkillsRunnerBodyProp
</p>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<label
htmlFor="skills-runner-schedule"
className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-1"
<div className="mt-3 rounded-2xl border border-stone-200/90 dark:border-stone-700/80 bg-gradient-to-br from-stone-50 via-white to-stone-100 dark:from-stone-900 dark:via-stone-900 dark:to-stone-800/80 px-3 py-3 shadow-soft">
<div className="flex flex-col sm:flex-row sm:items-end gap-2">
<div className="flex-1">
<label
htmlFor="skills-runner-schedule"
className="block text-xs font-semibold uppercase tracking-wide text-stone-600 dark:text-stone-300 mb-1.5"
>
{t('settings.skillsRunner.schedule.frequency')}
</label>
<select
id="skills-runner-schedule"
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
className="w-full rounded-xl border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-stone-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/40 focus:border-primary-500"
>
{SCHEDULE_PRESETS.map((p) => (
<option key={p.value} value={p.value}>
{t(p.labelKey)}
</option>
))}
</select>
</div>
<button
type="button"
onClick={() => void handleSaveSchedule()}
disabled={savingSchedule || missingRequired.length > 0}
className="rounded-xl border border-primary-700/30 bg-primary-600 hover:bg-primary-700 active:bg-primary-800 disabled:bg-stone-300 disabled:border-stone-300 dark:disabled:bg-stone-700 dark:disabled:border-stone-700 disabled:text-stone-600 dark:disabled:text-stone-300 px-4 py-2 text-sm font-semibold text-white shadow-soft transition-colors"
>
{t('settings.skillsRunner.schedule.frequency')}
</label>
<select
id="skills-runner-schedule"
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
className="w-full rounded border border-stone-300 dark:border-stone-600 bg-white dark:bg-stone-800 px-3 py-2 text-sm text-stone-900 dark:text-stone-100"
>
{SCHEDULE_PRESETS.map((p) => (
<option key={p.value} value={p.value}>
{t(p.labelKey)}
</option>
))}
</select>
{savingSchedule
? t('settings.skillsRunner.schedule.saving')
: t('settings.skillsRunner.schedule.save')}
</button>
</div>
<button
type="button"
onClick={() => void handleSaveSchedule()}
disabled={savingSchedule || missingRequired.length > 0}
className="rounded bg-stone-200 hover:bg-stone-300 dark:bg-stone-700 dark:hover:bg-stone-600 disabled:opacity-50 px-4 py-2 text-sm font-medium text-stone-900 dark:text-stone-100"
>
{savingSchedule
? t('settings.skillsRunner.schedule.saving')
: t('settings.skillsRunner.schedule.save')}
</button>
</div>
{scheduleSaved && (
<p className="text-xs text-emerald-700 dark:text-emerald-300">
<p className="mt-2 inline-flex items-center rounded-full border border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-900/40 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
{t('settings.skillsRunner.schedule.saved')}
</p>
)}
{scheduleError && (
<p className="text-xs text-red-600 dark:text-red-400">
<p className="mt-2 inline-flex items-center rounded-full border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/40 px-2.5 py-1 text-xs font-medium text-red-700 dark:text-red-300">
{t('settings.skillsRunner.schedule.error')} {scheduleError}
</p>
)}
{/* Existing scheduled jobs for this skill */}
{scheduledJobsLoading ? (
<p className="text-xs text-stone-500 dark:text-stone-400">
<p className="mt-3 text-xs text-stone-500 dark:text-stone-400">
{t('settings.skillsRunner.schedule.loadingJobs')}
</p>
) : scheduledJobs.length === 0 ? (
<p className="text-xs italic text-stone-500 dark:text-stone-400">
<p className="mt-3 text-xs italic text-stone-500 dark:text-stone-400">
{t('settings.skillsRunner.schedule.noJobs')}
</p>
) : (
<div className="space-y-2">
<div className="text-xs font-medium text-stone-600 dark:text-stone-400">
<div className="mt-3 space-y-2 rounded-2xl border border-stone-200/80 dark:border-stone-800 bg-stone-50/70 dark:bg-stone-900/40 p-2.5">
<div className="text-xs font-semibold uppercase tracking-wide text-stone-600 dark:text-stone-400 px-1">
{t('settings.skillsRunner.schedule.existing')}
</div>
{/* Per-skill saved-schedule list — uses the shared
@@ -1159,14 +1152,14 @@ export const SkillsRunnerBody = ({ headerText, className }: SkillsRunnerBodyProp
<button
type="button"
onClick={() => void handleRunJobNow(job.id)}
className="rounded bg-primary-600 hover:bg-primary-700 px-2 py-1 text-xs font-medium text-white"
className="rounded-lg border border-primary-700/30 bg-primary-600 hover:bg-primary-700 active:bg-primary-800 px-2.5 py-1 text-xs font-semibold text-white transition-colors"
>
{t('settings.skillsRunner.schedule.runNow')}
</button>
<button
type="button"
onClick={() => void handleRemoveJob(job.id)}
className="rounded bg-red-600 hover:bg-red-700 px-2 py-1 text-xs font-medium text-white"
className="rounded-lg border border-red-700/40 bg-red-600 hover:bg-red-700 active:bg-red-800 px-2.5 py-1 text-xs font-semibold text-white transition-colors"
>
{t('settings.skillsRunner.schedule.remove')}
</button>
@@ -387,6 +387,41 @@ describe('SkillsRunnerBody — per-job history viewer', () => {
});
});
describe('SkillsRunnerBody — schedule frequency + save', () => {
beforeEach(() => {
Object.values(hoisted).forEach((fn) => fn.mockReset());
hoisted.listSkills.mockResolvedValue(skillsList);
hoisted.describeSkill.mockResolvedValue(skillDescription);
hoisted.recentRuns.mockResolvedValue([]);
hoisted.cronList.mockResolvedValue({ result: [] });
hoisted.cronAdd.mockResolvedValue({ result: makeJob() });
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
});
it('changes schedule frequency and calls openhumanCronAdd on save', async () => {
const Body = await importBody();
renderBody(Body);
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
fireEvent.change(select, { target: { value: SKILL_ID } });
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
const freq = screen.getByLabelText(
'settings.skillsRunner.schedule.frequency'
) as HTMLSelectElement;
fireEvent.change(freq, { target: { value: '0 9 * * *' } });
expect(freq.value).toBe('0 9 * * *');
fireEvent.click(screen.getByText('settings.skillsRunner.schedule.save'));
await waitFor(() => expect(hoisted.cronAdd).toHaveBeenCalled());
const [params] = hoisted.cronAdd.mock.calls[0];
expect(params).toMatchObject({
schedule: { kind: 'cron', expr: '0 9 * * *' },
job_type: 'agent',
});
});
});
describe('SkillsRunnerBody — SmartIssuePicker conditional mount', () => {
beforeEach(() => {
Object.values(hoisted).forEach((fn) => fn.mockReset());
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { SCHEDULE_PRESET_VALUES, SCHEDULE_PRESETS } from './schedulePresets';
describe('SCHEDULE_PRESETS', () => {
it('is non-empty', () => {
expect(SCHEDULE_PRESETS.length).toBeGreaterThan(0);
});
it('all values are valid 5-field cron expressions', () => {
for (const preset of SCHEDULE_PRESETS) {
const fields = preset.value.trim().split(/\s+/);
expect(fields).toHaveLength(5);
}
});
it('has no duplicate cron expression values', () => {
const values = SCHEDULE_PRESETS.map(p => p.value);
const unique = new Set(values);
expect(unique.size).toBe(values.length);
});
it('has no duplicate labelKeys', () => {
const keys = SCHEDULE_PRESETS.map(p => p.labelKey);
const unique = new Set(keys);
expect(unique.size).toBe(keys.length);
});
});
describe('SCHEDULE_PRESET_VALUES', () => {
it('contains all preset values from SCHEDULE_PRESETS', () => {
for (const preset of SCHEDULE_PRESETS) {
expect(SCHEDULE_PRESET_VALUES.has(preset.value)).toBe(true);
}
});
it('has same size as SCHEDULE_PRESETS', () => {
expect(SCHEDULE_PRESET_VALUES.size).toBe(SCHEDULE_PRESETS.length);
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Shared schedule presets for cron-based scheduling UI.
*
* Both DevWorkflowPanel and SkillsRunnerBody previously defined their own
* local SCHEDULE_PRESETS arrays with identical expressions. This module
* consolidates them into a single authoritative source.
*
* Note: the `labelKey` values are *shared* keys from the `settings.cron.schedule.*`
* namespace so every panel renders the same translated labels — this module
* provides the canonical cron expressions, their shared labels, and a lookup set.
*/
export interface SchedulePreset {
/** i18n key from the shared cron namespace (e.g. 'settings.cron.schedule.every30min'). */
labelKey: string;
/** 5-field cron expression. */
value: string;
}
/**
* Canonical cron schedule presets used across scheduling UI panels.
* The `labelKey` values are *generic* keys from the cron namespace so the
* modal and any future panel can use them directly.
*/
export const SCHEDULE_PRESETS: ReadonlyArray<SchedulePreset> = [
{ labelKey: 'settings.cron.schedule.every30min', value: '*/30 * * * *' },
{ labelKey: 'settings.cron.schedule.everyHour', value: '0 * * * *' },
{ labelKey: 'settings.cron.schedule.every2hours', value: '0 */2 * * *' },
{ labelKey: 'settings.cron.schedule.every6hours', value: '0 */6 * * *' },
{ labelKey: 'settings.cron.schedule.onceDaily', value: '0 9 * * *' },
] as const;
/**
* Set of all preset cron expressions for O(1) membership checks.
* Use this to determine whether a user-typed expression matches a preset.
*/
export const SCHEDULE_PRESET_VALUES: ReadonlySet<string> = new Set(
SCHEDULE_PRESETS.map(p => p.value)
);
+53
View File
@@ -2994,8 +2994,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'جلسة TTL',
'settings.companion.screenCapture': 'لقطة الشاشة',
'settings.companion.appContext': 'سياق التطبيق',
'settings.cron.jobs.commandRequired': 'الأمر مطلوب لمهام shell',
'settings.cron.jobs.createJob': 'مهمة مجدولة جديدة',
'settings.cron.jobs.created': 'تم إنشاء المهمة',
'settings.cron.jobs.custom': 'مخصص',
'settings.cron.jobs.desc': 'الوصف',
'settings.cron.jobs.edit': 'تعديل',
'settings.cron.jobs.editJob': 'تعديل المهمة المجدولة',
'settings.cron.jobs.empty': 'لا توجد مهام cron أساسية.',
'settings.cron.jobs.errorCreate': 'فشل إنشاء مهمة cron: {message}',
'settings.cron.jobs.errorLoadList': 'فشل تحميل مهام cron الأساسية: {message}',
'settings.cron.jobs.errorLoadRuns': 'فشل تحميل سجل التشغيل: {message}',
'settings.cron.jobs.errorRemove': 'فشل إزالة مهمة cron الأساسية: {message}',
'settings.cron.jobs.errorRun': 'فشل تشغيل مهمة cron الأساسية: {message}',
'settings.cron.jobs.errorToggle': 'فشل تحديث مهمة cron الأساسية: {message}',
'settings.cron.jobs.errorUpdate': 'فشل تحديث مهمة cron: {message}',
'settings.cron.jobs.formAtLabel': 'التشغيل في',
'settings.cron.jobs.formCancel': 'إلغاء',
'settings.cron.jobs.formCommand': 'أمر shell',
'settings.cron.jobs.formCommandPlaceholder': 'مثال: curl https://example.com/health',
'settings.cron.jobs.formCreate': 'إنشاء',
'settings.cron.jobs.formCronCustom': 'تعبير مخصص',
'settings.cron.jobs.formCronCustomPlaceholder': 'مثال: */30 * * * *',
'settings.cron.jobs.formCronPreset': 'إعداد مسبق',
'settings.cron.jobs.formCronPreview': 'يعمل: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'حذف بعد أول تشغيل',
'settings.cron.jobs.formDelivery': 'وضع التسليم',
'settings.cron.jobs.formDeliveryNone': 'بلا (الإخراج فقط)',
'settings.cron.jobs.formDeliveryProactive': 'استباقي (إشعار فوري)',
'settings.cron.jobs.formError': 'فشل حفظ المهمة',
'settings.cron.jobs.formEveryLabel': 'الفاصل الزمني (ميلي ثانية)',
'settings.cron.jobs.formEveryPlaceholder': 'مثال: 3600000',
'settings.cron.jobs.formJobType': 'نوع المهمة',
'settings.cron.jobs.formJobTypeAgent': 'Agent (موجّه AI)',
'settings.cron.jobs.formJobTypeShell': 'أمر shell',
'settings.cron.jobs.formName': 'اسم المهمة',
'settings.cron.jobs.formNamePlaceholder': 'مثال: daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'موجّه Agent',
'settings.cron.jobs.formPromptPlaceholder': 'ماذا يجب أن يفعل الوكيل في كل تشغيل؟',
'settings.cron.jobs.formPromptRequired': 'الموجّه مطلوب لمهام Agent',
'settings.cron.jobs.formSave': 'حفظ',
'settings.cron.jobs.formSaving': 'جارٍ الحفظ…',
'settings.cron.jobs.formScheduleAt': 'مرة واحدة (التشغيل في)',
'settings.cron.jobs.formScheduleCron': 'متكرر (cron)',
'settings.cron.jobs.formScheduleEvery': 'الفاصل الزمني (كل N مللي ثانية)',
'settings.cron.jobs.formScheduleRequired': 'الجدول مطلوب',
'settings.cron.jobs.formScheduleType': 'نوع الجدول',
'settings.cron.jobs.formSessionIsolated': 'معزول (موصى به)',
'settings.cron.jobs.formSessionMain': 'الجلسة الرئيسية',
'settings.cron.jobs.formSessionTarget': 'هدف الجلسة',
'settings.cron.jobs.lastStatus': 'آخر حالة',
'settings.cron.jobs.loading': 'جارٍ تحميل مهام cron...',
'settings.cron.jobs.loadingRuns': 'جارٍ تحميل عمليات التشغيل',
@@ -3009,7 +3056,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'جارٍ الحفظ…',
'settings.cron.jobs.schedule': 'الجدول',
'settings.cron.jobs.title': 'مهام Cron الأساسية',
'settings.cron.jobs.updated': 'تم تحديث المهمة',
'settings.cron.jobs.viewRuns': 'عرض عمليات التشغيل',
'settings.cron.schedule.every2hours': 'كل ساعتين',
'settings.cron.schedule.every30min': 'كل 30 دقيقة',
'settings.cron.schedule.every6hours': 'كل 6 ساعات',
'settings.cron.schedule.everyHour': 'كل ساعة',
'settings.cron.schedule.onceDaily': 'مرة يوميًا (9 صباحًا)',
'settings.localModel.deviceCapability.active': 'نشط',
'settings.localModel.deviceCapability.appliedTier': 'المستوى المطبّق',
'settings.localModel.deviceCapability.applying': 'جارٍ التطبيق',
+53
View File
@@ -3046,8 +3046,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'সেশন TTL',
'settings.companion.screenCapture': 'স্ক্রিন ক্যাপচার',
'settings.companion.appContext': 'অ্যাপ প্রসঙ্গ',
'settings.cron.jobs.commandRequired': 'শেল জবের জন্য কমান্ড আবশ্যক',
'settings.cron.jobs.createJob': 'নতুন নির্ধারিত জব',
'settings.cron.jobs.created': 'জব তৈরি হয়েছে',
'settings.cron.jobs.custom': 'কাস্টম',
'settings.cron.jobs.desc': 'বিবরণ',
'settings.cron.jobs.edit': 'সম্পাদনা',
'settings.cron.jobs.editJob': 'নির্ধারিত জব সম্পাদনা',
'settings.cron.jobs.empty': 'কোনো কোর ক্রন জব পাওয়া যায়নি।',
'settings.cron.jobs.errorCreate': 'cron জব তৈরি করা যায়নি: {message}',
'settings.cron.jobs.errorLoadList': 'কোর cron জব লোড করা যায়নি: {message}',
'settings.cron.jobs.errorLoadRuns': 'রান ইতিহাস লোড করা যায়নি: {message}',
'settings.cron.jobs.errorRemove': 'কোর cron জব সরানো যায়নি: {message}',
'settings.cron.jobs.errorRun': 'কোর cron জব চালানো যায়নি: {message}',
'settings.cron.jobs.errorToggle': 'কোর cron জব আপডেট করা যায়নি: {message}',
'settings.cron.jobs.errorUpdate': 'cron জব আপডেট করা যায়নি: {message}',
'settings.cron.jobs.formAtLabel': 'যখন চালাবে',
'settings.cron.jobs.formCancel': 'বাতিল',
'settings.cron.jobs.formCommand': 'শেল কমান্ড',
'settings.cron.jobs.formCommandPlaceholder': 'যেমন curl https://example.com/health',
'settings.cron.jobs.formCreate': 'তৈরি করুন',
'settings.cron.jobs.formCronCustom': 'কাস্টম এক্সপ্রেশন',
'settings.cron.jobs.formCronCustomPlaceholder': 'যেমন */30 * * * *',
'settings.cron.jobs.formCronPreset': 'প্রিসেট',
'settings.cron.jobs.formCronPreview': 'চলে: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'প্রথম রানের পর মুছে ফেলুন',
'settings.cron.jobs.formDelivery': 'ডেলিভারি মোড',
'settings.cron.jobs.formDeliveryNone': 'কোনোটি নয় (শুধু আউটপুট)',
'settings.cron.jobs.formDeliveryProactive': 'সক্রিয় (পুশ নোটিফিকেশন)',
'settings.cron.jobs.formError': 'জব সংরক্ষণ করা যায়নি',
'settings.cron.jobs.formEveryLabel': 'ব্যবধান (মিলিসেকেন্ড)',
'settings.cron.jobs.formEveryPlaceholder': 'যেমন 3600000',
'settings.cron.jobs.formJobType': 'জবের ধরন',
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI প্রম্পট)',
'settings.cron.jobs.formJobTypeShell': 'শেল কমান্ড',
'settings.cron.jobs.formName': 'জবের নাম',
'settings.cron.jobs.formNamePlaceholder': 'যেমন daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Agent প্রম্পট',
'settings.cron.jobs.formPromptPlaceholder': 'প্রতিটি রানে এজেন্টের কী করা উচিত?',
'settings.cron.jobs.formPromptRequired': 'এজেন্ট জবের জন্য প্রম্পট আবশ্যক',
'settings.cron.jobs.formSave': 'সংরক্ষণ',
'settings.cron.jobs.formSaving': 'সংরক্ষণ করা হচ্ছে…',
'settings.cron.jobs.formScheduleAt': 'এককালীন (যখন চালাবে)',
'settings.cron.jobs.formScheduleCron': 'পুনরাবৃত্ত (cron)',
'settings.cron.jobs.formScheduleEvery': 'ব্যবধান (প্রতি N ms)',
'settings.cron.jobs.formScheduleRequired': 'শিডিউল আবশ্যক',
'settings.cron.jobs.formScheduleType': 'শিডিউলের ধরন',
'settings.cron.jobs.formSessionIsolated': 'বিচ্ছিন্ন (প্রস্তাবিত)',
'settings.cron.jobs.formSessionMain': 'প্রধান সেশন',
'settings.cron.jobs.formSessionTarget': 'সেশন টার্গেট',
'settings.cron.jobs.lastStatus': 'শেষ স্ট্যাটাস',
'settings.cron.jobs.loading': 'ক্রন জব লোড হচ্ছে...',
'settings.cron.jobs.loadingRuns': 'রান লোড হচ্ছে',
@@ -3061,7 +3108,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'সংরক্ষণ হচ্ছে…',
'settings.cron.jobs.schedule': 'সময়সূচি',
'settings.cron.jobs.title': 'কোর ক্রন জবস',
'settings.cron.jobs.updated': 'জব আপডেট হয়েছে',
'settings.cron.jobs.viewRuns': 'রান দেখুন',
'settings.cron.schedule.every2hours': 'প্রতি ২ ঘণ্টায়',
'settings.cron.schedule.every30min': 'প্রতি ৩০ মিনিটে',
'settings.cron.schedule.every6hours': 'প্রতি ৬ ঘণ্টায়',
'settings.cron.schedule.everyHour': 'প্রতি ঘণ্টায়',
'settings.cron.schedule.onceDaily': 'দৈনিক একবার (সকাল ৯টা)',
'settings.localModel.deviceCapability.active': 'সক্রিয়',
'settings.localModel.deviceCapability.appliedTier': 'প্রয়োগকৃত টায়ার',
'settings.localModel.deviceCapability.applying': 'প্রয়োগ হচ্ছে',
+53
View File
@@ -3125,8 +3125,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'Sitzungs-TTL',
'settings.companion.screenCapture': 'Bildschirmaufnahme',
'settings.companion.appContext': 'App-Kontext',
'settings.cron.jobs.commandRequired': 'Bei Shell-Jobs ist ein Befehl erforderlich',
'settings.cron.jobs.createJob': 'Neuer geplanter Job',
'settings.cron.jobs.created': 'Job erstellt',
'settings.cron.jobs.custom': 'Benutzerdefiniert',
'settings.cron.jobs.desc': 'Beschr',
'settings.cron.jobs.edit': 'Bearbeiten',
'settings.cron.jobs.editJob': 'Geplanten Job bearbeiten',
'settings.cron.jobs.empty': 'Keine zentralen Cron-Jobs gefunden.',
'settings.cron.jobs.errorCreate': 'Cron-Job konnte nicht erstellt werden: {message}',
'settings.cron.jobs.errorLoadList': 'Core-Cron-Jobs konnten nicht geladen werden: {message}',
'settings.cron.jobs.errorLoadRuns': 'Ausführungsverlauf konnte nicht geladen werden: {message}',
'settings.cron.jobs.errorRemove': 'Core-Cron-Job konnte nicht entfernt werden: {message}',
'settings.cron.jobs.errorRun': 'Core-Cron-Job konnte nicht ausgeführt werden: {message}',
'settings.cron.jobs.errorToggle': 'Core-Cron-Job konnte nicht aktualisiert werden: {message}',
'settings.cron.jobs.errorUpdate': 'Cron-Job konnte nicht aktualisiert werden: {message}',
'settings.cron.jobs.formAtLabel': 'Ausführen um',
'settings.cron.jobs.formCancel': 'Abbrechen',
'settings.cron.jobs.formCommand': 'Shell-Befehl',
'settings.cron.jobs.formCommandPlaceholder': 'z. B. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Erstellen',
'settings.cron.jobs.formCronCustom': 'Benutzerdefinierter Ausdruck',
'settings.cron.jobs.formCronCustomPlaceholder': 'z. B. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Voreinstellung',
'settings.cron.jobs.formCronPreview': 'Ausführungen: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'Nach erster Ausführung löschen',
'settings.cron.jobs.formDelivery': 'Zustellungsmodus',
'settings.cron.jobs.formDeliveryNone': 'Keine (nur Ausgabe)',
'settings.cron.jobs.formDeliveryProactive': 'Proaktiv (Push-Benachrichtigung)',
'settings.cron.jobs.formError': 'Job konnte nicht gespeichert werden',
'settings.cron.jobs.formEveryLabel': 'Intervall (Millisekunden)',
'settings.cron.jobs.formEveryPlaceholder': 'z. B. 3600000',
'settings.cron.jobs.formJobType': 'Job-Typ',
'settings.cron.jobs.formJobTypeAgent': 'Agent (KI-Prompt)',
'settings.cron.jobs.formJobTypeShell': 'Shell-Befehl',
'settings.cron.jobs.formName': 'Job-Name',
'settings.cron.jobs.formNamePlaceholder': 'z. B. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Agent-Prompt',
'settings.cron.jobs.formPromptPlaceholder': 'Was soll der Agent bei jeder Ausführung tun?',
'settings.cron.jobs.formPromptRequired': 'Bei Agent-Jobs ist ein Prompt erforderlich',
'settings.cron.jobs.formSave': 'Speichern',
'settings.cron.jobs.formSaving': 'Wird gespeichert…',
'settings.cron.jobs.formScheduleAt': 'Einmalig (ausführen um)',
'settings.cron.jobs.formScheduleCron': 'Wiederkehrend (Cron)',
'settings.cron.jobs.formScheduleEvery': 'Intervall (alle N ms)',
'settings.cron.jobs.formScheduleRequired': 'Ein Zeitplan ist erforderlich',
'settings.cron.jobs.formScheduleType': 'Zeitplantyp',
'settings.cron.jobs.formSessionIsolated': 'Isoliert (empfohlen)',
'settings.cron.jobs.formSessionMain': 'Hauptsitzung',
'settings.cron.jobs.formSessionTarget': 'Sitzungsziel',
'settings.cron.jobs.lastStatus': 'Letzter Stand',
'settings.cron.jobs.loading': 'Cron-Jobs werden geladen...',
'settings.cron.jobs.loadingRuns': 'Ladeläufe',
@@ -3140,7 +3187,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'Sparen…',
'settings.cron.jobs.schedule': 'Zeitplan',
'settings.cron.jobs.title': 'Kern-Cron-Jobs',
'settings.cron.jobs.updated': 'Job aktualisiert',
'settings.cron.jobs.viewRuns': 'Läufe ansehen',
'settings.cron.schedule.every2hours': 'Alle 2 Stunden',
'settings.cron.schedule.every30min': 'Alle 30 Minuten',
'settings.cron.schedule.every6hours': 'Alle 6 Stunden',
'settings.cron.schedule.everyHour': 'Jede Stunde',
'settings.cron.schedule.onceDaily': 'Einmal täglich (9 Uhr)',
'settings.localModel.deviceCapability.active': 'Aktiv',
'settings.localModel.deviceCapability.appliedTier': 'Angewandte Stufe',
'settings.localModel.deviceCapability.applying': 'Bewerben',
+53
View File
@@ -3258,8 +3258,55 @@ const en: TranslationMap = {
'settings.companion.sessionTtl': 'Session TTL',
'settings.companion.screenCapture': 'Screen Capture',
'settings.companion.appContext': 'App Context',
'settings.cron.jobs.commandRequired': 'Command is required for shell jobs',
'settings.cron.jobs.created': 'Job created',
'settings.cron.jobs.createJob': 'New Scheduled Job',
'settings.cron.jobs.custom': 'Custom',
'settings.cron.jobs.desc': 'View and manage scheduled jobs running in the core.',
'settings.cron.jobs.edit': 'Edit',
'settings.cron.jobs.editJob': 'Edit Scheduled Job',
'settings.cron.jobs.empty': 'No core cron jobs found.',
'settings.cron.jobs.errorCreate': 'Failed to create cron job: {message}',
'settings.cron.jobs.errorLoadList': 'Failed to load core cron jobs: {message}',
'settings.cron.jobs.errorLoadRuns': 'Failed to load run history: {message}',
'settings.cron.jobs.errorRemove': 'Failed to remove core cron job: {message}',
'settings.cron.jobs.errorRun': 'Failed to run core cron job: {message}',
'settings.cron.jobs.errorToggle': 'Failed to update core cron job: {message}',
'settings.cron.jobs.errorUpdate': 'Failed to update cron job: {message}',
'settings.cron.jobs.formAtLabel': 'Run at',
'settings.cron.jobs.formCancel': 'Cancel',
'settings.cron.jobs.formCommand': 'Shell command',
'settings.cron.jobs.formCommandPlaceholder': 'e.g. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Create',
'settings.cron.jobs.formCronCustom': 'Custom expression',
'settings.cron.jobs.formCronCustomPlaceholder': 'e.g. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Preset',
'settings.cron.jobs.formCronPreview': 'Runs: {preview}',
'settings.cron.jobs.formDelivery': 'Delivery mode',
'settings.cron.jobs.formDeliveryNone': 'None (output only)',
'settings.cron.jobs.formDeliveryProactive': 'Proactive (push notification)',
'settings.cron.jobs.formDeleteAfterRun': 'Delete after first run',
'settings.cron.jobs.formError': 'Failed to save job',
'settings.cron.jobs.formEveryLabel': 'Interval (milliseconds)',
'settings.cron.jobs.formEveryPlaceholder': 'e.g. 3600000',
'settings.cron.jobs.formJobType': 'Job type',
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI prompt)',
'settings.cron.jobs.formJobTypeShell': 'Shell command',
'settings.cron.jobs.formName': 'Job name',
'settings.cron.jobs.formNamePlaceholder': 'e.g. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Agent prompt',
'settings.cron.jobs.formPromptPlaceholder': 'What should the agent do each run?',
'settings.cron.jobs.formPromptRequired': 'Prompt is required for agent jobs',
'settings.cron.jobs.formSave': 'Save',
'settings.cron.jobs.formSaving': 'Saving…',
'settings.cron.jobs.formScheduleAt': 'One-time (run at)',
'settings.cron.jobs.formScheduleCron': 'Recurring (cron)',
'settings.cron.jobs.formScheduleEvery': 'Interval (every N ms)',
'settings.cron.jobs.formScheduleRequired': 'Schedule is required',
'settings.cron.jobs.formScheduleType': 'Schedule type',
'settings.cron.jobs.formSessionIsolated': 'Isolated (recommended)',
'settings.cron.jobs.formSessionMain': 'Main session',
'settings.cron.jobs.formSessionTarget': 'Session target',
'settings.cron.jobs.lastStatus': 'Last status',
'settings.cron.jobs.loading': 'Loading cron jobs...',
'settings.cron.jobs.loadingRuns': 'Loading runs…',
@@ -3273,7 +3320,13 @@ const en: TranslationMap = {
'settings.cron.jobs.saving': 'Saving…',
'settings.cron.jobs.schedule': 'Schedule',
'settings.cron.jobs.title': 'Core Cron Jobs',
'settings.cron.jobs.updated': 'Job updated',
'settings.cron.jobs.viewRuns': 'View runs',
'settings.cron.schedule.every2hours': 'Every 2 hours',
'settings.cron.schedule.every30min': 'Every 30 minutes',
'settings.cron.schedule.every6hours': 'Every 6 hours',
'settings.cron.schedule.everyHour': 'Every hour',
'settings.cron.schedule.onceDaily': 'Once daily (9 AM)',
'settings.localModel.deviceCapability.active': 'Active',
'settings.localModel.deviceCapability.appliedTier': 'Applied tier',
'settings.localModel.deviceCapability.applying': 'Applying',
+55
View File
@@ -3102,8 +3102,57 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'TTL de sesión',
'settings.companion.screenCapture': 'Captura de pantalla',
'settings.companion.appContext': 'Contexto de la aplicación',
'settings.cron.jobs.commandRequired': 'El comando es obligatorio para los trabajos de shell',
'settings.cron.jobs.createJob': 'Nuevo trabajo programado',
'settings.cron.jobs.created': 'Trabajo creado',
'settings.cron.jobs.custom': 'Personalizado',
'settings.cron.jobs.desc': 'Descripción',
'settings.cron.jobs.edit': 'Editar',
'settings.cron.jobs.editJob': 'Editar trabajo programado',
'settings.cron.jobs.empty': 'No se encontraron tareas cron del core.',
'settings.cron.jobs.errorCreate': 'No se pudo crear el trabajo cron: {message}',
'settings.cron.jobs.errorLoadList':
'No se pudieron cargar los trabajos cron del núcleo: {message}',
'settings.cron.jobs.errorLoadRuns': 'No se pudo cargar el historial de ejecuciones: {message}',
'settings.cron.jobs.errorRemove': 'No se pudo eliminar el trabajo cron del núcleo: {message}',
'settings.cron.jobs.errorRun': 'No se pudo ejecutar el trabajo cron del núcleo: {message}',
'settings.cron.jobs.errorToggle': 'No se pudo actualizar el trabajo cron del núcleo: {message}',
'settings.cron.jobs.errorUpdate': 'No se pudo actualizar el trabajo cron: {message}',
'settings.cron.jobs.formAtLabel': 'Ejecutar a las',
'settings.cron.jobs.formCancel': 'Cancelar',
'settings.cron.jobs.formCommand': 'Comando de shell',
'settings.cron.jobs.formCommandPlaceholder': 'p. ej. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Crear',
'settings.cron.jobs.formCronCustom': 'Expresión personalizada',
'settings.cron.jobs.formCronCustomPlaceholder': 'p. ej. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Predefinido',
'settings.cron.jobs.formCronPreview': 'Ejecuciones: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'Eliminar tras la primera ejecución',
'settings.cron.jobs.formDelivery': 'Modo de entrega',
'settings.cron.jobs.formDeliveryNone': 'Ninguno (solo salida)',
'settings.cron.jobs.formDeliveryProactive': 'Proactivo (notificación push)',
'settings.cron.jobs.formError': 'No se pudo guardar el trabajo',
'settings.cron.jobs.formEveryLabel': 'Intervalo (milisegundos)',
'settings.cron.jobs.formEveryPlaceholder': 'p. ej. 3600000',
'settings.cron.jobs.formJobType': 'Tipo de trabajo',
'settings.cron.jobs.formJobTypeAgent': 'Agent (instrucción de IA)',
'settings.cron.jobs.formJobTypeShell': 'Comando de shell',
'settings.cron.jobs.formName': 'Nombre del trabajo',
'settings.cron.jobs.formNamePlaceholder': 'p. ej. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Instrucción del agente',
'settings.cron.jobs.formPromptPlaceholder': '¿Qué debe hacer el agente en cada ejecución?',
'settings.cron.jobs.formPromptRequired':
'La instrucción es obligatoria para los trabajos de agente',
'settings.cron.jobs.formSave': 'Guardar',
'settings.cron.jobs.formSaving': 'Guardando…',
'settings.cron.jobs.formScheduleAt': 'Una sola vez (ejecutar a las)',
'settings.cron.jobs.formScheduleCron': 'Recurrente (cron)',
'settings.cron.jobs.formScheduleEvery': 'Intervalo (cada N ms)',
'settings.cron.jobs.formScheduleRequired': 'La programación es obligatoria',
'settings.cron.jobs.formScheduleType': 'Tipo de programación',
'settings.cron.jobs.formSessionIsolated': 'Aislada (recomendado)',
'settings.cron.jobs.formSessionMain': 'Sesión principal',
'settings.cron.jobs.formSessionTarget': 'Sesión de destino',
'settings.cron.jobs.lastStatus': 'Último estado',
'settings.cron.jobs.loading': 'Cargando tareas cron...',
'settings.cron.jobs.loadingRuns': 'Cargando ejecuciones',
@@ -3117,7 +3166,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'Guardando…',
'settings.cron.jobs.schedule': 'Programación',
'settings.cron.jobs.title': 'Tareas cron del core',
'settings.cron.jobs.updated': 'Trabajo actualizado',
'settings.cron.jobs.viewRuns': 'Ver ejecuciones',
'settings.cron.schedule.every2hours': 'Cada 2 horas',
'settings.cron.schedule.every30min': 'Cada 30 minutos',
'settings.cron.schedule.every6hours': 'Cada 6 horas',
'settings.cron.schedule.everyHour': 'Cada hora',
'settings.cron.schedule.onceDaily': 'Una vez al día (9 a. m.)',
'settings.localModel.deviceCapability.active': 'Activo',
'settings.localModel.deviceCapability.appliedTier': 'Nivel aplicado',
'settings.localModel.deviceCapability.applying': 'Aplicando',
+54
View File
@@ -3114,8 +3114,56 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'Durée de vie de la session',
'settings.companion.screenCapture': "Capture d'écran",
'settings.companion.appContext': "Contexte de l'application",
'settings.cron.jobs.commandRequired': 'La commande est requise pour les tâches shell',
'settings.cron.jobs.createJob': 'Nouvelle tâche planifiée',
'settings.cron.jobs.created': 'Tâche créée',
'settings.cron.jobs.custom': 'Personnalisé',
'settings.cron.jobs.desc': 'Description',
'settings.cron.jobs.edit': 'Modifier',
'settings.cron.jobs.editJob': 'Modifier la tâche planifiée',
'settings.cron.jobs.empty': 'Aucune tâche cron core trouvée.',
'settings.cron.jobs.errorCreate': 'Échec de la création de la tâche cron : {message}',
'settings.cron.jobs.errorLoadList': 'Échec du chargement des tâches cron du cœur : {message}',
'settings.cron.jobs.errorLoadRuns':
"Échec du chargement de l'historique des exécutions : {message}",
'settings.cron.jobs.errorRemove': 'Échec de la suppression de la tâche cron du cœur : {message}',
'settings.cron.jobs.errorRun': "Échec de l'exécution de la tâche cron du cœur : {message}",
'settings.cron.jobs.errorToggle': 'Échec de la mise à jour de la tâche cron du cœur : {message}',
'settings.cron.jobs.errorUpdate': 'Échec de la mise à jour de la tâche cron : {message}',
'settings.cron.jobs.formAtLabel': 'Exécuter à',
'settings.cron.jobs.formCancel': 'Annuler',
'settings.cron.jobs.formCommand': 'Commande shell',
'settings.cron.jobs.formCommandPlaceholder': 'ex. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Créer',
'settings.cron.jobs.formCronCustom': 'Expression personnalisée',
'settings.cron.jobs.formCronCustomPlaceholder': 'ex. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Préréglage',
'settings.cron.jobs.formCronPreview': 'Exécutions : {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'Supprimer après la première exécution',
'settings.cron.jobs.formDelivery': 'Mode de distribution',
'settings.cron.jobs.formDeliveryNone': 'Aucun (sortie uniquement)',
'settings.cron.jobs.formDeliveryProactive': 'Proactif (notification push)',
'settings.cron.jobs.formError': "Échec de l'enregistrement de la tâche",
'settings.cron.jobs.formEveryLabel': 'Intervalle (millisecondes)',
'settings.cron.jobs.formEveryPlaceholder': 'ex. 3600000',
'settings.cron.jobs.formJobType': 'Type de tâche',
'settings.cron.jobs.formJobTypeAgent': 'Agent (invite IA)',
'settings.cron.jobs.formJobTypeShell': 'Commande shell',
'settings.cron.jobs.formName': 'Nom de la tâche',
'settings.cron.jobs.formNamePlaceholder': 'ex. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': "Invite de l'agent",
'settings.cron.jobs.formPromptPlaceholder': "Que doit faire l'agent à chaque exécution ?",
'settings.cron.jobs.formPromptRequired': "L'invite est requise pour les tâches d'agent",
'settings.cron.jobs.formSave': 'Enregistrer',
'settings.cron.jobs.formSaving': 'Enregistrement…',
'settings.cron.jobs.formScheduleAt': 'Unique (exécuter à)',
'settings.cron.jobs.formScheduleCron': 'Récurrent (cron)',
'settings.cron.jobs.formScheduleEvery': 'Intervalle (toutes les N ms)',
'settings.cron.jobs.formScheduleRequired': 'La planification est requise',
'settings.cron.jobs.formScheduleType': 'Type de planification',
'settings.cron.jobs.formSessionIsolated': 'Isolée (recommandé)',
'settings.cron.jobs.formSessionMain': 'Session principale',
'settings.cron.jobs.formSessionTarget': 'Session cible',
'settings.cron.jobs.lastStatus': 'Dernier statut',
'settings.cron.jobs.loading': 'Chargement des tâches cron…',
'settings.cron.jobs.loadingRuns': 'Chargement des exécutions',
@@ -3129,7 +3177,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'Enregistrement…',
'settings.cron.jobs.schedule': 'Planification',
'settings.cron.jobs.title': 'Tâches cron du core',
'settings.cron.jobs.updated': 'Tâche mise à jour',
'settings.cron.jobs.viewRuns': 'Voir les exécutions',
'settings.cron.schedule.every2hours': 'Toutes les 2 heures',
'settings.cron.schedule.every30min': 'Toutes les 30 minutes',
'settings.cron.schedule.every6hours': 'Toutes les 6 heures',
'settings.cron.schedule.everyHour': 'Toutes les heures',
'settings.cron.schedule.onceDaily': 'Une fois par jour (9 h)',
'settings.localModel.deviceCapability.active': 'Actif',
'settings.localModel.deviceCapability.appliedTier': 'Niveau appliqué',
'settings.localModel.deviceCapability.applying': 'Application en cours',
+53
View File
@@ -3054,8 +3054,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'सत्र टीटीएल',
'settings.companion.screenCapture': 'स्क्रीन कैप्चर',
'settings.companion.appContext': 'ऐप संदर्भ',
'settings.cron.jobs.commandRequired': 'शेल जॉब के लिए कमांड आवश्यक है',
'settings.cron.jobs.createJob': 'नई शेड्यूल्ड जॉब',
'settings.cron.jobs.created': 'जॉब बनाई गई',
'settings.cron.jobs.custom': 'कस्टम',
'settings.cron.jobs.desc': 'विवरण',
'settings.cron.jobs.edit': 'संपादित करें',
'settings.cron.jobs.editJob': 'शेड्यूल्ड जॉब संपादित करें',
'settings.cron.jobs.empty': 'कोई कोर cron job नहीं मिला।',
'settings.cron.jobs.errorCreate': 'cron जॉब बनाने में विफल: {message}',
'settings.cron.jobs.errorLoadList': 'कोर cron जॉब लोड करने में विफल: {message}',
'settings.cron.jobs.errorLoadRuns': 'रन इतिहास लोड करने में विफल: {message}',
'settings.cron.jobs.errorRemove': 'कोर cron जॉब हटाने में विफल: {message}',
'settings.cron.jobs.errorRun': 'कोर cron जॉब चलाने में विफल: {message}',
'settings.cron.jobs.errorToggle': 'कोर cron जॉब अपडेट करने में विफल: {message}',
'settings.cron.jobs.errorUpdate': 'cron जॉब अपडेट करने में विफल: {message}',
'settings.cron.jobs.formAtLabel': 'इस समय चलाएँ',
'settings.cron.jobs.formCancel': 'रद्द करें',
'settings.cron.jobs.formCommand': 'शेल कमांड',
'settings.cron.jobs.formCommandPlaceholder': 'उदा. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'बनाएँ',
'settings.cron.jobs.formCronCustom': 'कस्टम एक्सप्रेशन',
'settings.cron.jobs.formCronCustomPlaceholder': 'उदा. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'प्रीसेट',
'settings.cron.jobs.formCronPreview': 'चलता है: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'पहली बार चलने के बाद हटाएँ',
'settings.cron.jobs.formDelivery': 'डिलीवरी मोड',
'settings.cron.jobs.formDeliveryNone': 'कोई नहीं (केवल आउटपुट)',
'settings.cron.jobs.formDeliveryProactive': 'प्रोएक्टिव (पुश सूचना)',
'settings.cron.jobs.formError': 'जॉब सहेजने में विफल',
'settings.cron.jobs.formEveryLabel': 'अंतराल (मिलीसेकंड)',
'settings.cron.jobs.formEveryPlaceholder': 'उदा. 3600000',
'settings.cron.jobs.formJobType': 'जॉब प्रकार',
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI प्रॉम्प्ट)',
'settings.cron.jobs.formJobTypeShell': 'शेल कमांड',
'settings.cron.jobs.formName': 'जॉब नाम',
'settings.cron.jobs.formNamePlaceholder': 'उदा. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Agent प्रॉम्प्ट',
'settings.cron.jobs.formPromptPlaceholder': 'हर बार चलने पर agent को क्या करना चाहिए?',
'settings.cron.jobs.formPromptRequired': 'agent जॉब के लिए प्रॉम्प्ट आवश्यक है',
'settings.cron.jobs.formSave': 'सहेजें',
'settings.cron.jobs.formSaving': 'सहेजा जा रहा है…',
'settings.cron.jobs.formScheduleAt': 'एक-बार (इस समय चलाएँ)',
'settings.cron.jobs.formScheduleCron': 'आवर्ती (cron)',
'settings.cron.jobs.formScheduleEvery': 'अंतराल (हर N मि.से.)',
'settings.cron.jobs.formScheduleRequired': 'शेड्यूल आवश्यक है',
'settings.cron.jobs.formScheduleType': 'शेड्यूल प्रकार',
'settings.cron.jobs.formSessionIsolated': 'पृथक (अनुशंसित)',
'settings.cron.jobs.formSessionMain': 'मुख्य सत्र',
'settings.cron.jobs.formSessionTarget': 'सत्र लक्ष्य',
'settings.cron.jobs.lastStatus': 'आखिरी स्टेटस',
'settings.cron.jobs.loading': 'Cron jobs लोड हो रही हैं...',
'settings.cron.jobs.loadingRuns': 'रन लोड हो रहे हैं',
@@ -3069,7 +3116,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'सेव हो रहा है…',
'settings.cron.jobs.schedule': 'शेड्यूल',
'settings.cron.jobs.title': 'कोर Cron Jobs',
'settings.cron.jobs.updated': 'जॉब अपडेट की गई',
'settings.cron.jobs.viewRuns': 'रन देखें',
'settings.cron.schedule.every2hours': 'हर 2 घंटे',
'settings.cron.schedule.every30min': 'हर 30 मिनट',
'settings.cron.schedule.every6hours': 'हर 6 घंटे',
'settings.cron.schedule.everyHour': 'हर घंटे',
'settings.cron.schedule.onceDaily': 'दिन में एक बार (सुबह 9 बजे)',
'settings.localModel.deviceCapability.active': 'एक्टिव',
'settings.localModel.deviceCapability.appliedTier': 'लागू टियर',
'settings.localModel.deviceCapability.applying': 'लागू हो रहा है',
+53
View File
@@ -3060,8 +3060,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'Sesi TTL',
'settings.companion.screenCapture': 'Tangkapan Layar',
'settings.companion.appContext': 'Konteks Aplikasi',
'settings.cron.jobs.commandRequired': 'Perintah wajib diisi untuk tugas shell',
'settings.cron.jobs.createJob': 'Tugas Terjadwal Baru',
'settings.cron.jobs.created': 'Tugas dibuat',
'settings.cron.jobs.custom': 'Kustom',
'settings.cron.jobs.desc': 'Deskripsi',
'settings.cron.jobs.edit': 'Edit',
'settings.cron.jobs.editJob': 'Edit Tugas Terjadwal',
'settings.cron.jobs.empty': 'Tidak ada cron job core ditemukan.',
'settings.cron.jobs.errorCreate': 'Gagal membuat tugas cron: {message}',
'settings.cron.jobs.errorLoadList': 'Gagal memuat tugas cron inti: {message}',
'settings.cron.jobs.errorLoadRuns': 'Gagal memuat riwayat eksekusi: {message}',
'settings.cron.jobs.errorRemove': 'Gagal menghapus tugas cron inti: {message}',
'settings.cron.jobs.errorRun': 'Gagal menjalankan tugas cron inti: {message}',
'settings.cron.jobs.errorToggle': 'Gagal memperbarui tugas cron inti: {message}',
'settings.cron.jobs.errorUpdate': 'Gagal memperbarui tugas cron: {message}',
'settings.cron.jobs.formAtLabel': 'Jalankan pada',
'settings.cron.jobs.formCancel': 'Batal',
'settings.cron.jobs.formCommand': 'Perintah shell',
'settings.cron.jobs.formCommandPlaceholder': 'mis. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Buat',
'settings.cron.jobs.formCronCustom': 'Ekspresi kustom',
'settings.cron.jobs.formCronCustomPlaceholder': 'mis. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Preset',
'settings.cron.jobs.formCronPreview': 'Berjalan: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'Hapus setelah eksekusi pertama',
'settings.cron.jobs.formDelivery': 'Mode pengiriman',
'settings.cron.jobs.formDeliveryNone': 'Tidak ada (hanya output)',
'settings.cron.jobs.formDeliveryProactive': 'Proaktif (notifikasi push)',
'settings.cron.jobs.formError': 'Gagal menyimpan tugas',
'settings.cron.jobs.formEveryLabel': 'Interval (milidetik)',
'settings.cron.jobs.formEveryPlaceholder': 'mis. 3600000',
'settings.cron.jobs.formJobType': 'Jenis tugas',
'settings.cron.jobs.formJobTypeAgent': 'Agent (prompt AI)',
'settings.cron.jobs.formJobTypeShell': 'Perintah shell',
'settings.cron.jobs.formName': 'Nama tugas',
'settings.cron.jobs.formNamePlaceholder': 'mis. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Prompt agent',
'settings.cron.jobs.formPromptPlaceholder': 'Apa yang harus dilakukan agent setiap eksekusi?',
'settings.cron.jobs.formPromptRequired': 'Prompt wajib diisi untuk tugas agent',
'settings.cron.jobs.formSave': 'Simpan',
'settings.cron.jobs.formSaving': 'Menyimpan…',
'settings.cron.jobs.formScheduleAt': 'Sekali jalan (jalankan pada)',
'settings.cron.jobs.formScheduleCron': 'Berulang (cron)',
'settings.cron.jobs.formScheduleEvery': 'Interval (setiap N md)',
'settings.cron.jobs.formScheduleRequired': 'Jadwal wajib diisi',
'settings.cron.jobs.formScheduleType': 'Jenis jadwal',
'settings.cron.jobs.formSessionIsolated': 'Terisolasi (disarankan)',
'settings.cron.jobs.formSessionMain': 'Sesi utama',
'settings.cron.jobs.formSessionTarget': 'Target sesi',
'settings.cron.jobs.lastStatus': 'Status terakhir',
'settings.cron.jobs.loading': 'Memuat cron job...',
'settings.cron.jobs.loadingRuns': 'Memuat run',
@@ -3075,7 +3122,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'Menyimpan...',
'settings.cron.jobs.schedule': 'Jadwal',
'settings.cron.jobs.title': 'Cron Job Core',
'settings.cron.jobs.updated': 'Tugas diperbarui',
'settings.cron.jobs.viewRuns': 'Lihat run',
'settings.cron.schedule.every2hours': 'Setiap 2 jam',
'settings.cron.schedule.every30min': 'Setiap 30 menit',
'settings.cron.schedule.every6hours': 'Setiap 6 jam',
'settings.cron.schedule.everyHour': 'Setiap jam',
'settings.cron.schedule.onceDaily': 'Sekali sehari (9 pagi)',
'settings.localModel.deviceCapability.active': 'Aktif',
'settings.localModel.deviceCapability.appliedTier': 'Tingkat yang diterapkan',
'settings.localModel.deviceCapability.applying': 'Menerapkan',
+54
View File
@@ -3097,8 +3097,56 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'Sessione TTL',
'settings.companion.screenCapture': 'Acquisizione schermata',
'settings.companion.appContext': 'Contesto app',
'settings.cron.jobs.commandRequired': 'Il comando è obbligatorio per i job shell',
'settings.cron.jobs.createJob': 'Nuovo job pianificato',
'settings.cron.jobs.created': 'Job creato',
'settings.cron.jobs.custom': 'Personalizzato',
'settings.cron.jobs.desc': 'Descrizione',
'settings.cron.jobs.edit': 'Modifica',
'settings.cron.jobs.editJob': 'Modifica job pianificato',
'settings.cron.jobs.empty': 'Nessun cron job core trovato.',
'settings.cron.jobs.errorCreate': 'Impossibile creare il job cron: {message}',
'settings.cron.jobs.errorLoadList': 'Impossibile caricare i job cron del core: {message}',
'settings.cron.jobs.errorLoadRuns':
'Impossibile caricare la cronologia delle esecuzioni: {message}',
'settings.cron.jobs.errorRemove': 'Impossibile rimuovere il job cron del core: {message}',
'settings.cron.jobs.errorRun': 'Impossibile eseguire il job cron del core: {message}',
'settings.cron.jobs.errorToggle': 'Impossibile aggiornare il job cron del core: {message}',
'settings.cron.jobs.errorUpdate': 'Impossibile aggiornare il job cron: {message}',
'settings.cron.jobs.formAtLabel': 'Esegui alle',
'settings.cron.jobs.formCancel': 'Annulla',
'settings.cron.jobs.formCommand': 'Comando shell',
'settings.cron.jobs.formCommandPlaceholder': 'es. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Crea',
'settings.cron.jobs.formCronCustom': 'Espressione personalizzata',
'settings.cron.jobs.formCronCustomPlaceholder': 'es. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Preimpostazione',
'settings.cron.jobs.formCronPreview': 'Esecuzioni: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'Elimina dopo la prima esecuzione',
'settings.cron.jobs.formDelivery': 'Modalità di consegna',
'settings.cron.jobs.formDeliveryNone': 'Nessuna (solo output)',
'settings.cron.jobs.formDeliveryProactive': 'Proattiva (notifica push)',
'settings.cron.jobs.formError': 'Impossibile salvare il job',
'settings.cron.jobs.formEveryLabel': 'Intervallo (millisecondi)',
'settings.cron.jobs.formEveryPlaceholder': 'es. 3600000',
'settings.cron.jobs.formJobType': 'Tipo di job',
'settings.cron.jobs.formJobTypeAgent': 'Agent (prompt AI)',
'settings.cron.jobs.formJobTypeShell': 'Comando shell',
'settings.cron.jobs.formName': 'Nome del job',
'settings.cron.jobs.formNamePlaceholder': 'es. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': "Prompt dell'agent",
'settings.cron.jobs.formPromptPlaceholder': "Cosa deve fare l'agent a ogni esecuzione?",
'settings.cron.jobs.formPromptRequired': 'Il prompt è obbligatorio per i job agent',
'settings.cron.jobs.formSave': 'Salva',
'settings.cron.jobs.formSaving': 'Salvataggio…',
'settings.cron.jobs.formScheduleAt': 'Una tantum (esegui alle)',
'settings.cron.jobs.formScheduleCron': 'Ricorrente (cron)',
'settings.cron.jobs.formScheduleEvery': 'Intervallo (ogni N ms)',
'settings.cron.jobs.formScheduleRequired': 'La pianificazione è obbligatoria',
'settings.cron.jobs.formScheduleType': 'Tipo di pianificazione',
'settings.cron.jobs.formSessionIsolated': 'Isolata (consigliata)',
'settings.cron.jobs.formSessionMain': 'Sessione principale',
'settings.cron.jobs.formSessionTarget': 'Sessione di destinazione',
'settings.cron.jobs.lastStatus': 'Ultimo stato',
'settings.cron.jobs.loading': 'Caricamento cron job...',
'settings.cron.jobs.loadingRuns': 'Caricamento esecuzioni',
@@ -3112,7 +3160,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'Salvataggio…',
'settings.cron.jobs.schedule': 'Pianificazione',
'settings.cron.jobs.title': 'Cron Job core',
'settings.cron.jobs.updated': 'Job aggiornato',
'settings.cron.jobs.viewRuns': 'Visualizza esecuzioni',
'settings.cron.schedule.every2hours': 'Ogni 2 ore',
'settings.cron.schedule.every30min': 'Ogni 30 minuti',
'settings.cron.schedule.every6hours': 'Ogni 6 ore',
'settings.cron.schedule.everyHour': 'Ogni ora',
'settings.cron.schedule.onceDaily': 'Una volta al giorno (9:00)',
'settings.localModel.deviceCapability.active': 'Attivo',
'settings.localModel.deviceCapability.appliedTier': 'Tier applicato',
'settings.localModel.deviceCapability.applying': 'Applicazione',
+53
View File
@@ -3026,8 +3026,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': '세션 TTL',
'settings.companion.screenCapture': '화면 캡처',
'settings.companion.appContext': '앱 컨텍스트',
'settings.cron.jobs.commandRequired': '셸 작업에는 명령어가 필요합니다',
'settings.cron.jobs.createJob': '새 예약 작업',
'settings.cron.jobs.created': '작업이 생성되었습니다',
'settings.cron.jobs.custom': '사용자 지정',
'settings.cron.jobs.desc': '설명',
'settings.cron.jobs.edit': '편집',
'settings.cron.jobs.editJob': '예약 작업 편집',
'settings.cron.jobs.empty': '코어 cron 작업을 찾을 수 없습니다.',
'settings.cron.jobs.errorCreate': 'cron 작업 생성에 실패했습니다: {message}',
'settings.cron.jobs.errorLoadList': '코어 cron 작업을 불러오지 못했습니다: {message}',
'settings.cron.jobs.errorLoadRuns': '실행 기록을 불러오지 못했습니다: {message}',
'settings.cron.jobs.errorRemove': '코어 cron 작업을 삭제하지 못했습니다: {message}',
'settings.cron.jobs.errorRun': '코어 cron 작업을 실행하지 못했습니다: {message}',
'settings.cron.jobs.errorToggle': '코어 cron 작업을 업데이트하지 못했습니다: {message}',
'settings.cron.jobs.errorUpdate': 'cron 작업 업데이트에 실패했습니다: {message}',
'settings.cron.jobs.formAtLabel': '실행 시각',
'settings.cron.jobs.formCancel': '취소',
'settings.cron.jobs.formCommand': '셸 명령어',
'settings.cron.jobs.formCommandPlaceholder': '예: curl https://example.com/health',
'settings.cron.jobs.formCreate': '생성',
'settings.cron.jobs.formCronCustom': '사용자 지정 표현식',
'settings.cron.jobs.formCronCustomPlaceholder': '예: */30 * * * *',
'settings.cron.jobs.formCronPreset': '프리셋',
'settings.cron.jobs.formCronPreview': '실행: {preview}',
'settings.cron.jobs.formDeleteAfterRun': '첫 실행 후 삭제',
'settings.cron.jobs.formDelivery': '전달 방식',
'settings.cron.jobs.formDeliveryNone': '없음 (출력만)',
'settings.cron.jobs.formDeliveryProactive': '선제적 (푸시 알림)',
'settings.cron.jobs.formError': '작업 저장에 실패했습니다',
'settings.cron.jobs.formEveryLabel': '간격 (밀리초)',
'settings.cron.jobs.formEveryPlaceholder': '예: 3600000',
'settings.cron.jobs.formJobType': '작업 유형',
'settings.cron.jobs.formJobTypeAgent': 'Agent (AI 프롬프트)',
'settings.cron.jobs.formJobTypeShell': '셸 명령어',
'settings.cron.jobs.formName': '작업 이름',
'settings.cron.jobs.formNamePlaceholder': '예: daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Agent 프롬프트',
'settings.cron.jobs.formPromptPlaceholder': '매 실행마다 에이전트가 무엇을 해야 하나요?',
'settings.cron.jobs.formPromptRequired': '에이전트 작업에는 프롬프트가 필요합니다',
'settings.cron.jobs.formSave': '저장',
'settings.cron.jobs.formSaving': '저장 중…',
'settings.cron.jobs.formScheduleAt': '일회성 (실행 시각)',
'settings.cron.jobs.formScheduleCron': '반복 (cron)',
'settings.cron.jobs.formScheduleEvery': '간격 (N밀리초마다)',
'settings.cron.jobs.formScheduleRequired': '일정이 필요합니다',
'settings.cron.jobs.formScheduleType': '일정 유형',
'settings.cron.jobs.formSessionIsolated': '격리됨 (권장)',
'settings.cron.jobs.formSessionMain': '메인 세션',
'settings.cron.jobs.formSessionTarget': '세션 대상',
'settings.cron.jobs.lastStatus': '마지막 상태',
'settings.cron.jobs.loading': 'cron 작업 불러오는 중...',
'settings.cron.jobs.loadingRuns': '실행 기록 불러오는 중',
@@ -3041,7 +3088,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': '저장 중…',
'settings.cron.jobs.schedule': '일정',
'settings.cron.jobs.title': '코어 Cron 작업',
'settings.cron.jobs.updated': '작업이 업데이트되었습니다',
'settings.cron.jobs.viewRuns': '실행 기록 보기',
'settings.cron.schedule.every2hours': '2시간마다',
'settings.cron.schedule.every30min': '30분마다',
'settings.cron.schedule.every6hours': '6시간마다',
'settings.cron.schedule.everyHour': '매시간',
'settings.cron.schedule.onceDaily': '매일 1회 (오전 9시)',
'settings.localModel.deviceCapability.active': '활성',
'settings.localModel.deviceCapability.appliedTier': '적용된 티어',
'settings.localModel.deviceCapability.applying': '적용 중',
+53
View File
@@ -3104,8 +3104,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'TTL sesji',
'settings.companion.screenCapture': 'Przechwytywanie ekranu',
'settings.companion.appContext': 'Kontekst aplikacji',
'settings.cron.jobs.commandRequired': 'Polecenie jest wymagane dla zadań typu shell',
'settings.cron.jobs.createJob': 'Nowe zaplanowane zadanie',
'settings.cron.jobs.created': 'Utworzono zadanie',
'settings.cron.jobs.custom': 'Niestandardowe',
'settings.cron.jobs.desc': 'Zadania cykliczne uruchamiane przez harmonogram rdzenia.',
'settings.cron.jobs.edit': 'Edytuj',
'settings.cron.jobs.editJob': 'Edytuj zaplanowane zadanie',
'settings.cron.jobs.empty': 'Nie znaleziono zadań cron rdzenia.',
'settings.cron.jobs.errorCreate': 'Nie udało się utworzyć zadania cron: {message}',
'settings.cron.jobs.errorLoadList': 'Nie udało się załadować zadań cron rdzenia: {message}',
'settings.cron.jobs.errorLoadRuns': 'Nie udało się załadować historii uruchomień: {message}',
'settings.cron.jobs.errorRemove': 'Nie udało się usunąć zadania cron rdzenia: {message}',
'settings.cron.jobs.errorRun': 'Nie udało się uruchomić zadania cron rdzenia: {message}',
'settings.cron.jobs.errorToggle': 'Nie udało się zaktualizować zadania cron rdzenia: {message}',
'settings.cron.jobs.errorUpdate': 'Nie udało się zaktualizować zadania cron: {message}',
'settings.cron.jobs.formAtLabel': 'Uruchom o',
'settings.cron.jobs.formCancel': 'Anuluj',
'settings.cron.jobs.formCommand': 'Polecenie shell',
'settings.cron.jobs.formCommandPlaceholder': 'np. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Utwórz',
'settings.cron.jobs.formCronCustom': 'Wyrażenie niestandardowe',
'settings.cron.jobs.formCronCustomPlaceholder': 'np. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Ustawienie wstępne',
'settings.cron.jobs.formCronPreview': 'Uruchomienia: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'Usuń po pierwszym uruchomieniu',
'settings.cron.jobs.formDelivery': 'Tryb dostarczania',
'settings.cron.jobs.formDeliveryNone': 'Brak (tylko wynik)',
'settings.cron.jobs.formDeliveryProactive': 'Proaktywny (powiadomienie push)',
'settings.cron.jobs.formError': 'Nie udało się zapisać zadania',
'settings.cron.jobs.formEveryLabel': 'Interwał (milisekundy)',
'settings.cron.jobs.formEveryPlaceholder': 'np. 3600000',
'settings.cron.jobs.formJobType': 'Typ zadania',
'settings.cron.jobs.formJobTypeAgent': 'Agent (zapytanie AI)',
'settings.cron.jobs.formJobTypeShell': 'Polecenie shell',
'settings.cron.jobs.formName': 'Nazwa zadania',
'settings.cron.jobs.formNamePlaceholder': 'np. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Zapytanie agenta',
'settings.cron.jobs.formPromptPlaceholder': 'Co agent ma robić przy każdym uruchomieniu?',
'settings.cron.jobs.formPromptRequired': 'Zapytanie jest wymagane dla zadań agenta',
'settings.cron.jobs.formSave': 'Zapisz',
'settings.cron.jobs.formSaving': 'Zapisywanie…',
'settings.cron.jobs.formScheduleAt': 'Jednorazowe (uruchom o)',
'settings.cron.jobs.formScheduleCron': 'Cykliczne (cron)',
'settings.cron.jobs.formScheduleEvery': 'Interwał (co N ms)',
'settings.cron.jobs.formScheduleRequired': 'Harmonogram jest wymagany',
'settings.cron.jobs.formScheduleType': 'Typ harmonogramu',
'settings.cron.jobs.formSessionIsolated': 'Izolowana (zalecane)',
'settings.cron.jobs.formSessionMain': 'Sesja główna',
'settings.cron.jobs.formSessionTarget': 'Docelowa sesja',
'settings.cron.jobs.lastStatus': 'Ostatni status',
'settings.cron.jobs.loading': 'Wczytywanie zadań cron...',
'settings.cron.jobs.loadingRuns': 'Wczytywanie uruchomień',
@@ -3119,7 +3166,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'Zapisywanie…',
'settings.cron.jobs.schedule': 'Harmonogram',
'settings.cron.jobs.title': 'Zadania cron rdzenia',
'settings.cron.jobs.updated': 'Zaktualizowano zadanie',
'settings.cron.jobs.viewRuns': 'Zobacz uruchomienia',
'settings.cron.schedule.every2hours': 'Co 2 godziny',
'settings.cron.schedule.every30min': 'Co 30 minut',
'settings.cron.schedule.every6hours': 'Co 6 godzin',
'settings.cron.schedule.everyHour': 'Co godzinę',
'settings.cron.schedule.onceDaily': 'Raz dziennie (9:00)',
'settings.localModel.deviceCapability.active': 'Aktywne',
'settings.localModel.deviceCapability.appliedTier': 'Zastosowany poziom',
'settings.localModel.deviceCapability.applying': 'Stosowanie',
+53
View File
@@ -3100,8 +3100,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'Sessão TTL',
'settings.companion.screenCapture': 'Captura de tela',
'settings.companion.appContext': 'Contexto do aplicativo',
'settings.cron.jobs.commandRequired': 'O comando é obrigatório para tarefas shell',
'settings.cron.jobs.createJob': 'Nova tarefa agendada',
'settings.cron.jobs.created': 'Tarefa criada',
'settings.cron.jobs.custom': 'Personalizado',
'settings.cron.jobs.desc': 'Descrição',
'settings.cron.jobs.edit': 'Editar',
'settings.cron.jobs.editJob': 'Editar tarefa agendada',
'settings.cron.jobs.empty': 'Nenhuma tarefa cron do core encontrada.',
'settings.cron.jobs.errorCreate': 'Falha ao criar tarefa cron: {message}',
'settings.cron.jobs.errorLoadList': 'Falha ao carregar tarefas cron do núcleo: {message}',
'settings.cron.jobs.errorLoadRuns': 'Falha ao carregar o histórico de execuções: {message}',
'settings.cron.jobs.errorRemove': 'Falha ao remover tarefa cron do núcleo: {message}',
'settings.cron.jobs.errorRun': 'Falha ao executar tarefa cron do núcleo: {message}',
'settings.cron.jobs.errorToggle': 'Falha ao atualizar tarefa cron do núcleo: {message}',
'settings.cron.jobs.errorUpdate': 'Falha ao atualizar tarefa cron: {message}',
'settings.cron.jobs.formAtLabel': 'Executar às',
'settings.cron.jobs.formCancel': 'Cancelar',
'settings.cron.jobs.formCommand': 'Comando shell',
'settings.cron.jobs.formCommandPlaceholder': 'ex.: curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Criar',
'settings.cron.jobs.formCronCustom': 'Expressão personalizada',
'settings.cron.jobs.formCronCustomPlaceholder': 'ex.: */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Predefinição',
'settings.cron.jobs.formCronPreview': 'Execuções: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'Excluir após a primeira execução',
'settings.cron.jobs.formDelivery': 'Modo de entrega',
'settings.cron.jobs.formDeliveryNone': 'Nenhum (apenas saída)',
'settings.cron.jobs.formDeliveryProactive': 'Proativo (notificação push)',
'settings.cron.jobs.formError': 'Falha ao salvar a tarefa',
'settings.cron.jobs.formEveryLabel': 'Intervalo (milissegundos)',
'settings.cron.jobs.formEveryPlaceholder': 'ex.: 3600000',
'settings.cron.jobs.formJobType': 'Tipo de tarefa',
'settings.cron.jobs.formJobTypeAgent': 'Agent (prompt de IA)',
'settings.cron.jobs.formJobTypeShell': 'Comando shell',
'settings.cron.jobs.formName': 'Nome da tarefa',
'settings.cron.jobs.formNamePlaceholder': 'ex.: daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Prompt do Agent',
'settings.cron.jobs.formPromptPlaceholder': 'O que o agent deve fazer em cada execução?',
'settings.cron.jobs.formPromptRequired': 'O prompt é obrigatório para tarefas de agent',
'settings.cron.jobs.formSave': 'Salvar',
'settings.cron.jobs.formSaving': 'Salvando…',
'settings.cron.jobs.formScheduleAt': 'Única (executar às)',
'settings.cron.jobs.formScheduleCron': 'Recorrente (cron)',
'settings.cron.jobs.formScheduleEvery': 'Intervalo (a cada N ms)',
'settings.cron.jobs.formScheduleRequired': 'O agendamento é obrigatório',
'settings.cron.jobs.formScheduleType': 'Tipo de agendamento',
'settings.cron.jobs.formSessionIsolated': 'Isolada (recomendada)',
'settings.cron.jobs.formSessionMain': 'Sessão principal',
'settings.cron.jobs.formSessionTarget': 'Sessão de destino',
'settings.cron.jobs.lastStatus': 'Último status',
'settings.cron.jobs.loading': 'Carregando tarefas cron...',
'settings.cron.jobs.loadingRuns': 'Carregando execuções',
@@ -3115,7 +3162,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'Salvando…',
'settings.cron.jobs.schedule': 'Agendamento',
'settings.cron.jobs.title': 'Tarefas Cron do Core',
'settings.cron.jobs.updated': 'Tarefa atualizada',
'settings.cron.jobs.viewRuns': 'Ver execuções',
'settings.cron.schedule.every2hours': 'A cada 2 horas',
'settings.cron.schedule.every30min': 'A cada 30 minutos',
'settings.cron.schedule.every6hours': 'A cada 6 horas',
'settings.cron.schedule.everyHour': 'A cada hora',
'settings.cron.schedule.onceDaily': 'Uma vez por dia (9h)',
'settings.localModel.deviceCapability.active': 'Ativo',
'settings.localModel.deviceCapability.appliedTier': 'Nível aplicado',
'settings.localModel.deviceCapability.applying': 'Aplicando',
+53
View File
@@ -3071,8 +3071,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': 'TTL сеанса',
'settings.companion.screenCapture': 'Снимок экрана',
'settings.companion.appContext': 'Контекст приложения',
'settings.cron.jobs.commandRequired': 'Для shell-задач требуется команда',
'settings.cron.jobs.createJob': 'Новая запланированная задача',
'settings.cron.jobs.created': 'Задача создана',
'settings.cron.jobs.custom': 'Свой вариант',
'settings.cron.jobs.desc': 'Описание',
'settings.cron.jobs.edit': 'Изменить',
'settings.cron.jobs.editJob': 'Изменить запланированную задачу',
'settings.cron.jobs.empty': 'Заданий по расписанию не найдено.',
'settings.cron.jobs.errorCreate': 'Не удалось создать cron-задачу: {message}',
'settings.cron.jobs.errorLoadList': 'Не удалось загрузить cron-задачи ядра: {message}',
'settings.cron.jobs.errorLoadRuns': 'Не удалось загрузить историю запусков: {message}',
'settings.cron.jobs.errorRemove': 'Не удалось удалить cron-задачу ядра: {message}',
'settings.cron.jobs.errorRun': 'Не удалось запустить cron-задачу ядра: {message}',
'settings.cron.jobs.errorToggle': 'Не удалось обновить cron-задачу ядра: {message}',
'settings.cron.jobs.errorUpdate': 'Не удалось обновить cron-задачу: {message}',
'settings.cron.jobs.formAtLabel': 'Запустить в',
'settings.cron.jobs.formCancel': 'Отмена',
'settings.cron.jobs.formCommand': 'Shell-команда',
'settings.cron.jobs.formCommandPlaceholder': 'напр. curl https://example.com/health',
'settings.cron.jobs.formCreate': 'Создать',
'settings.cron.jobs.formCronCustom': 'Своё выражение',
'settings.cron.jobs.formCronCustomPlaceholder': 'напр. */30 * * * *',
'settings.cron.jobs.formCronPreset': 'Шаблон',
'settings.cron.jobs.formCronPreview': 'Запуски: {preview}',
'settings.cron.jobs.formDeleteAfterRun': 'Удалить после первого запуска',
'settings.cron.jobs.formDelivery': 'Режим доставки',
'settings.cron.jobs.formDeliveryNone': 'Нет (только вывод)',
'settings.cron.jobs.formDeliveryProactive': 'Проактивный (push-уведомление)',
'settings.cron.jobs.formError': 'Не удалось сохранить задачу',
'settings.cron.jobs.formEveryLabel': 'Интервал (миллисекунды)',
'settings.cron.jobs.formEveryPlaceholder': 'напр. 3600000',
'settings.cron.jobs.formJobType': 'Тип задачи',
'settings.cron.jobs.formJobTypeAgent': 'Agent (запрос к ИИ)',
'settings.cron.jobs.formJobTypeShell': 'Shell-команда',
'settings.cron.jobs.formName': 'Название задачи',
'settings.cron.jobs.formNamePlaceholder': 'напр. daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Запрос для агента',
'settings.cron.jobs.formPromptPlaceholder': 'Что агент должен делать при каждом запуске?',
'settings.cron.jobs.formPromptRequired': 'Для задач агента требуется запрос',
'settings.cron.jobs.formSave': 'Сохранить',
'settings.cron.jobs.formSaving': 'Сохранение…',
'settings.cron.jobs.formScheduleAt': 'Однократно (запуск в)',
'settings.cron.jobs.formScheduleCron': 'Повторяющаяся (cron)',
'settings.cron.jobs.formScheduleEvery': 'Интервал (каждые N мс)',
'settings.cron.jobs.formScheduleRequired': 'Требуется расписание',
'settings.cron.jobs.formScheduleType': 'Тип расписания',
'settings.cron.jobs.formSessionIsolated': 'Изолированная (рекомендуется)',
'settings.cron.jobs.formSessionMain': 'Основная сессия',
'settings.cron.jobs.formSessionTarget': 'Целевая сессия',
'settings.cron.jobs.lastStatus': 'Последний статус',
'settings.cron.jobs.loading': 'Загрузка заданий...',
'settings.cron.jobs.loadingRuns': 'Загрузка запусков',
@@ -3086,7 +3133,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': 'Сохранение…',
'settings.cron.jobs.schedule': 'Расписание',
'settings.cron.jobs.title': 'Задания ядра по расписанию',
'settings.cron.jobs.updated': 'Задача обновлена',
'settings.cron.jobs.viewRuns': 'Посмотреть запуски',
'settings.cron.schedule.every2hours': 'Каждые 2 часа',
'settings.cron.schedule.every30min': 'Каждые 30 минут',
'settings.cron.schedule.every6hours': 'Каждые 6 часов',
'settings.cron.schedule.everyHour': 'Каждый час',
'settings.cron.schedule.onceDaily': 'Раз в день (9:00)',
'settings.localModel.deviceCapability.active': 'Активно',
'settings.localModel.deviceCapability.appliedTier': 'Применённый уровень',
'settings.localModel.deviceCapability.applying': 'Применение',
+53
View File
@@ -2908,8 +2908,55 @@ const messages: TranslationMap = {
'settings.companion.sessionTtl': '会话生存时间',
'settings.companion.screenCapture': '屏幕截图',
'settings.companion.appContext': '应用程序上下文',
'settings.cron.jobs.commandRequired': 'Shell 任务必须填写命令',
'settings.cron.jobs.createJob': '新建定时任务',
'settings.cron.jobs.created': '任务已创建',
'settings.cron.jobs.custom': '自定义',
'settings.cron.jobs.desc': '管理核心调度器中的定时任务',
'settings.cron.jobs.edit': '编辑',
'settings.cron.jobs.editJob': '编辑定时任务',
'settings.cron.jobs.empty': '未找到核心定时任务。',
'settings.cron.jobs.errorCreate': '创建 cron 任务失败:{message}',
'settings.cron.jobs.errorLoadList': '加载核心 cron 任务失败:{message}',
'settings.cron.jobs.errorLoadRuns': '加载运行历史失败:{message}',
'settings.cron.jobs.errorRemove': '删除核心 cron 任务失败:{message}',
'settings.cron.jobs.errorRun': '运行核心 cron 任务失败:{message}',
'settings.cron.jobs.errorToggle': '更新核心 cron 任务失败:{message}',
'settings.cron.jobs.errorUpdate': '更新 cron 任务失败:{message}',
'settings.cron.jobs.formAtLabel': '运行时间',
'settings.cron.jobs.formCancel': '取消',
'settings.cron.jobs.formCommand': 'Shell 命令',
'settings.cron.jobs.formCommandPlaceholder': '例如 curl https://example.com/health',
'settings.cron.jobs.formCreate': '创建',
'settings.cron.jobs.formCronCustom': '自定义表达式',
'settings.cron.jobs.formCronCustomPlaceholder': '例如 */30 * * * *',
'settings.cron.jobs.formCronPreset': '预设',
'settings.cron.jobs.formCronPreview': '运行时间:{preview}',
'settings.cron.jobs.formDeleteAfterRun': '首次运行后删除',
'settings.cron.jobs.formDelivery': '投递方式',
'settings.cron.jobs.formDeliveryNone': '无(仅输出)',
'settings.cron.jobs.formDeliveryProactive': '主动(推送通知)',
'settings.cron.jobs.formError': '保存任务失败',
'settings.cron.jobs.formEveryLabel': '间隔(毫秒)',
'settings.cron.jobs.formEveryPlaceholder': '例如 3600000',
'settings.cron.jobs.formJobType': '任务类型',
'settings.cron.jobs.formJobTypeAgent': 'AgentAI 提示词)',
'settings.cron.jobs.formJobTypeShell': 'Shell 命令',
'settings.cron.jobs.formName': '任务名称',
'settings.cron.jobs.formNamePlaceholder': '例如 daily-report, cleanup-task',
'settings.cron.jobs.formPrompt': 'Agent 提示词',
'settings.cron.jobs.formPromptPlaceholder': '每次运行时 Agent 应执行什么?',
'settings.cron.jobs.formPromptRequired': 'Agent 任务必须填写提示词',
'settings.cron.jobs.formSave': '保存',
'settings.cron.jobs.formSaving': '保存中…',
'settings.cron.jobs.formScheduleAt': '一次性(在指定时间运行)',
'settings.cron.jobs.formScheduleCron': '周期性(cron',
'settings.cron.jobs.formScheduleEvery': '间隔(每 N 毫秒)',
'settings.cron.jobs.formScheduleRequired': '必须设置计划',
'settings.cron.jobs.formScheduleType': '计划类型',
'settings.cron.jobs.formSessionIsolated': '隔离(推荐)',
'settings.cron.jobs.formSessionMain': '主会话',
'settings.cron.jobs.formSessionTarget': '会话目标',
'settings.cron.jobs.lastStatus': '上次状态',
'settings.cron.jobs.loading': '正在加载定时任务...',
'settings.cron.jobs.loadingRuns': '加载运行记录中',
@@ -2923,7 +2970,13 @@ const messages: TranslationMap = {
'settings.cron.jobs.saving': '保存中…',
'settings.cron.jobs.schedule': '计划',
'settings.cron.jobs.title': '核心定时任务',
'settings.cron.jobs.updated': '任务已更新',
'settings.cron.jobs.viewRuns': '查看运行记录',
'settings.cron.schedule.every2hours': '每 2 小时',
'settings.cron.schedule.every30min': '每 30 分钟',
'settings.cron.schedule.every6hours': '每 6 小时',
'settings.cron.schedule.everyHour': '每小时',
'settings.cron.schedule.onceDaily': '每天一次(上午 9 点)',
'settings.localModel.deviceCapability.active': '活跃',
'settings.localModel.deviceCapability.appliedTier': '已应用档位',
'settings.localModel.deviceCapability.applying': '应用中',
@@ -4,7 +4,7 @@ import { Outlet, useNavigate } from 'react-router-dom';
import { setWalkthroughPending } from '../../components/walkthrough/AppWalkthrough';
import { useCoreState } from '../../providers/CoreStateProvider';
import { trackEvent } from '../../services/analytics';
import { getDefaultEnabledTools } from '../../utils/toolDefinitions';
import { getDefaultEnabledTools, getEnabledRustToolNames } from '../../utils/toolDefinitions';
import BetaBanner from './components/BetaBanner';
import { OnboardingContext, type OnboardingDraft } from './OnboardingContext';
@@ -34,7 +34,7 @@ const OnboardingLayout = () => {
snapshot.localState.onboardingTasks?.accessibilityPermissionGranted ?? false,
localModelConsentGiven: false,
localModelDownloadStarted: false,
enabledTools: getDefaultEnabledTools(),
enabledTools: getEnabledRustToolNames(getDefaultEnabledTools()),
connectedSources: draft.connectedSources,
updatedAtMs: Date.now(),
});
@@ -40,7 +40,10 @@ vi.mock('../../../services/chatService', () => ({
chatSend: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../../utils/toolDefinitions', () => ({ getDefaultEnabledTools: vi.fn(() => []) }));
vi.mock('../../../utils/toolDefinitions', () => ({
getDefaultEnabledTools: vi.fn(() => []),
getEnabledRustToolNames: vi.fn((ids: string[]) => ids),
}));
vi.mock('../components/BetaBanner', () => ({ default: () => <div data-testid="beta-banner" /> }));
+47 -1
View File
@@ -41,6 +41,7 @@ use crate::openhuman::tools::Tool;
use crate::openhuman::util::truncate_with_ellipsis;
use anyhow::Result;
use std::borrow::Cow;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
@@ -55,6 +56,48 @@ use std::sync::Arc;
/// failure mode this guards against.
use super::turn_checkpoint::{assistant_message_has_tool_calls, MAX_ITER_CHECKPOINT_INSTRUCTION};
/// Built-in direct tools that the orchestrator should call by name, not via
/// `run_skill`.
const DIRECT_TOOL_NAMES: &[&str] = &[
"cron_add",
"cron_list",
"cron_remove",
"cron_update",
"cron_run",
"cron_runs",
"current_time",
];
/// Recovery shim for legacy/wrong-model calls of the form:
/// `run_skill({skill_id: "<built-in tool>", inputs: {...}})`.
///
/// When this pattern appears, rewrite it into a direct tool call so the turn
/// can proceed without a manual retry.
fn normalize_tool_call<'a>(call: &'a ParsedToolCall) -> Cow<'a, ParsedToolCall> {
if call.name != "run_skill" {
return Cow::Borrowed(call);
}
let Some(skill_id) = call.arguments.get("skill_id").and_then(|v| v.as_str()) else {
return Cow::Borrowed(call);
};
if !DIRECT_TOOL_NAMES.contains(&skill_id) {
return Cow::Borrowed(call);
}
let Some(inputs) = call.arguments.get("inputs").and_then(|v| v.as_object()) else {
return Cow::Borrowed(call);
};
log::warn!(
"[agent_loop] rewrote legacy run_skill->{} call into direct tool invocation",
skill_id
);
Cow::Owned(ParsedToolCall {
name: skill_id.to_string(),
arguments: serde_json::Value::Object(inputs.clone()),
tool_call_id: call.tool_call_id.clone(),
})
}
impl Agent {
/// Executes a single interaction "turn" with the agent.
///
@@ -697,11 +740,14 @@ impl Agent {
call: &ParsedToolCall,
iteration: usize,
) -> (ToolExecutionResult, ToolCallRecord) {
let normalized_call = normalize_tool_call(call);
let call: &ParsedToolCall = &normalized_call;
// The per-call execution path lives in the shared
// [`super::agent_tool_exec::run_agent_tool_call`] so `Agent::turn`
// (when migrated to the turn engine, via `AgentToolSource`) and any
// direct caller run the identical logic. Progress is emitted through a
// `TurnProgress` over this agent's sink.
// `TurnProgress` over this agent's sink. Legacy `run_skill`-wrapped
// built-in cron tool calls are normalized to direct calls first.
let progress = super::super::engine::TurnProgress::new(self.on_progress.clone());
let ctx = super::agent_tool_exec::AgentToolExecCtx {
tools: &self.tools,
@@ -112,6 +112,27 @@ impl Tool for EchoTool {
}
}
struct CronAddProbeTool;
#[async_trait]
impl Tool for CronAddProbeTool {
fn name(&self) -> &str {
"cron_add"
}
fn description(&self) -> &str {
"cron add probe"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
Ok(ToolResult::success(format!("cron_add_args={args}")))
}
}
struct CountingTool {
calls: Arc<AtomicUsize>,
}
@@ -606,6 +627,40 @@ async fn execute_tool_call_reports_unknown_tool() {
assert!(!record.success);
}
#[tokio::test]
async fn execute_tool_call_rewrites_legacy_run_skill_for_builtin_cron_tools() {
let provider: Arc<dyn Provider> = Arc::new(DummyProvider);
let agent = make_agent_with_builder(
provider,
vec![Box::new(CronAddProbeTool)],
Box::new(FixedMemoryLoader {
context: String::new(),
}),
vec![],
crate::openhuman::config::AgentConfig::default(),
crate::openhuman::config::ContextConfig::default(),
);
let call = ParsedToolCall {
name: "run_skill".into(),
arguments: serde_json::json!({
"skill_id": "cron_add",
"inputs": {
"name": "water-reminder",
"schedule": { "kind": "every", "every_ms": 60000 },
"job_type": "agent",
"prompt": "remind me to drink water"
}
}),
tool_call_id: Some("tc-run-skill-1".into()),
};
let (result, record) = agent.execute_tool_call(&call, 0).await;
assert!(result.success, "{}", result.output);
assert_eq!(result.name, "cron_add");
assert_eq!(record.name, "cron_add");
assert!(result.output.contains("\"every_ms\":60000"));
}
#[tokio::test]
async fn execute_tool_call_denies_tool_above_channel_permission() {
let calls = Arc::new(AtomicUsize::new(0));
@@ -48,14 +48,53 @@ Default bias: **do not spawn a sub-agent when a direct response or direct tool c
- **Fail gracefully** — If a sub-agent fails after retries, explain what happened clearly.
- **Escalate when appropriate** — If orchestration is the wrong mode or a specialist cannot make progress, hand control back to OpenHuman Core with a concise explanation and let Core handle general interactions.
**Scheduling rule of thumb.** To "remind me in 10 minutes", call `current_time`
first. If `cron_add` is available and enabled for this runtime, then call
`cron_add` with `schedule = {kind:"at", at:"<iso-time>"}`, `job_type:"agent"`,
and a `prompt` that tells a future agent what to deliver (e.g. "Send pushover:
'stand up and stretch'"). If `cron_add` is disabled by config, absent from your
tool list, or returns an error, do not promise the reminder: tell the user you
can't schedule it in this environment and, if helpful, provide the computed time
or a manual fallback.
**Scheduling rule of thumb.**
- **`cron_add`, `cron_list`, `cron_remove`, `current_time` are direct named tools.**
Call them by their tool name — never via `run_skill`. `run_skill` is for
user-installed skills only and will return "skill not found" for any built-in tool name.
- **Never call `run_skill` with `skill_id="cron_add"` (or `"cron_list"`, `"cron_remove"`,
`"current_time"`, or any other built-in tool name).** This path always errors.
- **One-shot / reminders** (e.g. "remind me in 10 minutes"): call `current_time`
first, propose the exact reminder timing, ask the user to confirm, then call
`cron_add` with `schedule = {kind:"at", at:"<iso-time>"}`,
`job_type:"agent"`, and a `prompt` that tells a future agent what to deliver
(e.g. "Send pushover: 'stand up and stretch'").
- **Recurring tasks** (e.g. "run this every day", "check my email every hour"):
propose a specific schedule (e.g. "I'll run this daily at 09:00 — shall I set
that up?"), ask the user to confirm, then call `cron_add` directly with
`schedule = {kind:"cron", expr:"<5-field-cron>", tz:null}`, `job_type:"agent"`,
and a detailed `prompt` for the recurring agent. Common expressions:
`"0 9 * * *"` (daily 9 AM), `"0 * * * *"` (hourly), `"*/30 * * * *"` (every 30 min),
`"* * * * *"` (every minute).
- **Finite repetitions** (e.g. "send X every minute for 10 times"): use a recurring
cron schedule with `delete_after_run:false`. The user can pause or remove the job
after N deliveries, or you can note the job id and remove it after the Nth run if
you have a way to track count. Do not refuse or stall — set up the schedule.
- **Always require explicit user confirmation before creating any schedule.**
This applies to both one-shot and recurring jobs. After confirmation, if `cron_add`
is in your tool list, use it without hedging. Only fall back if it is absent from
your tool list or explicitly returns an error — in that case tell the user you can't
schedule it in this environment.
**Worked example.** User: "send me a cricketer name every minute".
1. Reply with one short bubble: "got it — i'll send a name every minute via cron. ok?"
2. After confirmation, call `cron_add` directly (NOT `run_skill`):
```json
{
"schedule": {"kind": "cron", "expr": "* * * * *", "tz": null},
"job_type": "agent",
"prompt": "Send the user one random cricketer name, just the name.",
"delivery": {"mode": "proactive", "best_effort": true}
}
```
3. Reply with the new job id and a hint that it's listed under Settings → Cron Jobs.
## Dedicated worker threads
+64 -4
View File
@@ -47,9 +47,36 @@ fn all_filterable_tool_names() -> HashSet<&'static str> {
.collect()
}
/// Given the list of enabled Rust tool names (already expanded from UI IDs by
/// the frontend), retain only tools that are either infrastructure (not
/// filterable) or explicitly enabled.
/// Expand persisted tool-preference entries into Rust tool `name()` values.
///
/// Accepts both formats we may find in app state:
/// - Rust tool names (new format)
/// - UI toggle IDs (legacy / partial-rollout format)
///
/// Unknown entries are ignored.
fn expand_enabled_tool_names(enabled_tool_names: &[String]) -> HashSet<String> {
let mut expanded = HashSet::new();
for entry in enabled_tool_names {
if let Some((_, rust_names)) = TOOL_ID_TO_RUST_NAMES.iter().find(|(id, _)| id == &entry) {
for name in *rust_names {
expanded.insert((*name).to_string());
}
continue;
}
if TOOL_ID_TO_RUST_NAMES
.iter()
.flat_map(|(_, names)| names.iter().copied())
.any(|name| name == entry)
{
expanded.insert(entry.clone());
}
}
expanded
}
/// Given the list of enabled tools from app state, retain only tools that are
/// either infrastructure (not filterable) or explicitly enabled.
///
/// An empty `enabled_tool_names` list means "all enabled" (default / not yet
/// configured) — the filter is a no-op in that case.
@@ -64,7 +91,13 @@ pub(crate) fn filter_tools_by_user_preference(
let filterable = all_filterable_tool_names();
let allowed: HashSet<&str> = enabled_tool_names.iter().map(String::as_str).collect();
let allowed = expand_enabled_tool_names(enabled_tool_names);
if allowed.is_empty() {
log::warn!(
"[tool-filter] enabled_tools was non-empty but none matched known UI IDs or tool names; leaving tools unfiltered for safety"
);
return;
}
let before = tools.len();
tools.retain(|tool| {
@@ -86,3 +119,30 @@ pub(crate) fn filter_tools_by_user_preference(
);
}
}
#[cfg(test)]
mod tests {
use super::expand_enabled_tool_names;
#[test]
fn expands_legacy_ui_toggle_ids_to_rust_tool_names() {
let allowed = expand_enabled_tool_names(&["cron".to_string(), "web_search".to_string()]);
assert!(allowed.contains("cron_add"));
assert!(allowed.contains("cron_list"));
assert!(allowed.contains("web_search_tool"));
}
#[test]
fn keeps_direct_rust_tool_names() {
let allowed =
expand_enabled_tool_names(&["cron_add".to_string(), "memory_store".to_string()]);
assert!(allowed.contains("cron_add"));
assert!(allowed.contains("memory_store"));
}
#[test]
fn ignores_unknown_entries() {
let allowed = expand_enabled_tool_names(&["totally_unknown".to_string()]);
assert!(allowed.is_empty());
}
}