diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 27c28e612d..ebb51aceb0 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput, useApp, useStdout } from "ink"; import Spinner from "ink-spinner"; import TextInput from "ink-text-input"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { spawn } from "node:child_process"; import { appendFileSync } from "node:fs"; @@ -778,49 +779,50 @@ function UtilitiesPanel({ state, isFocused }: { state: DashboardState; isFocused // ── Help overlay ────────────────────────────────────────────────────────────── function HelpOverlay() { + const { t } = useTranslation("cli"); const shortcuts: Array<[string, string]> = [ - ["[m] / [s]", "Main (status mode)"], - ["[b]", "Board view"], - ["[a]", "Agents view"], - ["[g]", "Settings view"], - ["[t]", "Git view"], - ["[f]", "Files (when not on Logs); cycles log severity filter on Logs"], - ["[Tab]", "Cycle focused panel / pane forward"], - ["[Shift+Tab]", "Cycle focused panel / pane backward"], - ["[1-5]", "Jump to panel (Main: System/Logs/Stats/Utilities/Settings)"], - ["[← / →]", "Switch pane (Agents, Settings, Files, Git)"], - ["[→] / [↓] / [n]", "Next panel (Main; ↑/↓ scroll on Logs)"], - ["[←] / [↑] / [p]", "Previous panel (Main; ↑/↓ scroll on Logs)"], - ["[Enter]", "Expand log + release mouse for text selection (Logs)"], - ["[r]", "Refresh stats (Utilities)"], - ["[c]", "Clear logs (Utilities)"], - ["[k]", "Kill all vitest processes (Utilities)"], - ["[v]", "Toggle auto-kill vitest on memory pressure (Utilities)"], - ["[+/-]", "Adjust vitest kill memory threshold (Utilities)"], - ["[Enter]", "Open dashboard URL in browser (System)"], - ["[c]", "Copy auth token to clipboard (System)"], - ["[M]", "Manual mouse-mode toggle (auto: on for Logs/Files/Git/Board, off elsewhere)"], - ["[↑/↓/k/j]", "Navigate list / log entries"], - ["[Home / G]", "First / last log entry (Logs)"], - ["[Enter/Space]", "Expand log entry (Logs)"], - ["[c]", "Copy selected log entry to clipboard (Logs)"], - ["[w]", "Toggle word wrap (Logs / Files)"], - ["[Space]", "Toggle boolean (Settings)"], - ["[+/-]", "Adjust number (Settings)"], - ["[p]", "Project picker (Board, Files)"], - ["[n]", "New task (Board)"], - ["[D]", "Delete agent — requires confirm (Agents)"], - ["[P] / [F]", "Push / fetch (Git)"], - ["[.]", "Toggle hidden files (Files)"], - ["[?] / [h]", "Toggle help"], - ["[q]", "Quit"], - ["[Ctrl+C]", "Force quit"], + ["[m] / [s]", t("tui.helpShortcutMain", "Main (status mode)")], + ["[b]", t("tui.helpShortcutBoard", "Board view")], + ["[a]", t("tui.helpShortcutAgents", "Agents view")], + ["[g]", t("tui.helpShortcutSettings", "Settings view")], + ["[t]", t("tui.helpShortcutGit", "Git view")], + ["[f]", t("tui.helpShortcutFiles", "Files (when not on Logs); cycles log severity filter on Logs")], + ["[Tab]", t("tui.helpShortcutTabForward", "Cycle focused panel / pane forward")], + ["[Shift+Tab]", t("tui.helpShortcutTabBackward", "Cycle focused panel / pane backward")], + ["[1-5]", t("tui.helpShortcutJumpPanel", "Jump to panel (Main: System/Logs/Stats/Utilities/Settings)")], + ["[← / →]", t("tui.helpShortcutSwitchPane", "Switch pane (Agents, Settings, Files, Git)")], + ["[→] / [↓] / [n]", t("tui.helpShortcutNextPanel", "Next panel (Main; ↑/↓ scroll on Logs)")], + ["[←] / [↑] / [p]", t("tui.helpShortcutPrevPanel", "Previous panel (Main; ↑/↓ scroll on Logs)")], + ["[Enter]", t("tui.helpShortcutExpandLog", "Expand log + release mouse for text selection (Logs)")], + ["[r]", t("tui.helpShortcutRefreshStats", "Refresh stats (Utilities)")], + ["[c]", t("tui.helpShortcutClearLogs", "Clear logs (Utilities)")], + ["[k]", t("tui.helpShortcutKillVitest", "Kill all vitest processes (Utilities)")], + ["[v]", t("tui.helpShortcutToggleAutoKill", "Toggle auto-kill vitest on memory pressure (Utilities)")], + ["[+/-]", t("tui.helpShortcutAdjustThreshold", "Adjust vitest kill memory threshold (Utilities)")], + ["[Enter]", t("tui.helpShortcutOpenUrl", "Open dashboard URL in browser (System)")], + ["[c]", t("tui.helpShortcutCopyToken", "Copy auth token to clipboard (System)")], + ["[M]", t("tui.helpShortcutMouseToggle", "Manual mouse-mode toggle (auto: on for Logs/Files/Git/Board, off elsewhere)")], + ["[↑/↓/k/j]", t("tui.helpShortcutNavigate", "Navigate list / log entries")], + ["[Home / G]", t("tui.helpShortcutFirstLast", "First / last log entry (Logs)")], + ["[Enter/Space]", t("tui.helpShortcutExpandEntry", "Expand log entry (Logs)")], + ["[c]", t("tui.helpShortcutCopyEntry", "Copy selected log entry to clipboard (Logs)")], + ["[w]", t("tui.helpShortcutWordWrap", "Toggle word wrap (Logs / Files)")], + ["[Space]", t("tui.helpShortcutToggleBool", "Toggle boolean (Settings)")], + ["[+/-]", t("tui.helpShortcutAdjustNumber", "Adjust number (Settings)")], + ["[p]", t("tui.helpShortcutProjectPicker", "Project picker (Board, Files)")], + ["[n]", t("tui.helpShortcutNewTask", "New task (Board)")], + ["[D]", t("tui.helpShortcutDeleteAgent", "Delete agent — requires confirm (Agents)")], + ["[P] / [F]", t("tui.helpShortcutPushFetch", "Push / fetch (Git)")], + ["[.]", t("tui.helpShortcutHiddenFiles", "Toggle hidden files (Files)")], + ["[?] / [h]", t("tui.helpShortcutToggleHelp", "Toggle help")], + ["[q]", t("tui.helpShortcutQuit", "Quit")], + ["[Ctrl+C]", t("tui.helpShortcutForceQuit", "Force quit")], ]; const rowKeyWidth = 22; const rowDescWidth = Math.max(...shortcuts.map(([, d]) => d.length)); const innerWidth = rowKeyWidth + 2 + rowDescWidth + 2; - const titleRow = " KEYBOARD SHORTCUTS".padEnd(innerWidth); + const titleRow = ` ${t("tui.helpTitle", "KEYBOARD SHORTCUTS")}`.padEnd(innerWidth); return ( @@ -1066,12 +1068,12 @@ function MainHeader({ state }: { state: DashboardState }) { | { key: string; label: string; kind: "main" } | { key: string; label: string; kind: "interactive"; view: InteractiveView }; const tabs: Tab[] = [ - { key: "m", label: "Main", kind: "main" }, - { key: "b", label: "Board", kind: "interactive", view: "board" }, - { key: "a", label: "Agents", kind: "interactive", view: "agents" }, - { key: "g", label: "Settings", kind: "interactive", view: "settings" }, - { key: "t", label: "Git", kind: "interactive", view: "git" }, - { key: "f", label: "Files", kind: "interactive", view: "files" }, + { key: "m", label: t("tui.tabMain", "Main"), kind: "main" }, + { key: "b", label: t("tui.tabBoard", "Board"), kind: "interactive", view: "board" }, + { key: "a", label: t("tui.tabAgents", "Agents"), kind: "interactive", view: "agents" }, + { key: "g", label: t("tui.tabSettings", "Settings"), kind: "interactive", view: "settings" }, + { key: "t", label: t("tui.tabGit", "Git"), kind: "interactive", view: "git" }, + { key: "f", label: t("tui.tabFiles", "Files"), kind: "interactive", view: "files" }, ]; const showHelpHint = cols >= 110; const fullLabels = cols >= 90; @@ -2012,18 +2014,18 @@ function heartbeatFreshness(lastHeartbeatAt?: string): { fresh: boolean; label: type AgentSubView = "list" | "confirm-delete"; -function formatRunStatusLabel(status: string): string { +function formatRunStatusLabel(status: string, t: TFunction): string { switch (status) { case "completed": - return "Completed"; + return t("tui.runStatusCompleted", "Completed"); case "failed": - return "Failed"; + return t("tui.runStatusFailed", "Failed"); case "terminated": - return "Terminated"; + return t("tui.runStatusTerminated", "Terminated"); case "active": - return "Active"; + return t("tui.runStatusActive", "Active"); default: - return status.length > 0 ? `${status[0]!.toUpperCase()}${status.slice(1)}` : "Unknown"; + return status.length > 0 ? `${status[0]!.toUpperCase()}${status.slice(1)}` : t("tui.runStatusUnknown", "Unknown"); } } @@ -2042,26 +2044,26 @@ function runStatusColor(status: string): "green" | "red" | "yellow" | "cyanBrigh } } -function getRunLogLines(run: AgentRunItem): string[] { +function getRunLogLines(run: AgentRunItem, t: TFunction): string[] { if (Array.isArray(run.logs) && run.logs.length > 0) return run.logs; const lines: string[] = []; - if (run.triggerDetail) lines.push(`trigger: ${run.triggerDetail}`); - if (run.invocationSource) lines.push(`source: ${run.invocationSource}`); + if (run.triggerDetail) lines.push(`${t("tui.runLogTrigger", "trigger")}: ${run.triggerDetail}`); + if (run.invocationSource) lines.push(`${t("tui.runLogSource", "source")}: ${run.invocationSource}`); if (run.stdoutExcerpt) { - lines.push("stdout:"); + lines.push(t("tui.runLogStdout", "stdout:")); lines.push(...run.stdoutExcerpt.split(/\r?\n/).filter((line) => line.length > 0)); } if (run.stderrExcerpt) { - lines.push("stderr:"); + lines.push(t("tui.runLogStderr", "stderr:")); lines.push(...run.stderrExcerpt.split(/\r?\n/).filter((line) => line.length > 0)); } if (run.resultJson) { - lines.push("result:"); + lines.push(t("tui.runLogResult", "result:")); lines.push(JSON.stringify(run.resultJson)); } - return lines.length > 0 ? lines : ["No logs captured for this run."]; + return lines.length > 0 ? lines : [t("tui.runLogNone", "No logs captured for this run.")]; } function AgentsView({ state }: { state: DashboardState }) { @@ -2328,7 +2330,7 @@ function AgentsView({ state }: { state: DashboardState }) { {t("tui.agentRunLogsTitle", "Run logs ({{index}})", { index: selectedRunIndex + 1 })} {t("tui.agentRunId", "ID:")} {selectedRun.id} - {getRunLogLines(selectedRun).slice(0, 10).map((line, i) => ( + {getRunLogLines(selectedRun, t).slice(0, 10).map((line, i) => ( {line} ))} @@ -2373,7 +2375,7 @@ function AgentsView({ state }: { state: DashboardState }) { {detailFocused && i === selectedRunIndex ? "▶" : " "} - {formatRunStatusLabel(run.status)} + {formatRunStatusLabel(run.status, t)} {run.startedAt.slice(11, 19)} {run.triggerDetail && {run.triggerDetail}} @@ -2407,22 +2409,23 @@ type SettingKey = "maxConcurrent" | "maxWorktrees" | "autoMerge" | "mergeStrateg interface SettingDef { key: SettingKey; - label: string; + labelKey: string; + labelDefault: string; type: "number" | "boolean" | "enum"; options?: string[]; } const SETTING_DEFS: SettingDef[] = [ - { key: "maxConcurrent", label: "Max Concurrent", type: "number" }, - { key: "maxWorktrees", label: "Max Worktrees", type: "number" }, - { key: "autoMerge", label: "Auto Merge", type: "boolean" }, - { key: "mergeStrategy", label: "Merge Strategy", type: "enum", options: ["direct", "squash", "rebase"] }, - { key: "pollIntervalMs", label: "Poll Interval (ms)", type: "number" }, - { key: "enginePaused", label: "Engine Paused", type: "boolean" }, - { key: "globalPause", label: "Global Pause", type: "boolean" }, - { key: "remoteActiveProvider", label: "Remote Provider", type: "enum", options: ["tailscale", "cloudflare"] }, - { key: "remoteShortLivedEnabled", label: "Short-Lived Tokens", type: "boolean" }, - { key: "remoteShortLivedTtlMs", label: "Short-Lived TTL (ms)", type: "number" }, + { key: "maxConcurrent", labelKey: "tui.settingMaxConcurrent", labelDefault: "Max Concurrent", type: "number" }, + { key: "maxWorktrees", labelKey: "tui.settingMaxWorktrees", labelDefault: "Max Worktrees", type: "number" }, + { key: "autoMerge", labelKey: "tui.settingAutoMerge", labelDefault: "Auto Merge", type: "boolean" }, + { key: "mergeStrategy", labelKey: "tui.settingMergeStrategy", labelDefault: "Merge Strategy", type: "enum", options: ["direct", "squash", "rebase"] }, + { key: "pollIntervalMs", labelKey: "tui.settingPollIntervalMs", labelDefault: "Poll Interval (ms)", type: "number" }, + { key: "enginePaused", labelKey: "tui.settingEnginePaused", labelDefault: "Engine Paused", type: "boolean" }, + { key: "globalPause", labelKey: "tui.settingGlobalPause", labelDefault: "Global Pause", type: "boolean" }, + { key: "remoteActiveProvider", labelKey: "tui.settingRemoteActiveProvider", labelDefault: "Remote Provider", type: "enum", options: ["tailscale", "cloudflare"] }, + { key: "remoteShortLivedEnabled", labelKey: "tui.settingRemoteShortLivedEnabled", labelDefault: "Short-Lived Tokens", type: "boolean" }, + { key: "remoteShortLivedTtlMs", labelKey: "tui.settingRemoteShortLivedTtlMs", labelDefault: "Short-Lived TTL (ms)", type: "number" }, ]; function SettingsInteractiveView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) { @@ -2743,7 +2746,7 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState; {isSel ? "▶" : " "} - {def.label} + {t(def.labelKey, def.labelDefault)} {renderValue(def, localSettings)} @@ -2772,7 +2775,7 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState; {t("tui.settingsLoadingSettings", "Loading settings…")} ) : !selectedDef ? null : ( <> - {selectedDef.label} + {t(selectedDef.labelKey, selectedDef.labelDefault)} {t("tui.settingsCurrentLabel", "Current:")} diff --git a/packages/dashboard/app/__tests__/agent-runs-ui.test.ts b/packages/dashboard/app/__tests__/agent-runs-ui.test.ts index 9d9e8578a7..946d4cec8e 100644 --- a/packages/dashboard/app/__tests__/agent-runs-ui.test.ts +++ b/packages/dashboard/app/__tests__/agent-runs-ui.test.ts @@ -132,8 +132,10 @@ describe("Agent runs UI — static analysis", () => { it("wires a stop run handler", () => { expect(agentDetailViewContent).toMatch(/handleStopRun|handleStop/); - expect(agentDetailViewContent).toMatch(/title:\s*"Stop Active Run"/); - expect(agentDetailViewContent).toMatch(/message:\s*"Stop the active run\? The agent's work will be interrupted\."/); + // Title may be a raw string or an i18n t() call whose default is "Stop Active Run" + expect(agentDetailViewContent).toMatch(/title:.*"Stop Active Run"/); + // Message may be a raw string or an i18n t() call whose default is the stop confirmation + expect(agentDetailViewContent).toMatch(/message:.*"Stop the active run\? The agent's work will be interrupted\."/); }); it("references stopAgentRun and stop button copy", () => { diff --git a/packages/dashboard/app/components/ActivityLogModal.tsx b/packages/dashboard/app/components/ActivityLogModal.tsx index 59ff1ac9e9..3d213725c8 100644 --- a/packages/dashboard/app/components/ActivityLogModal.tsx +++ b/packages/dashboard/app/components/ActivityLogModal.tsx @@ -3,6 +3,7 @@ import "./ScriptsModal.css"; import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react"; import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api"; import { useActivityLog } from "../hooks/useActivityLog"; @@ -24,23 +25,25 @@ interface ActivityLogModalProps { currentProject?: ProjectInfo | null; } -const EVENT_TYPE_LABELS: Record = { - "task:created": "Task Created", - "task:moved": "Task Moved", - "task:updated": "Task Updated", - "task:deleted": "Task Deleted", - "task:merged": "Task Merged", - "task:failed": "Task Failed", - "task:duplicate-warning-overridden": "Duplicate Warning Overridden", - "task:auto-archived-ghost-bug": "Task Auto-Archived (Ghost Bug)", - "task:auto-archived-duplicate": "Task Auto-Archived (Duplicate)", - "task:merge-worktree-reacquired": "Merge Worktree Reacquired", - "task:auto-archived-deterministic-duplicate": "Task Auto-Archived (Deterministic Duplicate)", - "task:auto-archived-near-duplicate": "Task Auto-Archived (Near-Duplicate)", - "task:near-duplicate-flagged": "Near-Duplicate Flagged", - "settings:updated": "Settings Updated", - "project:isolation-transition": "Project Isolation Transition", -}; +function getEventTypeLabels(t: TFunction<"app">): Record { + return { + "task:created": t("activityLog.eventType.taskCreated", "Task Created"), + "task:moved": t("activityLog.eventType.taskMoved", "Task Moved"), + "task:updated": t("activityLog.eventType.taskUpdated", "Task Updated"), + "task:deleted": t("activityLog.eventType.taskDeleted", "Task Deleted"), + "task:merged": t("activityLog.eventType.taskMerged", "Task Merged"), + "task:failed": t("activityLog.eventType.taskFailed", "Task Failed"), + "task:duplicate-warning-overridden": t("activityLog.eventType.duplicateWarningOverridden", "Duplicate Warning Overridden"), + "task:auto-archived-ghost-bug": t("activityLog.eventType.autoArchivedGhostBug", "Task Auto-Archived (Ghost Bug)"), + "task:auto-archived-duplicate": t("activityLog.eventType.autoArchivedDuplicate", "Task Auto-Archived (Duplicate)"), + "task:merge-worktree-reacquired": t("activityLog.eventType.mergeWorktreeReacquired", "Merge Worktree Reacquired"), + "task:auto-archived-deterministic-duplicate": t("activityLog.eventType.autoArchivedDeterministicDuplicate", "Task Auto-Archived (Deterministic Duplicate)"), + "task:auto-archived-near-duplicate": t("activityLog.eventType.autoArchivedNearDuplicate", "Task Auto-Archived (Near-Duplicate)"), + "task:near-duplicate-flagged": t("activityLog.eventType.nearDuplicateFlagged", "Near-Duplicate Flagged"), + "settings:updated": t("activityLog.eventType.settingsUpdated", "Settings Updated"), + "project:isolation-transition": t("activityLog.eventType.projectIsolationTransition", "Project Isolation Transition"), + }; +} const EVENT_TYPE_ICONS: Record = { "task:created": , @@ -60,7 +63,7 @@ const EVENT_TYPE_ICONS: Record = { "project:isolation-transition": , }; -function formatTimestamp(timestamp: string): string { +function formatTimestamp(timestamp: string, t: TFunction<"app">): string { const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); @@ -68,10 +71,10 @@ function formatTimestamp(timestamp: string): string { const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; + if (diffMins < 1) return t("activityLog.time.justNow", "Just now"); + if (diffMins < 60) return t("activityLog.time.minutesAgo", "{{count}}m ago", { count: diffMins }); + if (diffHours < 24) return t("activityLog.time.hoursAgo", "{{count}}h ago", { count: diffHours }); + if (diffDays < 7) return t("activityLog.time.daysAgo", "{{count}}d ago", { count: diffDays }); return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } @@ -103,6 +106,7 @@ export function ActivityLogModal({ currentProject, }: ActivityLogModalProps) { const { t } = useTranslation("app"); + const EVENT_TYPE_LABELS = getEventTypeLabels(t); const [filteredType, setFilteredType] = useState("all"); const [filteredProjectId, setFilteredProjectId] = useState(projectId || "all"); const [showConfirmClear, setShowConfirmClear] = useState(false); @@ -361,7 +365,7 @@ export function ActivityLogModal({ {EVENT_TYPE_LABELS[entry.type]} - {formatTimestamp(entry.timestamp)} + {formatTimestamp(entry.timestamp, t)}
@@ -388,7 +392,9 @@ export function ActivityLogModal({ )} {typeof entry.metadata.merged === "boolean" && ( - {entry.metadata.merged ? "Merged" : "Not merged"} + {entry.metadata.merged + ? t("activityLog.merged", "Merged") + : t("activityLog.notMerged", "Not merged")} )}
diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 94a89cf09c..c5b580419d 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -44,25 +44,32 @@ function cn(...classes: (string | boolean | undefined | null)[]): string { /** * Format an ISO timestamp to a relative time string. */ -export function relativeTime(iso: string): string { +export function relativeTime(iso: string, t?: (key: string, defaultValue: string, options?: Record) => string): string { const now = Date.now(); const then = new Date(iso).getTime(); const diffMs = now - then; + // Fallback interpolates {{n}} manually when no t() is provided + const tr = t ?? ((_key: string, def: string, opts?: Record) => { + if (!opts) return def; + return def.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? "")); + }); // Future if (diffMs < 0) { const absDiff = Math.abs(diffMs); - if (absDiff < 60_000) return "in a moment"; - if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`; - if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`; - return `in ${Math.floor(absDiff / 86_400_000)}d`; + if (absDiff < 60_000) return tr("time.inAMoment", "in a moment"); + if (absDiff < 3_600_000) { const n = Math.floor(absDiff / 60_000); return tr("time.inMinutes", "in {{n}}m", { n }); } + if (absDiff < 86_400_000) { const n = Math.floor(absDiff / 3_600_000); return tr("time.inHours", "in {{n}}h", { n }); } + const n = Math.floor(absDiff / 86_400_000); + return tr("time.inDays", "in {{n}}d", { n }); } // Past - if (diffMs < 60_000) return "just now"; - if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`; - if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`; - return `${Math.floor(diffMs / 86_400_000)}d ago`; + if (diffMs < 60_000) return tr("time.justNow", "just now"); + if (diffMs < 3_600_000) { const n = Math.floor(diffMs / 60_000); return tr("time.minutesAgo", "{{n}}m ago", { n }); } + if (diffMs < 86_400_000) { const n = Math.floor(diffMs / 3_600_000); return tr("time.hoursAgo", "{{n}}h ago", { n }); } + const n = Math.floor(diffMs / 86_400_000); + return tr("time.daysAgo", "{{n}}d ago", { n }); } interface AgentDetailViewProps { @@ -548,7 +555,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild const successCount = results.length - failedResults.length; const failureCount = failedResults.length; - const baseSummary = t("agents.bulkResult", "{{action}} {{successCount}} agent(s); skipped {{skippedCount}}", { action: targetState === "paused" ? t("agents.pausedPast", "Paused") : t("agents.resumedPast", "Resumed"), successCount, skippedCount }); + const actionWord = targetState === "paused" ? t("agents.pausedPast", "Paused") : t("agents.resumedPast", "Resumed"); + const agentWord = successCount === 1 ? t("agents.agentSingular", "agent") : t("agents.agentPlural", "agents"); + const baseSummary = t(successCount === 1 ? "agents.bulkResult_one" : "agents.bulkResult_other", "{{action}} {{successCount}} {{agentWord}}; skipped {{skippedCount}}", { action: actionWord, successCount, agentWord, skippedCount }); if (failureCount > 0) { const failureSummary = failedResults @@ -822,7 +831,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild ? t("agents.loadingEligible", "Loading eligible agents...") : isPauseAllDisabled ? t("agents.noActiveEligible", "No active agents eligible") - : t("agents.pauseCountHint", "Pause {{count}} active/running agent(s)", { count: bulkPauseEligibleCount })} + : t(bulkPauseEligibleCount === 1 ? "agents.pauseCountHint_one" : "agents.pauseCountHint_other", bulkPauseEligibleCount === 1 ? "Pause {{count}} active/running agent" : "Pause {{count}} active/running agents", { count: bulkPauseEligibleCount })} @@ -1264,11 +1273,11 @@ function DashboardTab({

{t("agents.lastHeartbeat", "Last heartbeat")}

-

{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : t("agents.never", "Never")}

+

{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt, t) : t("agents.never", "Never")}

{t("agents.nextExpected", "Next expected")}

-

{nextHeartbeatAt ? relativeTime(nextHeartbeatAt) : t("agents.notScheduled", "Not scheduled")}

+

{nextHeartbeatAt ? relativeTime(nextHeartbeatAt, t) : t("agents.notScheduled", "Not scheduled")}

{t("agents.interval", "Interval")}

@@ -1306,7 +1315,7 @@ function DashboardTab({ return (
- {relativeTime(run.startedAt)} + {relativeTime(run.startedAt, t)} {Math.max(0, Math.round((new Date(run.endedAt || run.startedAt).getTime() - new Date(run.startedAt).getTime()) / 1000))}s
); @@ -1409,18 +1418,22 @@ function LogsTab({ ); } -function formatMailboxTimestamp(ts: string): string { +function formatMailboxTimestamp(ts: string, t?: (key: string, defaultValue: string, options?: Record) => string): string { const date = new Date(ts); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); + const tr = t ?? ((_key: string, def: string, opts?: Record) => { + if (!opts) return def; + return def.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? "")); + }); - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; + if (diffMins < 1) return tr("time.justNow", "just now"); + if (diffMins < 60) return tr("time.minutesAgo", "{{n}}m ago", { n: diffMins }); + if (diffHours < 24) return tr("time.hoursAgo", "{{n}}h ago", { n: diffHours }); + if (diffDays < 7) return tr("time.daysAgo", "{{n}}d ago", { n: diffDays }); return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } @@ -1429,14 +1442,19 @@ function mailboxParticipantLabel( id: string, type: ParticipantType, agentNamesById?: ReadonlyMap, + t?: (key: string, defaultValue: string, options?: Record) => string, ): string { - if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`; + const tr = t ?? ((_key: string, def: string, opts?: Record) => { + if (!opts) return def; + return def.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? "")); + }); + if (type === "user") return id === "dashboard" ? tr("mailbox.you", "You") : tr("mailbox.userLabel", "User: {{id}}", { id }); if (type === "agent") { const name = agentNamesById?.get(id)?.trim(); - if (!name || name === id) return `Agent: ${id}`; - return `Agent: ${name}`; + if (!name || name === id) return tr("mailbox.agentById", "Agent: {{id}}", { id }); + return tr("mailbox.agentByName", "Agent: {{name}}", { name }); } - return "System"; + return tr("mailbox.system", "System"); } function MailTab({ @@ -1542,11 +1560,11 @@ function MailTab({
{activeSubtab === "inbox" ? ( - {mailboxParticipantLabel(message.fromId, message.fromType, agentNamesById)} + {mailboxParticipantLabel(message.fromId, message.fromType, agentNamesById, t)} ) : ( - {t("agents.mailTo", "To: {{recipient}}", { recipient: mailboxParticipantLabel(message.toId, message.toType, agentNamesById) })} + {t("agents.mailTo", "To: {{recipient}}", { recipient: mailboxParticipantLabel(message.toId, message.toType, agentNamesById, t) })} )} - {formatMailboxTimestamp(message.createdAt)} + {formatMailboxTimestamp(message.createdAt, t)}
{message.content.slice(0, 80)}{message.content.length > 80 ? "…" : ""}
@@ -1611,11 +1629,11 @@ function MailTab({
{t("agents.mailFrom", "From")} - {mailboxParticipantLabel(selectedMessage.fromId, selectedMessage.fromType, agentNamesById)} + {mailboxParticipantLabel(selectedMessage.fromId, selectedMessage.fromType, agentNamesById, t)}
{t("agents.mailToLabel", "To")} - {mailboxParticipantLabel(selectedMessage.toId, selectedMessage.toType, agentNamesById)} + {mailboxParticipantLabel(selectedMessage.toId, selectedMessage.toType, agentNamesById, t)}
{t("agents.mailType", "Type")} @@ -1977,7 +1995,7 @@ function RunsTab({
- {t("agents.runStarted", "Started {{time}}", { time: relativeTime(run.startedAt) })} + {t("agents.runStarted", "Started {{time}}", { time: relativeTime(run.startedAt, t) })} • {duration} {run.triggerDetail && ( @@ -2271,7 +2289,7 @@ function TasksTab({ {truncateTaskLabel(task)}
- {task.status ?? "idle"} · {t("agents.taskUpdated", "Updated {{time}}", { time: relativeTime(task.updatedAt) })} + {task.status ?? "idle"} · {t("agents.taskUpdated", "Updated {{time}}", { time: relativeTime(task.updatedAt, t) })}
))} @@ -2805,7 +2823,7 @@ function MemoryTab({
{({ "long-term": t("agents.memoryLayerLongTerm", "Long-term"), daily: t("agents.memoryLayerDaily", "Daily"), dreams: t("agents.memoryLayerDreams", "Dreams") } as Record)[selectedMemoryFile.layer] ?? selectedMemoryFile.layer} · {selectedLayerDescription}
- {t("agents.memoryFileMeta", "{{size}} bytes · Updated {{time}}", { size: selectedMemoryFile.size.toLocaleString(), time: relativeTime(selectedMemoryFile.updatedAt) })} + {t("agents.memoryFileMeta", "{{size}} bytes · Updated {{time}}", { size: selectedMemoryFile.size.toLocaleString(), time: relativeTime(selectedMemoryFile.updatedAt, t) })}
)} diff --git a/packages/dashboard/app/components/AgentErrorDetailsModal.tsx b/packages/dashboard/app/components/AgentErrorDetailsModal.tsx index 91370955cf..24d7e28091 100644 --- a/packages/dashboard/app/components/AgentErrorDetailsModal.tsx +++ b/packages/dashboard/app/components/AgentErrorDetailsModal.tsx @@ -59,14 +59,14 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext } return ( -
event.target === event.currentTarget && onClose()} role="dialog" aria-modal="true" aria-label="Agent error details"> +
event.target === event.currentTarget && onClose()} role="dialog" aria-modal="true" aria-label={t("agentError.dialogLabel", "Agent error details")}>

{t("agentError.title", "Agent Error Details")}

- +
{errorText}
diff --git a/packages/dashboard/app/components/AgentImportModal.tsx b/packages/dashboard/app/components/AgentImportModal.tsx index b830ca8c4e..4b321edbb4 100644 --- a/packages/dashboard/app/components/AgentImportModal.tsx +++ b/packages/dashboard/app/components/AgentImportModal.tsx @@ -432,18 +432,18 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi const selectedAgentCount = selectedAgentNames.length; const selectedSkillCount = selectedSkillNames.length; - const selectedAgentLabel = `${selectedAgentCount} Agent${selectedAgentCount !== 1 ? "s" : ""}`; - const selectedSkillLabel = `${selectedSkillCount} Skill${selectedSkillCount !== 1 ? "s" : ""}`; + const selectedAgentLabel = t("agents.selectedAgentLabel", "{{count}} Agent{{plural}}", { count: selectedAgentCount, plural: selectedAgentCount !== 1 ? "s" : "" }); + const selectedSkillLabel = t("agents.selectedSkillLabel", "{{count}} Skill{{plural}}", { count: selectedSkillCount, plural: selectedSkillCount !== 1 ? "s" : "" }); const importActionLabel = selectedAgentCount > 0 && selectedSkillCount > 0 ? `${selectedAgentLabel} + ${selectedSkillLabel}` : selectedSkillCount > 0 ? selectedSkillLabel : selectedAgentLabel; const importLoadingLabel = selectedAgentCount > 0 && selectedSkillCount > 0 - ? `Importing ${selectedAgentCount} agent${selectedAgentCount !== 1 ? "s" : ""} and ${selectedSkillCount} skill${selectedSkillCount !== 1 ? "s" : ""}...` + ? t("agents.importingAgentsAndSkills", "Importing {{agentCount}} agent{{agentPlural}} and {{skillCount}} skill{{skillPlural}}...", { agentCount: selectedAgentCount, agentPlural: selectedAgentCount !== 1 ? "s" : "", skillCount: selectedSkillCount, skillPlural: selectedSkillCount !== 1 ? "s" : "" }) : selectedSkillCount > 0 - ? `Importing ${selectedSkillCount} skill${selectedSkillCount !== 1 ? "s" : ""}...` - : `Importing ${selectedAgentCount} agent${selectedAgentCount !== 1 ? "s" : ""}...`; + ? t("agents.importingSkills", "Importing {{count}} skill{{plural}}...", { count: selectedSkillCount, plural: selectedSkillCount !== 1 ? "s" : "" }) + : t("agents.importingAgents", "Importing {{count}} agent{{plural}}...", { count: selectedAgentCount, plural: selectedAgentCount !== 1 ? "s" : "" }); const toggleAgentSelection = (name: string) => { setSelectedAgentNames((current) => ( @@ -647,7 +647,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi {/* Text area for paste */}