diff --git a/packages/views/eslint.config.mjs b/packages/views/eslint.config.mjs index 97e249e51c..019c7a3dc0 100644 --- a/packages/views/eslint.config.mjs +++ b/packages/views/eslint.config.mjs @@ -78,6 +78,10 @@ const TRANSLATED_FILES = [ "settings/components/workspace-tab.tsx", "settings/components/members-tab.tsx", "settings/components/delete-workspace-dialog.tsx", + "runtimes/components/runtimes-page.tsx", + "runtimes/components/runtime-detail-page.tsx", + "runtimes/components/runtime-detail.tsx", + "runtimes/components/shared.tsx", ]; export default [ diff --git a/packages/views/i18n/resources-types.ts b/packages/views/i18n/resources-types.ts index 80cfc91d42..6492250221 100644 --- a/packages/views/i18n/resources-types.ts +++ b/packages/views/i18n/resources-types.ts @@ -18,6 +18,7 @@ import type autopilots from "../locales/en/autopilots.json"; import type skills from "../locales/en/skills.json"; import type chat from "../locales/en/chat.json"; import type modals from "../locales/en/modals.json"; +import type runtimes from "../locales/en/runtimes.json"; // Module augmentation enables i18next v26 selector API across the monorepo: // `t($ => $.signin.title)` resolves to the value in en/auth.json. @@ -50,6 +51,7 @@ declare module "i18next" { skills: typeof skills; chat: typeof chat; modals: typeof modals; + runtimes: typeof runtimes; }; enableSelector: true; } diff --git a/packages/views/locales/en/runtimes.json b/packages/views/locales/en/runtimes.json new file mode 100644 index 0000000000..32e2431463 --- /dev/null +++ b/packages/views/locales/en/runtimes.json @@ -0,0 +1,85 @@ +{ + "page": { + "title": "Runtimes", + "tagline": "Machines and cloud workers running CLI sessions for your agents.", + "learn_more": "Learn more →", + "connect_remote": "Connect remote machine", + "search_placeholder": "Search runtimes…", + "scope_mine": "Mine", + "scope_all": "All", + "live": "Live", + "live_tooltip": "Real-time updates · offline detection up to 75s", + "filter_all": "All", + "filter_all_description": "All runtimes in this view", + "empty": { + "title": "No runtimes yet", + "hint": "Desktop auto-scans your local machine. For AWS EC2 or other remote machines, connect them using the setup wizard." + }, + "no_matches": { + "title": "No matches", + "with_query": "No runtimes match \"{{query}}\"{{filterSuffix}}.", + "with_query_filter_suffix": " in this filter", + "no_query": "No runtimes match this filter.", + "try_widening": " Try widening the scope or clearing filters." + }, + "bootstrapping": { + "title": "Starting local runtime…", + "hint": "This usually takes a few seconds. Your daemon is registering with the workspace." + } + }, + "health": { + "online": { + "label": "Online", + "description": "Heartbeat received in the last 45s. Ready to dispatch tasks." + }, + "recently_lost": { + "label": "Recently lost", + "description": "Lost contact under 5 minutes ago — often a brief network blip." + }, + "offline": { + "label": "Offline", + "description": "No heartbeat for 5+ minutes. Restart the daemon or investigate the host." + }, + "about_to_gc": { + "label": "About to GC", + "description": "Offline 6+ days. Auto-deleted at 7 days unless it reconnects." + } + }, + "detail": { + "all_runtimes": "All runtimes", + "read_only": "Read-only", + "delete_aria": "Delete runtime", + "delete_tooltip": "Delete runtime", + "delete_button": "Delete runtime", + "last_seen": "last seen {{when}}", + "fact_owner": "Owner", + "fact_device": "Device", + "fact_runtime": "Runtime", + "technical_details": "Technical details", + "fact_daemon_cli": "Daemon CLI", + "fact_daemon_id": "Daemon ID", + "serving_title": "Serving", + "serving_count_one": "{{count}} agent", + "serving_count_other": "{{count}} agents", + "no_agents": "No agents are bound to this runtime yet.", + "diagnostics_title": "Diagnostics", + "diagnostics_cli": "CLI", + "delete_dialog": { + "title": "Delete Runtime", + "description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "cancel": "Cancel", + "confirm": "Delete", + "deleting": "Deleting..." + }, + "toast_deleted": "Runtime deleted", + "toast_delete_failed": "Failed to delete runtime" + }, + "detail_page": { + "not_found_title": "Runtime not found", + "not_found_hint": "It may have been deleted or you may not have access." + }, + "running_one": "{{count}} running", + "running_other": "{{count}} running", + "queued_one": "{{count}} queued", + "queued_other": "{{count}} queued" +} diff --git a/packages/views/locales/index.ts b/packages/views/locales/index.ts index 976cfe85bc..5e54c43aaf 100644 --- a/packages/views/locales/index.ts +++ b/packages/views/locales/index.ts @@ -15,6 +15,7 @@ import enAutopilots from "./en/autopilots.json"; import enSkills from "./en/skills.json"; import enChat from "./en/chat.json"; import enModals from "./en/modals.json"; +import enRuntimes from "./en/runtimes.json"; import zhHansCommon from "./zh-Hans/common.json"; import zhHansAuth from "./zh-Hans/auth.json"; import zhHansSettings from "./zh-Hans/settings.json"; @@ -31,6 +32,7 @@ import zhHansAutopilots from "./zh-Hans/autopilots.json"; import zhHansSkills from "./zh-Hans/skills.json"; import zhHansChat from "./zh-Hans/chat.json"; import zhHansModals from "./zh-Hans/modals.json"; +import zhHansRuntimes from "./zh-Hans/runtimes.json"; // Single source of truth for the resource bundle. Both apps (web layout + // desktop App.tsx) import from here so adding a locale or namespace happens @@ -53,6 +55,7 @@ export const RESOURCES: Record = { skills: enSkills, chat: enChat, modals: enModals, + runtimes: enRuntimes, }, "zh-Hans": { common: zhHansCommon, @@ -71,5 +74,6 @@ export const RESOURCES: Record = { skills: zhHansSkills, chat: zhHansChat, modals: zhHansModals, + runtimes: zhHansRuntimes, }, }; diff --git a/packages/views/locales/zh-Hans/runtimes.json b/packages/views/locales/zh-Hans/runtimes.json new file mode 100644 index 0000000000..83cf6454b9 --- /dev/null +++ b/packages/views/locales/zh-Hans/runtimes.json @@ -0,0 +1,82 @@ +{ + "page": { + "title": "运行时", + "tagline": "为智能体跑 CLI 会话的机器和云端 worker。", + "learn_more": "了解更多 →", + "connect_remote": "连接远程机器", + "search_placeholder": "搜索运行时…", + "scope_mine": "我的", + "scope_all": "全部", + "live": "实时", + "live_tooltip": "实时更新 · 离线检测 75 秒以内", + "filter_all": "全部", + "filter_all_description": "当前视图下的所有运行时", + "empty": { + "title": "还没有运行时", + "hint": "桌面端会自动扫描本地机器。对于 AWS EC2 或其他远程机器,可使用连接向导。" + }, + "no_matches": { + "title": "无匹配", + "with_query": "没有运行时匹配 \"{{query}}\"{{filterSuffix}}。", + "with_query_filter_suffix": "(在当前筛选下)", + "no_query": "当前筛选下没有匹配的运行时。", + "try_widening": "试试放宽筛选或清除条件。" + }, + "bootstrapping": { + "title": "正在启动本地运行时…", + "hint": "通常需要几秒钟。守护进程正在向工作区注册。" + } + }, + "health": { + "online": { + "label": "在线", + "description": "最近 45 秒内收到心跳。可以派发 task。" + }, + "recently_lost": { + "label": "最近失联", + "description": "失联不到 5 分钟——通常是短暂的网络抖动。" + }, + "offline": { + "label": "离线", + "description": "5 分钟以上没有心跳。请重启守护进程或排查主机。" + }, + "about_to_gc": { + "label": "即将清理", + "description": "离线 6 天以上。如不重连,将在 7 天时自动删除。" + } + }, + "detail": { + "all_runtimes": "全部运行时", + "read_only": "只读", + "delete_aria": "删除运行时", + "delete_tooltip": "删除运行时", + "delete_button": "删除运行时", + "last_seen": "最后活跃 {{when}}", + "fact_owner": "所有者", + "fact_device": "设备", + "fact_runtime": "运行时", + "technical_details": "技术详情", + "fact_daemon_cli": "守护进程 CLI", + "fact_daemon_id": "守护进程 ID", + "serving_title": "服务中", + "serving_count_other": "{{count}} 个智能体", + "no_agents": "还没有智能体绑定到这个运行时。", + "diagnostics_title": "诊断", + "diagnostics_cli": "CLI", + "delete_dialog": { + "title": "删除运行时", + "description": "确定要删除\"{{name}}\"吗?此操作不可撤销。", + "cancel": "取消", + "confirm": "删除", + "deleting": "删除中..." + }, + "toast_deleted": "已删除运行时", + "toast_delete_failed": "删除运行时失败" + }, + "detail_page": { + "not_found_title": "未找到运行时", + "not_found_hint": "它可能已被删除,或你没有访问权限。" + }, + "running_other": "{{count}} 个运行中", + "queued_other": "{{count}} 个排队中" +} diff --git a/packages/views/runtimes/components/runtime-detail-page.tsx b/packages/views/runtimes/components/runtime-detail-page.tsx index fc23482109..2b0d3ffb94 100644 --- a/packages/views/runtimes/components/runtime-detail-page.tsx +++ b/packages/views/runtimes/components/runtime-detail-page.tsx @@ -6,6 +6,7 @@ import { useWorkspaceId } from "@multica/core/hooks"; import { runtimeListOptions } from "@multica/core/runtimes/queries"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { RuntimeDetail } from "./runtime-detail"; +import { useT } from "../../i18n"; /** * Routed entry for `/{slug}/runtimes/{id}`. Reads the workspace runtime list @@ -16,6 +17,7 @@ import { RuntimeDetail } from "./runtime-detail"; * keeps cache invariants simple (one cache, one update path). */ export function RuntimeDetailPage({ runtimeId }: { runtimeId: string }) { + const { t } = useT("runtimes"); const wsId = useWorkspaceId(); const { data: runtimes, isLoading } = useQuery(runtimeListOptions(wsId)); @@ -38,9 +40,9 @@ export function RuntimeDetailPage({ runtimeId }: { runtimeId: string }) { return (
-

Runtime not found

+

{t(($) => $.detail_page.not_found_title)}

- It may have been deleted or you may not have access. + {t(($) => $.detail_page.not_found_hint)}

); diff --git a/packages/views/runtimes/components/runtime-detail.tsx b/packages/views/runtimes/components/runtime-detail.tsx index faba56b6e4..7144c354c9 100644 --- a/packages/views/runtimes/components/runtime-detail.tsx +++ b/packages/views/runtimes/components/runtime-detail.tsx @@ -45,6 +45,7 @@ import { HealthBadge } from "./shared"; import { ProviderLogo } from "./provider-logo"; import { UpdateSection } from "./update-section"; import { UsageSection } from "./usage-section"; +import { useT } from "../../i18n"; function getCliVersion(metadata: Record): string | null { if ( @@ -89,6 +90,7 @@ function useNowTick(intervalMs = 30_000): number { } export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { + const { t } = useT("runtimes"); const cliVersion = runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null; const launchedBy = @@ -124,11 +126,11 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { const handleDelete = () => { deleteMutation.mutate(runtime.id, { onSuccess: () => { - toast.success("Runtime deleted"); + toast.success(t(($) => $.detail.toast_deleted)); setDeleteOpen(false); }, onError: (e) => { - toast.error(e instanceof Error ? e.message : "Failed to delete runtime"); + toast.error(e instanceof Error ? e.message : t(($) => $.detail.toast_delete_failed)); }, }); }; @@ -148,7 +150,7 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { render={} > - All runtimes + {t(($) => $.detail.all_runtimes)} @@ -158,7 +160,7 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { {!canDelete && ( - Read-only + {t(($) => $.detail.read_only)} )} {canDelete && ( @@ -170,13 +172,13 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { size="icon-sm" onClick={() => setDeleteOpen(true)} className="text-muted-foreground hover:text-destructive" - aria-label="Delete runtime" + aria-label={t(($) => $.detail.delete_aria)} > } /> - Delete runtime + {t(($) => $.detail.delete_tooltip)} )} @@ -223,19 +225,19 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { { if (!v) setDeleteOpen(false); }}> - Delete Runtime + {t(($) => $.detail.delete_dialog.title)} - Are you sure you want to delete “{runtime.name}”? This action cannot be undone. + {t(($) => $.detail.delete_dialog.description, { name: runtime.name })} - Cancel + {t(($) => $.detail.delete_dialog.cancel)} - {deleteMutation.isPending ? "Deleting..." : "Delete"} + {deleteMutation.isPending ? t(($) => $.detail.delete_dialog.deleting) : t(($) => $.detail.delete_dialog.confirm)} diff --git a/packages/views/runtimes/components/runtimes-page.tsx b/packages/views/runtimes/components/runtimes-page.tsx index 5dcf308b5b..5623d38e66 100644 --- a/packages/views/runtimes/components/runtimes-page.tsx +++ b/packages/views/runtimes/components/runtimes-page.tsx @@ -20,6 +20,7 @@ import { import { PageHeader } from "../../layout/page-header"; import { ConnectRemoteDialog } from "./connect-remote-dialog"; import { RuntimeList } from "./runtime-list"; +import { useT } from "../../i18n"; type RuntimeFilter = "mine" | "all"; type HealthFilter = "all" | "online" | "recently_lost" | "offline" | "about_to_gc"; @@ -32,35 +33,12 @@ const HEALTH_ORDER: HealthFilter[] = [ "about_to_gc", ]; -// Single source of truth for the 4-state chip visuals + tooltip copy. -// Thresholds come from `deriveRuntimeHealth`: 45s heartbeat window (server -// sweeper), 5 min "recently lost" cutoff, 6 day "about_to_gc" trigger, -// 7 day hard GC. Wording leans on what the user should *do*, not the -// internals of the sweeper — those live in the redesign doc. -const HEALTH_CHIP: Record< - Exclude, - { label: string; dot: string; description: string } -> = { - online: { - label: "Online", - dot: "bg-success", - description: "Heartbeat received in the last 45s. Ready to dispatch tasks.", - }, - recently_lost: { - label: "Recently lost", - dot: "bg-warning", - description: "Lost contact under 5 minutes ago — often a brief network blip.", - }, - offline: { - label: "Offline", - dot: "bg-muted-foreground/40", - description: "No heartbeat for 5+ minutes. Restart the daemon or investigate the host.", - }, - about_to_gc: { - label: "About to GC", - dot: "bg-destructive", - description: "Offline 6+ days. Auto-deleted at 7 days unless it reconnects.", - }, +// Dot tokens stay in code — labels/descriptions flow through useT. +const HEALTH_DOT: Record, string> = { + online: "bg-success", + recently_lost: "bg-warning", + offline: "bg-muted-foreground/40", + about_to_gc: "bg-destructive", }; interface RuntimesPageProps { @@ -214,31 +192,32 @@ function PageHeaderBar({ totalCount: number; onConnectRemote: () => void; }) { + const { t } = useT("runtimes"); return (
-

Runtimes

+

{t(($) => $.page.title)}

{totalCount > 0 && ( {totalCount} )}

- Machines and cloud workers running CLI sessions for your agents.{" "} + {t(($) => $.page.tagline)}{" "} - Learn more → + {t(($) => $.page.learn_more)}

); @@ -268,6 +247,7 @@ function CardToolbar({ scope: RuntimeFilter; setScope: (v: RuntimeFilter) => void; }) { + const { t } = useT("runtimes"); return (
@@ -275,7 +255,7 @@ function CardToolbar({ setSearch(e.target.value)} - placeholder="Search runtimes…" + placeholder={t(($) => $.page.search_placeholder)} className="h-8 w-64 pl-8 text-sm" />
@@ -288,12 +268,12 @@ function CardToolbar({ - Live + {t(($) => $.page.live)}
} /> - Real-time updates · offline detection up to 75s + {t(($) => $.page.live_tooltip)} @@ -307,9 +287,7 @@ function ScopeSegment({ value: RuntimeFilter; onChange: (v: RuntimeFilter) => void; }) { - // Mine first — that's the default selection and the more frequent - // scope (your own runtimes), so it lives in the leading slot. Mirrors - // the Agents page convention. + const { t } = useT("runtimes"); return (
); @@ -353,21 +331,27 @@ function FilterChipsRow({ healthCounts: Record, number>; total: number; }) { + const { t } = useT("runtimes"); return (
{HEALTH_ORDER.map((key) => { const count = key === "all" ? total : healthCounts[key]; - const visual = key === "all" ? null : HEALTH_CHIP[key]; + const label = + key === "all" + ? t(($) => $.page.filter_all) + : t(($) => $.health[key].label); const description = - key === "all" ? "All runtimes in this view" : visual!.description; + key === "all" + ? t(($) => $.page.filter_all_description) + : t(($) => $.health[key].description); return ( setHealthFilter(key)} - label={visual?.label ?? "All"} + label={label} count={count} - dotClass={visual?.dot} + dotClass={key === "all" ? undefined : HEALTH_DOT[key]} description={description} /> ); @@ -430,15 +414,15 @@ function HealthChip({ // --------------------------------------------------------------------------- function EmptyState({ onConnectRemote }: { onConnectRemote: () => void }) { + const { t } = useT("runtimes"); return (
-

No runtimes yet

+

{t(($) => $.page.empty.title)}

- Desktop auto-scans your local machine. For AWS EC2 or other remote - machines, connect them using the setup wizard. + {t(($) => $.page.empty.hint)}

); @@ -470,13 +454,14 @@ function NoMatchesState({ scope: RuntimeFilter; bootstrapping?: boolean; }) { + const { t } = useT("runtimes"); if (bootstrapping) { return (
-

Starting local runtime…

+

{t(($) => $.page.bootstrapping.title)}

- This usually takes a few seconds. Your daemon is registering with the workspace. + {t(($) => $.page.bootstrapping.hint)}

); @@ -485,16 +470,17 @@ function NoMatchesState({ const hasSearch = search.length > 0; const hasHealthFilter = healthFilter !== "all"; const hasScope = scope === "mine"; + const filterSuffix = hasHealthFilter || hasScope ? t(($) => $.page.no_matches.with_query_filter_suffix) : ""; return (
-

No matches

+

{t(($) => $.page.no_matches.title)}

{hasSearch - ? `No runtimes match "${search}"${hasHealthFilter || hasScope ? " in this filter" : ""}.` - : "No runtimes match this filter."}{" "} - Try widening the scope or clearing filters. + ? t(($) => $.page.no_matches.with_query, { query: search, filterSuffix }) + : t(($) => $.page.no_matches.no_query)} + {t(($) => $.page.no_matches.try_widening)}

); diff --git a/packages/views/runtimes/components/shared.tsx b/packages/views/runtimes/components/shared.tsx index 9909aae628..a9911f2ad2 100644 --- a/packages/views/runtimes/components/shared.tsx +++ b/packages/views/runtimes/components/shared.tsx @@ -2,6 +2,7 @@ import { Cloud, Monitor, Wifi, WifiHigh, WifiOff } from "lucide-react"; import { Badge } from "@multica/ui/components/ui/badge"; import type { RuntimeHealth } from "@multica/core/runtimes"; import { ProviderLogo } from "./provider-logo"; +import { useT } from "../../i18n"; export function RuntimeModeIcon({ mode }: { mode: string }) { return mode === "cloud" ? ( @@ -26,30 +27,13 @@ export function ProviderChip({ provider }: { provider: string }) { // The mapping intentionally reuses our existing tokens (success/warning/ // muted-foreground/destructive) instead of introducing runtime-specific // colours — keeps the palette small and consistent with Skills. -const HEALTH_VISUAL: Record< - RuntimeHealth, - { dot: string; label: string; tone: string } -> = { - online: { - dot: "bg-success", - label: "Online", - tone: "bg-success/10 text-success", - }, - recently_lost: { - dot: "bg-warning", - label: "Recently lost", - tone: "bg-warning/10 text-warning", - }, - offline: { - dot: "bg-muted-foreground/40", - label: "Offline", - tone: "bg-muted text-muted-foreground", - }, - about_to_gc: { - dot: "bg-destructive", - label: "About to GC", - tone: "bg-destructive/10 text-destructive", - }, +// Maps each derived 4-state runtime health to a semantic colour class. +// Labels flow through useT — see useHealthLabel below. +const HEALTH_VISUAL: Record = { + online: { dot: "bg-success", tone: "bg-success/10 text-success" }, + recently_lost: { dot: "bg-warning", tone: "bg-warning/10 text-warning" }, + offline: { dot: "bg-muted-foreground/40", tone: "bg-muted text-muted-foreground" }, + about_to_gc: { dot: "bg-destructive", tone: "bg-destructive/10 text-destructive" }, }; export function HealthDot({ @@ -106,9 +90,29 @@ export function HealthIcon({ return ; } +// English-only fallback. Pure function form for non-component callers +// (e.g. column factory builders). Translated call sites should use the +// `useHealthLabel` hook below instead. +const HEALTH_LABEL_EN: Record = { + online: "Online", + recently_lost: "Recently lost", + offline: "Offline", + about_to_gc: "About to GC", +}; + export function healthLabel(health: RuntimeHealth | "loading"): string { if (health === "loading") return "—"; - return HEALTH_VISUAL[health].label; + return HEALTH_LABEL_EN[health]; +} + +// Hook form: usable inside React components (preferred for new call sites +// that aren't running in non-component contexts). +export function useHealthLabel(): (health: RuntimeHealth | "loading") => string { + const { t } = useT("runtimes"); + return (health) => { + if (health === "loading") return "—"; + return t(($) => $.health[health].label); + }; } export function HealthBadge({ @@ -116,6 +120,7 @@ export function HealthBadge({ }: { health: RuntimeHealth | "loading"; }) { + const labelOf = useHealthLabel(); if (health === "loading") { return ( @@ -127,7 +132,7 @@ export function HealthBadge({ return ( - {v.label} + {labelOf(health)} ); }