diff --git a/CHANGELOG.md b/CHANGELOG.md index fcbbedad6ec..6d1d2c50b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis. +- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) - Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz. - Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus. - Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus. diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1f790d7fb93..83af74d2612 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -120,4 +120,212 @@ export const en: TranslationMap = { ptBR: "Português (Brazilian Portuguese)", de: "Deutsch (German)", }, + cron: { + summary: { + enabled: "Enabled", + yes: "Yes", + no: "No", + jobs: "Jobs", + nextWake: "Next wake", + refreshing: "Refreshing...", + refresh: "Refresh", + }, + jobs: { + title: "Jobs", + subtitle: "All scheduled jobs stored in the gateway.", + shownOf: "{shown} shown of {total}", + searchJobs: "Search jobs", + searchPlaceholder: "Name, description, or agent", + enabled: "Enabled", + all: "All", + sort: "Sort", + nextRun: "Next run", + recentlyUpdated: "Recently updated", + name: "Name", + direction: "Direction", + ascending: "Ascending", + descending: "Descending", + noMatching: "No matching jobs.", + loading: "Loading...", + loadMore: "Load more jobs", + }, + runs: { + title: "Run history", + subtitleAll: "Latest runs across all jobs.", + subtitleJob: "Latest runs for {title}.", + scope: "Scope", + allJobs: "All jobs", + selectedJob: "Selected job", + searchRuns: "Search runs", + searchPlaceholder: "Summary, error, or job", + newestFirst: "Newest first", + oldestFirst: "Oldest first", + status: "Status", + delivery: "Delivery", + clear: "Clear", + allStatuses: "All statuses", + allDelivery: "All delivery", + selectJobHint: "Select a job to inspect run history.", + noMatching: "No matching runs.", + loadMore: "Load more runs", + runStatusOk: "OK", + runStatusError: "Error", + runStatusSkipped: "Skipped", + runStatusUnknown: "Unknown", + deliveryDelivered: "Delivered", + deliveryNotDelivered: "Not delivered", + deliveryUnknown: "Unknown", + deliveryNotRequested: "Not requested", + }, + form: { + editJob: "Edit Job", + newJob: "New Job", + updateSubtitle: "Update the selected scheduled job.", + createSubtitle: "Create a scheduled wakeup or agent run.", + required: "Required", + requiredSr: "required", + basics: "Basics", + basicsSub: "Name it, choose the assistant, and set enabled state.", + fieldName: "Name", + description: "Description", + agentId: "Agent ID", + namePlaceholder: "Morning brief", + descriptionPlaceholder: "Optional context for this job", + agentPlaceholder: "main or ops", + agentHelp: "Start typing to pick a known agent, or enter a custom one.", + schedule: "Schedule", + scheduleSub: "Control when this job runs.", + every: "Every", + at: "At", + cronOption: "Cron", + runAt: "Run at", + unit: "Unit", + minutes: "Minutes", + hours: "Hours", + days: "Days", + expression: "Expression", + expressionPlaceholder: "0 7 * * *", + everyAmountPlaceholder: "30", + timezoneOptional: "Timezone (optional)", + timezonePlaceholder: "America/Los_Angeles", + timezoneHelp: "Pick a common timezone or enter any valid IANA timezone.", + jitterHelp: "Need jitter? Use Advanced → Stagger window / Stagger unit.", + execution: "Execution", + executionSub: "Choose when to wake, and what this job should do.", + session: "Session", + main: "Main", + isolated: "Isolated", + sessionHelp: "Main posts a system event. Isolated runs a dedicated agent turn.", + wakeMode: "Wake mode", + now: "Now", + nextHeartbeat: "Next heartbeat", + wakeModeHelp: "Now triggers immediately. Next heartbeat waits for the next cycle.", + payloadKind: "What should run?", + systemEvent: "Post message to main timeline", + agentTurn: "Run assistant task (isolated)", + systemEventHelp: + "Sends your text to the gateway main timeline (good for reminders/triggers).", + agentTurnHelp: "Starts an assistant run in its own session using your prompt.", + timeoutSeconds: "Timeout (seconds)", + timeoutPlaceholder: "Optional, e.g. 90", + timeoutHelp: + "Optional. Leave blank to use the gateway default timeout behavior for this run.", + mainTimelineMessage: "Main timeline message", + assistantTaskPrompt: "Assistant task prompt", + deliverySection: "Delivery", + deliverySub: "Choose where run summaries are sent.", + resultDelivery: "Result delivery", + announceDefault: "Announce summary (default)", + webhookPost: "Webhook POST", + noneInternal: "None (internal)", + deliveryHelp: "Announce posts a summary to chat. None keeps execution internal.", + webhookUrl: "Webhook URL", + channel: "Channel", + webhookPlaceholder: "https://example.com/cron", + channelHelp: "Choose which connected channel receives the summary.", + webhookHelp: "Send run summaries to a webhook endpoint.", + to: "To", + toPlaceholder: "+1555... or chat id", + toHelp: "Optional recipient override (chat id, phone, or user id).", + advanced: "Advanced", + advancedHelp: + "Optional overrides for delivery guarantees, schedule jitter, and model controls.", + deleteAfterRun: "Delete after run", + deleteAfterRunHelp: "Best for one-shot reminders that should auto-clean up.", + clearAgentOverride: "Clear agent override", + clearAgentHelp: "Force this job to use the gateway default assistant.", + exactTiming: "Exact timing (no stagger)", + exactTimingHelp: "Run on exact cron boundaries with no spread.", + staggerWindow: "Stagger window", + staggerUnit: "Stagger unit", + staggerPlaceholder: "30", + seconds: "Seconds", + model: "Model", + modelPlaceholder: "openai/gpt-5.2", + modelHelp: "Start typing to pick a known model, or enter a custom one.", + thinking: "Thinking", + thinkingPlaceholder: "low", + thinkingHelp: "Use a suggested level or enter a provider-specific value.", + bestEffortDelivery: "Best effort delivery", + bestEffortHelp: "Do not fail the job if delivery itself fails.", + cantAddYet: "Can't add job yet", + fillRequired: "Fill the required fields below to enable submit.", + fixFields: "Fix {count} field to continue.", + fixFieldsPlural: "Fix {count} fields to continue.", + saving: "Saving...", + saveChanges: "Save changes", + addJob: "Add job", + cancel: "Cancel", + }, + jobList: { + allJobs: "all jobs", + selectJob: "(select a job)", + enabled: "enabled", + disabled: "disabled", + edit: "Edit", + clone: "Clone", + disable: "Disable", + enable: "Enable", + run: "Run", + history: "History", + remove: "Remove", + }, + jobDetail: { + system: "System", + prompt: "Prompt", + delivery: "Delivery", + agent: "Agent", + }, + jobState: { + status: "Status", + next: "Next", + last: "Last", + }, + runEntry: { + noSummary: "No summary.", + runAt: "Run at", + openRunChat: "Open run chat", + next: "Next {rel}", + due: "Due {rel}", + }, + errors: { + nameRequired: "Name is required.", + scheduleAtInvalid: "Enter a valid date/time.", + everyAmountInvalid: "Interval must be greater than 0.", + cronExprRequired: "Cron expression is required.", + staggerAmountInvalid: "Stagger must be greater than 0.", + systemTextRequired: "System text is required.", + agentMessageRequired: "Agent message is required.", + timeoutInvalid: "If set, timeout must be greater than 0 seconds.", + webhookUrlRequired: "Webhook URL is required.", + webhookUrlInvalid: "Webhook URL must start with http:// or https://.", + invalidRunTime: "Invalid run time.", + invalidIntervalAmount: "Invalid interval amount.", + cronExprRequiredShort: "Cron expression required.", + invalidStaggerAmount: "Invalid stagger amount.", + systemEventTextRequired: "System event text required.", + agentMessageRequiredShort: "Agent message required.", + nameRequiredShort: "Name required.", + }, + }, }; diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 07ff0872701..cd7273958b6 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -119,4 +119,209 @@ export const zh_CN: TranslationMap = { ptBR: "Português (巴西葡萄牙语)", de: "Deutsch (德语)", }, + cron: { + summary: { + enabled: "已启用", + yes: "是", + no: "否", + jobs: "任务数", + nextWake: "下次唤醒", + refreshing: "刷新中...", + refresh: "刷新", + }, + jobs: { + title: "任务列表", + subtitle: "网关中存储的所有定时任务。", + shownOf: "显示 {shown} / 共 {total}", + searchJobs: "搜索任务", + searchPlaceholder: "名称、描述或代理", + enabled: "启用状态", + all: "全部", + sort: "排序", + nextRun: "下次运行", + recentlyUpdated: "最近更新", + name: "名称", + direction: "方向", + ascending: "升序", + descending: "降序", + noMatching: "没有匹配的任务。", + loading: "加载中...", + loadMore: "加载更多任务", + }, + runs: { + title: "运行历史", + subtitleAll: "所有任务的最新运行记录。", + subtitleJob: "{title} 的最新运行记录。", + scope: "范围", + allJobs: "所有任务", + selectedJob: "已选任务", + searchRuns: "搜索运行", + searchPlaceholder: "摘要、错误或任务", + newestFirst: "最新优先", + oldestFirst: "最早优先", + status: "状态", + delivery: "投递", + clear: "清除", + allStatuses: "全部状态", + allDelivery: "全部投递", + selectJobHint: "请选择一个任务以查看运行历史。", + noMatching: "没有匹配的运行记录。", + loadMore: "加载更多运行", + runStatusOk: "成功", + runStatusError: "错误", + runStatusSkipped: "已跳过", + runStatusUnknown: "未知", + deliveryDelivered: "已投递", + deliveryNotDelivered: "未投递", + deliveryUnknown: "未知", + deliveryNotRequested: "未请求", + }, + form: { + editJob: "编辑任务", + newJob: "新建任务", + updateSubtitle: "更新所选定时任务。", + createSubtitle: "创建定时唤醒或代理运行。", + required: "必填", + requiredSr: "必填", + basics: "基本信息", + basicsSub: "命名、选择助手并设置启用状态。", + fieldName: "名称", + description: "描述", + agentId: "代理 ID", + namePlaceholder: "晨间简报", + descriptionPlaceholder: "此任务的可选说明", + agentPlaceholder: "main 或 ops", + agentHelp: "输入以选择已知代理,或输入自定义 ID。", + schedule: "调度", + scheduleSub: "控制任务运行时间。", + every: "每隔", + at: "指定时间", + cronOption: "Cron", + runAt: "运行时间", + unit: "单位", + minutes: "分钟", + hours: "小时", + days: "天", + expression: "表达式", + expressionPlaceholder: "0 7 * * *", + everyAmountPlaceholder: "30", + timezoneOptional: "时区(可选)", + timezonePlaceholder: "America/Los_Angeles", + timezoneHelp: "选择常用时区或输入有效的 IANA 时区。", + jitterHelp: "需要抖动?使用高级 → 抖动窗口 / 抖动单位。", + execution: "执行", + executionSub: "选择唤醒时机和任务执行内容。", + session: "会话", + main: "主会话", + isolated: "隔离会话", + sessionHelp: "主会话发布系统事件。隔离会话运行独立的代理轮次。", + wakeMode: "唤醒模式", + now: "立即", + nextHeartbeat: "下次心跳", + wakeModeHelp: "立即模式立即触发。下次心跳等待下一个周期。", + payloadKind: "执行内容", + systemEvent: "发布消息到主时间线", + agentTurn: "运行助手任务(隔离)", + systemEventHelp: "将文本发送到网关主时间线(适用于提醒/触发)。", + agentTurnHelp: "使用您的提示在独立会话中启动助手运行。", + timeoutSeconds: "超时(秒)", + timeoutPlaceholder: "可选,如 90", + timeoutHelp: "可选。留空以使用网关默认超时行为。", + mainTimelineMessage: "主时间线消息", + assistantTaskPrompt: "助手任务提示", + deliverySection: "投递", + deliverySub: "选择运行摘要的发送位置。", + resultDelivery: "结果投递", + announceDefault: "发布摘要(默认)", + webhookPost: "Webhook POST", + noneInternal: "无(仅内部)", + deliveryHelp: "发布将摘要发送到聊天。无保持执行仅内部。", + webhookUrl: "Webhook URL", + channel: "频道", + webhookPlaceholder: "https://example.com/cron", + channelHelp: "选择接收摘要的已连接频道。", + webhookHelp: "将运行摘要发送到 Webhook 端点。", + to: "收件人", + toPlaceholder: "+1555... 或聊天 ID", + toHelp: "可选收件人覆盖(聊天 ID、电话或用户 ID)。", + advanced: "高级", + advancedHelp: "投递保证、调度抖动和模型控制的可选覆盖。", + deleteAfterRun: "运行后删除", + deleteAfterRunHelp: "适用于应自动清理的一次性提醒。", + clearAgentOverride: "清除代理覆盖", + clearAgentHelp: "强制此任务使用网关默认助手。", + exactTiming: "精确时间(无抖动)", + exactTimingHelp: "在精确的 cron 边界运行,无分散。", + staggerWindow: "抖动窗口", + staggerUnit: "抖动单位", + staggerPlaceholder: "30", + seconds: "秒", + model: "模型", + modelPlaceholder: "openai/gpt-5.2", + modelHelp: "输入以选择已知模型,或输入自定义模型。", + thinking: "思考", + thinkingPlaceholder: "low", + thinkingHelp: "使用建议级别或输入提供商特定值。", + bestEffortDelivery: "尽力投递", + bestEffortHelp: "投递失败时不使任务失败。", + cantAddYet: "暂无法添加任务", + fillRequired: "填写下方必填项以启用提交。", + fixFields: "修复 {count} 个字段以继续。", + fixFieldsPlural: "修复 {count} 个字段以继续。", + saving: "保存中...", + saveChanges: "保存更改", + addJob: "添加任务", + cancel: "取消", + }, + jobList: { + allJobs: "所有任务", + selectJob: "(选择任务)", + enabled: "已启用", + disabled: "已禁用", + edit: "编辑", + clone: "克隆", + disable: "禁用", + enable: "启用", + run: "运行", + history: "历史", + remove: "删除", + }, + jobDetail: { + system: "系统", + prompt: "提示", + delivery: "投递", + agent: "代理", + }, + jobState: { + status: "状态", + next: "下次", + last: "上次", + }, + runEntry: { + noSummary: "无摘要。", + runAt: "运行于", + openRunChat: "打开运行聊天", + next: "下次 {rel}", + due: "到期 {rel}", + }, + errors: { + nameRequired: "名称为必填项。", + scheduleAtInvalid: "请输入有效的日期/时间。", + everyAmountInvalid: "间隔必须大于 0。", + cronExprRequired: "Cron 表达式为必填项。", + staggerAmountInvalid: "抖动值必须大于 0。", + systemTextRequired: "系统文本为必填项。", + agentMessageRequired: "代理消息为必填项。", + timeoutInvalid: "若设置超时,必须大于 0 秒。", + webhookUrlRequired: "Webhook URL 为必填项。", + webhookUrlInvalid: "Webhook URL 必须以 http:// 或 https:// 开头。", + invalidRunTime: "无效的运行时间。", + invalidIntervalAmount: "无效的间隔值。", + cronExprRequiredShort: "Cron 表达式为必填。", + invalidStaggerAmount: "无效的抖动值。", + systemEventTextRequired: "系统事件文本为必填。", + agentMessageRequiredShort: "代理消息为必填。", + nameRequiredShort: "名称为必填。", + }, + }, }; diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index ee2bab887cd..f94e4dd1f93 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -343,11 +343,11 @@ describe("cron controller", () => { deliveryMode: "webhook", deliveryTo: "ftp://bad", }); - expect(errors.name).toBeDefined(); - expect(errors.cronExpr).toBeDefined(); - expect(errors.payloadText).toBeDefined(); - expect(errors.timeoutSeconds).toBe("If set, timeout must be greater than 0 seconds."); - expect(errors.deliveryTo).toBeDefined(); + expect(errors.name).toBe("cron.errors.nameRequired"); + expect(errors.cronExpr).toBe("cron.errors.cronExprRequired"); + expect(errors.payloadText).toBe("cron.errors.agentMessageRequired"); + expect(errors.timeoutSeconds).toBe("cron.errors.timeoutInvalid"); + expect(errors.deliveryTo).toBe("cron.errors.webhookUrlInvalid"); }); it("blocks add/update submit when validation errors exist", async () => { diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 99917cce741..286c84f38d4 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -1,3 +1,4 @@ +import { t } from "../../i18n/index.ts"; import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; @@ -95,28 +96,28 @@ export function normalizeCronFormState(form: CronFormState): CronFormState { export function validateCronForm(form: CronFormState): CronFieldErrors { const errors: CronFieldErrors = {}; if (!form.name.trim()) { - errors.name = "Name is required."; + errors.name = "cron.errors.nameRequired"; } if (form.scheduleKind === "at") { const ms = Date.parse(form.scheduleAt); if (!Number.isFinite(ms)) { - errors.scheduleAt = "Enter a valid date/time."; + errors.scheduleAt = "cron.errors.scheduleAtInvalid"; } } else if (form.scheduleKind === "every") { const amount = toNumber(form.everyAmount, 0); if (amount <= 0) { - errors.everyAmount = "Interval must be greater than 0."; + errors.everyAmount = "cron.errors.everyAmountInvalid"; } } else { if (!form.cronExpr.trim()) { - errors.cronExpr = "Cron expression is required."; + errors.cronExpr = "cron.errors.cronExprRequired"; } if (!form.scheduleExact) { const staggerAmount = form.staggerAmount.trim(); if (staggerAmount) { const stagger = toNumber(staggerAmount, 0); if (stagger <= 0) { - errors.staggerAmount = "Stagger must be greater than 0."; + errors.staggerAmount = "cron.errors.staggerAmountInvalid"; } } } @@ -124,24 +125,24 @@ export function validateCronForm(form: CronFormState): CronFieldErrors { if (!form.payloadText.trim()) { errors.payloadText = form.payloadKind === "systemEvent" - ? "System text is required." - : "Agent message is required."; + ? "cron.errors.systemTextRequired" + : "cron.errors.agentMessageRequired"; } if (form.payloadKind === "agentTurn") { const timeoutRaw = form.timeoutSeconds.trim(); if (timeoutRaw) { const timeout = toNumber(timeoutRaw, 0); if (timeout <= 0) { - errors.timeoutSeconds = "If set, timeout must be greater than 0 seconds."; + errors.timeoutSeconds = "cron.errors.timeoutInvalid"; } } } if (form.deliveryMode === "webhook") { const target = form.deliveryTo.trim(); if (!target) { - errors.deliveryTo = "Webhook URL is required."; + errors.deliveryTo = "cron.errors.webhookUrlRequired"; } else if (!/^https?:\/\//i.test(target)) { - errors.deliveryTo = "Webhook URL must start with http:// or https://."; + errors.deliveryTo = "cron.errors.webhookUrlInvalid"; } } return errors; @@ -428,14 +429,14 @@ export function buildCronSchedule(form: CronFormState) { if (form.scheduleKind === "at") { const ms = Date.parse(form.scheduleAt); if (!Number.isFinite(ms)) { - throw new Error("Invalid run time."); + throw new Error(t("cron.errors.invalidRunTime")); } return { kind: "at" as const, at: new Date(ms).toISOString() }; } if (form.scheduleKind === "every") { const amount = toNumber(form.everyAmount, 0); if (amount <= 0) { - throw new Error("Invalid interval amount."); + throw new Error(t("cron.errors.invalidIntervalAmount")); } const unit = form.everyUnit; const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000; @@ -443,7 +444,7 @@ export function buildCronSchedule(form: CronFormState) { } const expr = form.cronExpr.trim(); if (!expr) { - throw new Error("Cron expression required."); + throw new Error(t("cron.errors.cronExprRequiredShort")); } if (form.scheduleExact) { return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs: 0 }; @@ -454,7 +455,7 @@ export function buildCronSchedule(form: CronFormState) { } const staggerValue = toNumber(staggerAmount, 0); if (staggerValue <= 0) { - throw new Error("Invalid stagger amount."); + throw new Error(t("cron.errors.invalidStaggerAmount")); } const staggerMs = form.staggerUnit === "minutes" ? staggerValue * 60_000 : staggerValue * 1_000; return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs }; @@ -464,13 +465,13 @@ export function buildCronPayload(form: CronFormState) { if (form.payloadKind === "systemEvent") { const text = form.payloadText.trim(); if (!text) { - throw new Error("System event text required."); + throw new Error(t("cron.errors.systemEventTextRequired")); } return { kind: "systemEvent" as const, text }; } const message = form.payloadText.trim(); if (!message) { - throw new Error("Agent message required."); + throw new Error(t("cron.errors.agentMessageRequiredShort")); } const payload: { kind: "agentTurn"; @@ -540,7 +541,7 @@ export async function addCronJob(state: CronState) { delivery, }; if (!job.name) { - throw new Error("Name required."); + throw new Error(t("cron.errors.nameRequiredShort")); } if (state.cronEditingJobId) { await state.client.request("cron.update", { diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index b09100494f7..18ac9129b83 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -491,9 +491,9 @@ describe("cron view", () => { payloadText: "", }, fieldErrors: { - name: "Name is required.", - cronExpr: "Cron expression is required.", - payloadText: "Agent message is required.", + name: "cron.errors.nameRequired", + cronExpr: "cron.errors.cronExprRequired", + payloadText: "cron.errors.agentMessageRequired", }, canSubmit: false, }), @@ -527,9 +527,9 @@ describe("cron view", () => { payloadText: "", }, fieldErrors: { - name: "Name is required.", - everyAmount: "Interval must be greater than 0.", - payloadText: "Agent message is required.", + name: "cron.errors.nameRequired", + everyAmount: "cron.errors.everyAmountInvalid", + payloadText: "cron.errors.agentMessageRequired", }, canSubmit: false, }), diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index e84c6f9f03f..fbcc942bf42 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; +import { t } from "../../i18n/index.ts"; import type { CronFieldErrors, CronFieldKey } from "../controllers/cron.ts"; import { formatRelativeTimestamp, formatMs } from "../format.ts"; import { pathForTab } from "../navigation.ts"; @@ -81,18 +82,22 @@ export type CronProps = { }) => void | Promise; }; -const RUN_STATUS_OPTIONS: Array<{ value: CronRunsStatusValue; label: string }> = [ - { value: "ok", label: "OK" }, - { value: "error", label: "Error" }, - { value: "skipped", label: "Skipped" }, -]; +function getRunStatusOptions(): Array<{ value: CronRunsStatusValue; label: string }> { + return [ + { value: "ok", label: t("cron.runs.runStatusOk") }, + { value: "error", label: t("cron.runs.runStatusError") }, + { value: "skipped", label: t("cron.runs.runStatusSkipped") }, + ]; +} -const RUN_DELIVERY_OPTIONS: Array<{ value: CronDeliveryStatus; label: string }> = [ - { value: "delivered", label: "Delivered" }, - { value: "not-delivered", label: "Not delivered" }, - { value: "unknown", label: "Unknown" }, - { value: "not-requested", label: "Not requested" }, -]; +function getRunDeliveryOptions(): Array<{ value: CronDeliveryStatus; label: string }> { + return [ + { value: "delivered", label: t("cron.runs.deliveryDelivered") }, + { value: "not-delivered", label: t("cron.runs.deliveryNotDelivered") }, + { value: "unknown", label: t("cron.runs.deliveryUnknown") }, + { value: "not-requested", label: t("cron.runs.deliveryNotRequested") }, + ]; +} function toggleSelection(selected: T[], value: T, checked: boolean): T[] { const set = new Set(selected); @@ -177,7 +182,7 @@ function renderRunFilterDropdown(params: { )}
- +
@@ -243,22 +248,24 @@ function fieldLabelForKey( deliveryMode: CronFormState["deliveryMode"], ) { if (key === "payloadText") { - return form.payloadKind === "systemEvent" ? "Main timeline message" : "Assistant task prompt"; + return form.payloadKind === "systemEvent" + ? t("cron.form.mainTimelineMessage") + : t("cron.form.assistantTaskPrompt"); } if (key === "deliveryTo") { - return deliveryMode === "webhook" ? "Webhook URL" : "To"; + return deliveryMode === "webhook" ? t("cron.form.webhookUrl") : t("cron.form.to"); } const labels: Record = { - name: "Name", - scheduleAt: "Run at", - everyAmount: "Every", - cronExpr: "Expression", - staggerAmount: "Stagger window", - payloadText: "Payload text", - payloadModel: "Model", - payloadThinking: "Thinking", - timeoutSeconds: "Timeout (seconds)", - deliveryTo: "To", + name: t("cron.form.fieldName"), + scheduleAt: t("cron.form.runAt"), + everyAmount: t("cron.form.every"), + cronExpr: t("cron.form.expression"), + staggerAmount: t("cron.form.staggerWindow"), + payloadText: t("cron.form.assistantTaskPrompt"), + payloadModel: t("cron.form.model"), + payloadThinking: t("cron.form.thinking"), + timeoutSeconds: t("cron.form.timeoutSeconds"), + deliveryTo: t("cron.form.to"), }; return labels[key]; } @@ -314,7 +321,7 @@ function renderFieldLabel(text: string, required = false) { required ? html` - required + ${t("cron.form.requiredSr")} ` : nothing } @@ -330,17 +337,19 @@ export function renderCron(props: CronProps) { props.runsJobId == null ? undefined : props.jobs.find((job) => job.id === props.runsJobId); const selectedRunTitle = props.runsScope === "all" - ? "all jobs" - : (selectedJob?.name ?? props.runsJobId ?? "(select a job)"); + ? t("cron.jobList.allJobs") + : (selectedJob?.name ?? props.runsJobId ?? t("cron.jobList.selectJob")); const runs = props.runs; - const selectedStatusLabels = RUN_STATUS_OPTIONS.filter((option) => - props.runsStatuses.includes(option.value), - ).map((option) => option.label); - const selectedDeliveryLabels = RUN_DELIVERY_OPTIONS.filter((option) => - props.runsDeliveryStatuses.includes(option.value), - ).map((option) => option.label); - const statusSummary = summarizeSelection(selectedStatusLabels, "All statuses"); - const deliverySummary = summarizeSelection(selectedDeliveryLabels, "All delivery"); + const runStatusOptions = getRunStatusOptions(); + const runDeliveryOptions = getRunDeliveryOptions(); + const selectedStatusLabels = runStatusOptions + .filter((option) => props.runsStatuses.includes(option.value)) + .map((option) => option.label); + const selectedDeliveryLabels = runDeliveryOptions + .filter((option) => props.runsDeliveryStatuses.includes(option.value)) + .map((option) => option.label); + const statusSummary = summarizeSelection(selectedStatusLabels, t("cron.runs.allStatuses")); + const deliverySummary = summarizeSelection(selectedDeliveryLabels, t("cron.runs.allDelivery")); const supportsAnnounce = props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; const selectedDeliveryMode = @@ -349,31 +358,39 @@ export function renderCron(props: CronProps) { const blockedByValidation = !props.busy && blockingFields.length > 0; const submitDisabledReason = blockedByValidation && !props.canSubmit - ? `Fix ${blockingFields.length} ${blockingFields.length === 1 ? "field" : "fields"} to continue.` + ? blockingFields.length === 1 + ? t("cron.form.fixFields", { count: String(blockingFields.length) }) + : t("cron.form.fixFieldsPlural", { count: String(blockingFields.length) }) : ""; return html`
-
Enabled
+
${t("cron.summary.enabled")}
- ${props.status ? (props.status.enabled ? "Yes" : "No") : "n/a"} + ${ + props.status + ? props.status.enabled + ? t("cron.summary.yes") + : t("cron.summary.no") + : t("common.na") + }
-
Jobs
-
${props.status?.jobs ?? "n/a"}
+
${t("cron.summary.jobs")}
+
${props.status?.jobs ?? t("common.na")}
-
Next wake
+
${t("cron.summary.nextWake")}
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
${props.error ? html`${props.error}` : nothing}
@@ -384,17 +401,20 @@ export function renderCron(props: CronProps) {
-
Jobs
-
All scheduled jobs stored in the gateway.
+
${t("cron.jobs.title")}
+
${t("cron.jobs.subtitle")}
-
${props.jobs.length} shown of ${props.jobsTotal}
+
${t("cron.jobs.shownOf", { + shown: String(props.jobs.length), + total: String(props.jobsTotal), + })}
${ props.jobs.length === 0 ? html` -
No matching jobs.
+
${t("cron.jobs.noMatching")}
` : html`
@@ -464,7 +484,7 @@ export function renderCron(props: CronProps) { ?disabled=${props.loading || props.jobsLoadingMore} @click=${props.onLoadMoreJobs} > - ${props.jobsLoadingMore ? "Loading..." : "Load more jobs"} + ${props.jobsLoadingMore ? t("cron.jobs.loading") : t("cron.jobs.loadMore")}
` @@ -475,21 +495,24 @@ export function renderCron(props: CronProps) {
-
Run history
+
${t("cron.runs.title")}
${ props.runsScope === "all" - ? "Latest runs across all jobs." - : `Latest runs for ${selectedRunTitle}.` + ? t("cron.runs.subtitleAll") + : t("cron.runs.subtitleJob", { title: selectedRunTitle }) }
-
${runs.length} shown of ${props.runsTotal}
+
${t("cron.jobs.shownOf", { + shown: String(runs.length), + total: String(props.runsTotal), + })}
${renderRunFilterDropdown({ id: "status", - title: "Status", + title: t("cron.runs.status"), summary: statusSummary, - options: RUN_STATUS_OPTIONS, + options: runStatusOptions, selected: props.runsStatuses, onToggle: (value, checked) => { const next = toggleSelection( @@ -547,9 +570,9 @@ export function renderCron(props: CronProps) { })} ${renderRunFilterDropdown({ id: "delivery", - title: "Delivery", + title: t("cron.runs.delivery"), summary: deliverySummary, - options: RUN_DELIVERY_OPTIONS, + options: runDeliveryOptions, selected: props.runsDeliveryStatuses, onToggle: (value, checked) => { const next = toggleSelection( @@ -568,11 +591,11 @@ export function renderCron(props: CronProps) { ${ props.runsScope === "job" && props.runsJobId == null ? html` -
Select a job to inspect run history.
+
${t("cron.runs.selectJobHint")}
` : runs.length === 0 ? html` -
No matching runs.
+
${t("cron.runs.noMatching")}
` : html`
@@ -589,7 +612,7 @@ export function renderCron(props: CronProps) { ?disabled=${props.runsLoadingMore} @click=${props.onLoadMoreRuns} > - ${props.runsLoadingMore ? "Loading..." : "Load more runs"} + ${props.runsLoadingMore ? t("cron.jobs.loading") : t("cron.runs.loadMore")}
` @@ -599,24 +622,24 @@ export function renderCron(props: CronProps) {
-
${isEditing ? "Edit Job" : "New Job"}
+
${isEditing ? t("cron.form.editJob") : t("cron.form.newJob")}
- ${isEditing ? "Update the selected scheduled job." : "Create a scheduled wakeup or agent run."} + ${isEditing ? t("cron.form.updateSubtitle") : t("cron.form.createSubtitle")}
- Required + ${t("cron.form.required")}
-
Basics
-
Name it, choose the assistant, and set enabled state.
+
${t("cron.form.basics")}
+
${t("cron.form.basicsSub")}
-
Schedule
-
Control when this job runs.
+
${t("cron.form.schedule")}
+
${t("cron.form.scheduleSub")}
@@ -687,11 +708,11 @@ export function renderCron(props: CronProps) {
-
Execution
-
Choose when to wake, and what this job should do.
+
${t("cron.form.execution")}
+
${t("cron.form.executionSub")}
@@ -747,11 +768,11 @@ export function renderCron(props: CronProps) { isAgentTurn ? html`