diff --git a/surfaces/gui/package-lock.json b/surfaces/gui/package-lock.json index 56baf1e1..d4eb573b 100644 --- a/surfaces/gui/package-lock.json +++ b/surfaces/gui/package-lock.json @@ -8,9 +8,11 @@ "name": "openworker-gui", "version": "0.0.0", "dependencies": { + "i18next": "^26.3.6", "pdfjs-dist": "^4.10.38", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "simple-icons": "^16.26.0", @@ -98,6 +100,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -305,7 +308,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -447,6 +449,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -470,6 +473,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1799,6 +1803,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1952,6 +1957,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1963,6 +1969,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2316,6 +2323,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3204,6 +3212,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -3242,6 +3259,35 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3392,6 +3438,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3408,6 +3455,7 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -4713,6 +4761,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -4917,6 +4966,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4929,6 +4979,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4937,6 +4988,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -5481,6 +5559,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5608,8 +5687,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5736,6 +5816,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5777,6 +5866,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/surfaces/gui/package.json b/surfaces/gui/package.json index c909514d..aeceff89 100644 --- a/surfaces/gui/package.json +++ b/surfaces/gui/package.json @@ -14,9 +14,11 @@ "tauri": "tauri" }, "dependencies": { + "i18next": "^26.3.6", "pdfjs-dist": "^4.10.38", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "simple-icons": "^16.26.0", diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index d18825cf..e81039e9 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; +import { useTranslation } from "react-i18next"; import { announceInboxUnlock, finalizeAutomationRun, @@ -69,12 +70,6 @@ import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; const newId = () => (crypto as any).randomUUID ? crypto.randomUUID().slice(0, 12) : Math.random().toString(36).slice(2, 14); -const SUGGESTIONS = [ - { ico: "⚙", text: "Run the test suite and summarize any failures." }, - { ico: "✦", text: "Read the project and give me a 5-bullet overview." }, - { ico: "↻", text: "Find and fix the failing build." }, -]; - // Tools whose success means a new/changed file should show up under Artifacts right away. const FILE_WRITE_TOOLS = new Set(["write_file", "apply_patch", "apply_unified_diff", "replace_in_file"]); @@ -153,6 +148,12 @@ function fallbackWorkspace(current: string | null, projects: RecentWorkspace[]): } export function App() { + const { t } = useTranslation(); + const SUGGESTIONS = [ + { ico: "⚙", text: t("app.suggestion1") }, + { ico: "✦", text: t("app.suggestion2") }, + { ico: "↻", text: t("app.suggestion3") }, + ]; const [workspace, setWorkspace] = useState(null); const [branch, setBranch] = useState(null); const [showGate, setShowGate] = useState(false); @@ -717,13 +718,13 @@ export function App() { break; case "turn_end": if (d.status === "max_iterations_exceeded") - setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Stopped: max iterations reached." }]); + setItems((p) => [...p, { kind: "notice", tone: "warn", text: t("app.maxIterations") }]); break; case "model_changed": // Mid-session switch (server-applied): update the header fact and drop the // persisted marker into the live transcript (replay renders it from history). if (d.model) setModel(d.model); - setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]); + setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || t("app.modelSwitched") }]); break; case "compacting": setCompacting(true); @@ -731,23 +732,23 @@ export function App() { case "compacted": // Auto-compaction marker (OPE-27): outbound-only — the transcript stays intact, // this divider just shows where the model's memory was summarized. - setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Context compacted" }]); + setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || t("app.contextCompacted") }]); break; case "interrupted": flushPartialStream(); - setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]); + setItems((p) => [...p, { kind: "notice", tone: "warn", text: t("app.interrupted") }]); break; case "error": flushPartialStream(); setItems((p) => [ ...p, - { kind: "notice", tone: "warn", text: "Error: " + (d.error || "unknown"), retriable: true }, + { kind: "notice", tone: "warn", text: t("error.generic", { error: d.error || t("common.unknown") }), retriable: true }, ]); break; case "input_rejected": setItems((p) => [ ...p, - { kind: "notice", tone: "warn", text: d.error || "That message was rejected." }, + { kind: "notice", tone: "warn", text: d.error || t("app.inputRejected") }, ]); break; case "turn_done": @@ -958,7 +959,7 @@ export function App() { if (msg.type !== "automation_run_started") return; const d = (msg.data ?? {}) as Record; setRunToast({ - title: d.task_title || "Automation", + title: d.task_title || t("app.automation"), sessionId: d.session_id || "", workspace: d.workspace || "", agent: d.agent || "cowork", @@ -1163,7 +1164,7 @@ export function App() { const subtitleParts = [modelDisplay]; if (isProjectScoped(personaOf(agent)) && workspace) subtitleParts.push(baseName(workspace)); const activeInfo = sessions.find((s) => s.session_id === sessionId); - const activeTitle = activeInfo?.title || "New session"; + const activeTitle = activeInfo?.title || t("session.new"); const desktop = isTauri(); // Dev-only: `?overlay=1` simulates the desktop overlay layout in the browser (adds the @@ -1187,7 +1188,7 @@ export function App() { {overlay && (
- OpenWorkerBETA + OpenWorker{t("app.beta")}
)} @@ -1202,8 +1203,8 @@ export function App() {
- {resumedExisting ? "Restoring your session…" : "Starting OpenWorker…"} - BETA + {resumedExisting ? t("app.restoring") : t("app.starting")} + {t("app.beta")}
); @@ -1235,10 +1236,10 @@ export function App() { >
- Automation started + {t("app.automationStarted")}
- {runToast.title} · {runToast.time} run + {t("app.automationToastSub", { title: runToast.title, time: runToast.time })}
@@ -1364,8 +1365,8 @@ export function App() { startNewSession(); prefillComposer( description - ? `Build a new skill for me: ${description}` - : "Build a new skill for me: (describe what the skill should do)", + ? t("skills.buildForMe", { description }) + : t("skills.buildForMeEmpty"), ); }} /> @@ -1397,24 +1398,24 @@ export function App() { @@ -1450,10 +1451,10 @@ export function App() { className="topbar-artifacts-btn" onMouseDown={(e) => e.stopPropagation()} onClick={() => setRailHidden(false)} - title="Show files this conversation produced" + title={t("app.showFiles")} > - Artifacts + {t("app.artifacts")} {artifactCount} )} @@ -1464,8 +1465,8 @@ export function App() { className="topbar-icon-btn" onMouseDown={(e) => e.stopPropagation()} onClick={() => setRailHidden((h) => !h)} - aria-label={railHidden ? "Show side panel" : "Hide side panel"} - title={railHidden ? "Show side panel" : "Hide side panel"} + aria-label={railHidden ? t("app.showSidePanel") : t("app.hideSidePanel")} + title={railHidden ? t("app.showSidePanel") : t("app.hideSidePanel")} > @@ -1485,14 +1486,14 @@ export function App() { > - Scheduled run + {t("app.scheduledRun")} {runContext?.title ? ( <> {" — "} {runContext.title} ) : null}{" "} - · started by an automation + {t("app.startedByAutomation")}
)} @@ -1517,11 +1518,11 @@ export function App() {

- {agent === "chat" ? "How can I help?" : "Let's build something."} + {agent === "chat" ? t("app.howCanIHelp") : t("app.letsBuild")}

{needsWorkspace(agent) && (
-
Try a task
+
{t("app.tryTask")}
{SUGGESTIONS.map((s, i) => (
workspace && send(s.text)}> {s.ico} @@ -1554,7 +1555,7 @@ export function App() { )} {/* Compaction runs between provider turns (nothing streams during it), so the transient takes over the waiting slot with a specific label. */} - {running && compacting && } + {running && compacting && } {running && !compacting && !reasoningStream && @@ -1563,7 +1564,7 @@ export function App() { {streaming && streamMode(streaming, items, running) === "answer" && (
-
assistant
+
{t("app.assistant")}
@@ -1584,7 +1585,7 @@ export function App() { onClick={followLatest} > - Jump to latest + {t("app.jumpToLatest")}
)} @@ -1614,10 +1615,10 @@ export function App() { contextBar={contextBar} placeholder={ agent === "code" - ? "Ask the coder to build, fix, or explain… (drop or paste files)" + ? t("app.placeholderCode") : agent === "chat" - ? "Ask anything… (drop or paste files)" - : "Ask the coworker… (drop or paste files)" + ? t("app.placeholderChat") + : t("app.placeholderCowork") } approvalSlot={ // Live inline cards are for ATTENDED sessions only; when Unattended the prompt is @@ -1725,11 +1726,12 @@ function lastItemIsAssistant(items: Item[]): boolean { } function WaitingForAgent({ label }: { label?: string }) { + const { t } = useTranslation(); return (
- {label || "Waiting for agent..."} + {label || t("app.waitingForAgent")}
); diff --git a/surfaces/gui/src/components/AccessSection.tsx b/surfaces/gui/src/components/AccessSection.tsx index 15faa6db..62dbf25b 100644 --- a/surfaces/gui/src/components/AccessSection.tsx +++ b/surfaces/gui/src/components/AccessSection.tsx @@ -10,6 +10,7 @@ // to expand it and scroll it into view. import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { CLOUD_CHANGED, getCloudStatus, @@ -69,6 +70,7 @@ export function AccessSection({ openKey?: number; onOpenIntegrations?: () => void; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [conns, setConns] = useState(null); const [byName, setByName] = useState({}); @@ -130,10 +132,10 @@ export function AccessSection({ // listen for the sign-in broadcast so the pane flips the moment login lands. const load = () => getCloudStatus().then(setCloud).catch(() => {}); load(); - const t = setInterval(load, 5000); + const iv = setInterval(load, 5000); window.addEventListener(CLOUD_CHANGED, load); return () => { - clearInterval(t); + clearInterval(iv); window.removeEventListener(CLOUD_CHANGED, load); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -173,7 +175,7 @@ export function AccessSection({ const channel = raw.includes(":") || raw.startsWith("#") ? raw : `${channelsFor}:${raw}`; const r = await subscribeChannel(sessionId, channel); if (!r.ok) { - setAddErr(r.error || "Couldn't add that channel."); + setAddErr(r.error || t("access.couldNotAddChannel")); return; } setAddErr(null); @@ -212,14 +214,14 @@ export function AccessSection({ const names = live.map((c) => labelFor(c.connector, byName)); const sourcesPart = names.length === 0 - ? "no sources" + ? t("access.noSources") : names.length <= 2 ? names.join(", ") : `${names.slice(0, 2).join(", ")} +${names.length - 2}`; const folderPart = projectScoped ? baseName(workspace || roots.find((r) => r.primary)?.path || "") || null : roots.length > 0 - ? `${roots.length} folder${roots.length === 1 ? "" : "s"}` + ? t("access.folderCount", { count: roots.length }) : null; const summary = [sourcesPart, folderPart].filter(Boolean).join(" · "); @@ -228,7 +230,7 @@ export function AccessSection({
{open && ( -
+
{connectFor ? ( {/* Sources — each toggle is a per-session override (mute for THIS session only). */}
-
Sources
+
{t("access.sources")}
{connected.length === 0 && (
- No connectors enabled for this session. + {t("access.noConnectors")}
)}
@@ -305,7 +307,7 @@ export function AccessSection({ setChannelsFor(c.connector); }} > - Channels · {channelsOf(c.connector).length} + {t("access.channelsCount", { count: channelsOf(c.connector).length })} )} @@ -313,14 +315,15 @@ export function AccessSection({ toggleSession(c.connector, next)} - title="Enabled for this session — tap to mute here" + title={t("access.enabledForSession")} />
))}
{connected.length > 0 && (

- Off mutes it for this session only — the connector stays connected. + {t("access.offMutesPrefix")} {t("access.thisSessionOnly")} + {t("access.offMutesSuffix")}

)} {/* §32 addendum (owner ask 2026-07-13; FB-012): the catalog's long tail, @@ -330,7 +333,7 @@ export function AccessSection({
setQuery(e.target.value)} onKeyDown={(e) => { @@ -346,7 +349,7 @@ export function AccessSection({ // Also covers a failed/empty catalog fetch: an open picker must never // be silently blank — point at the Connectors page either way.
- No match — see all on the Connectors page below. + {t("access.noMatch")}
)}
@@ -380,7 +383,7 @@ export function AccessSection({ onClick={() => setAdding(true)} data-testid="access-add-source" > - + Add a source… + {t("access.addSource")} )} {/* Lives with its list (tester ask 2026-07-26): each group's manage link sits @@ -389,13 +392,13 @@ export function AccessSection({ className="mt-1.5 block text-[12px] text-accent font-medium hover:underline text-left" onClick={() => onOpenIntegrations?.()} > - Manage all connectors (global) → + {t("access.manageAllConnectors")}
{recommended.length > 0 && (
-
Recommended
+
{t("access.recommended")}
{recommended.map((r) => (
@@ -403,7 +406,7 @@ export function AccessSection({
{labelFor(r.connector, byName)} - {r.tier === "core" && core} + {r.tier === "core" && {t("access.core")}}
{r.reason} @@ -419,7 +422,7 @@ export function AccessSection({ else onOpenIntegrations?.(); }} > - Connect + {t("access.connect")}
))} @@ -431,7 +434,7 @@ export function AccessSection({ a quiet "+" link, structurally identical to Sources (owner ask 2026-07-13: the old drawer's card wrapper read too heavy in the rail). */}
-
Folders
+
{t("access.folders")}
{roots.map((r) => ( setAddingFolder(true)} > - + Give access to a folder… + {t("access.giveAccessFolder")} )} {rootsError &&
{rootsError}
} @@ -486,8 +489,9 @@ function ConnectInline({ onDone: () => void; onBack: () => void; }) { + const { t } = useTranslation(); useEffect(() => { - const t = setInterval(async () => { + const iv = setInterval(async () => { try { const list = await getConnectors(); if (list.find((x) => x.name === c.name)?.connected) onDone(); @@ -495,7 +499,7 @@ function ConnectInline({ /* poll again */ } }, 2500); - return () => clearInterval(t); + return () => clearInterval(iv); }, [c.name, onDone]); return ( @@ -503,9 +507,9 @@ function ConnectInline({ {c.blurb &&

{c.blurb}

}
@@ -514,8 +518,7 @@ function ConnectInline({ {/* Scope semantics, stated once (owner ask 2026-07-13): connecting is account-level, the toggle above is what scopes it to a session. */}

- Connecting makes {c.title} available to all your coworkers — the toggle in this list - controls just this session. + {t("access.connectingMakes", { title: c.title })}

); @@ -544,19 +547,20 @@ function ChannelsInline({ onRemove: (channel: string) => void; onBack: () => void; }) { + const { t } = useTranslation(); return (
-
Subscribed channels · {channels.length}
+
{t("access.subscribedChannels", { count: channels.length })}
{channels.length === 0 ? (
- Not listening to any {label} channel yet. + {t("access.notListening", { label })}
) : (
@@ -569,14 +573,14 @@ function ChannelsInline({ {s.collision && ( )}
)} -
Add a channel
+
{t("access.addChannel")}
{error && ( @@ -598,8 +602,7 @@ function ChannelsInline({

)}

- The agent receives messages posted to these channels. Removing one stops this session - from listening — the connector stays connected. + {t("access.channelHelp")}

); diff --git a/surfaces/gui/src/components/AddFolderForm.tsx b/surfaces/gui/src/components/AddFolderForm.tsx index b7159a95..f4a1c94e 100644 --- a/surfaces/gui/src/components/AddFolderForm.tsx +++ b/surfaces/gui/src/components/AddFolderForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { chooseFolder } from "../tauri"; import { Icon } from "./Icon"; @@ -20,6 +21,7 @@ export function AddFolderForm({ startOpen?: boolean; onDismiss?: () => void; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(!!startOpen); const [path, setPath] = useState(""); const [writable, setWritable] = useState(false); @@ -45,7 +47,7 @@ export function AddFolderForm({ if (!open) { return ( ); } @@ -56,7 +58,7 @@ export function AddFolderForm({ setPath(e.target.value)} @@ -65,21 +67,21 @@ export function AddFolderForm({ else if (e.key === "Escape") reset(); }} /> -
-
diff --git a/surfaces/gui/src/components/ApprovalCard.tsx b/surfaces/gui/src/components/ApprovalCard.tsx index 8f2f2541..13bbde8f 100644 --- a/surfaces/gui/src/components/ApprovalCard.tsx +++ b/surfaces/gui/src/components/ApprovalCard.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import type { ApprovalDecision, Item } from "../types"; import { humanizeApprovalTitle, type HumanLine } from "../humanize"; import { Icon } from "./Icon"; @@ -16,13 +17,13 @@ export function shortArgs(args: any): string { // Human verbs kept for the §25 grant lines (the card title now comes from humanize.ts). const TOOL_VERBS: Record = { - write_file: "Write a file", - replace_in_file: "Edit a file", - apply_patch: "Apply a patch", - apply_unified_diff: "Apply a patch", - run_shell: "Run a command", - send_message: "Send a message", - send_file: "Send a file", + write_file: "approval.writeFile", + replace_in_file: "approval.editFile", + apply_patch: "approval.applyPatch", + apply_unified_diff: "approval.applyPatch", + run_shell: "approval.runCommand", + send_message: "approval.sendMessage", + send_file: "approval.sendFile", }; // §35: routine workspace writes render as a compact ROW; everything else is a full card. @@ -34,10 +35,13 @@ type ApprovalItem = Extract; // Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the // parked Inbox card so both dialects match. -export function approvalActionLabels(name?: string): { allow: string; deny: string } { +export function approvalActionLabels( + t: (key: string, opts?: Record) => string, + name?: string, +): { allow: string; deny: string } { return name === "save_skill" - ? { allow: "Add to my skills", deny: "Not now" } - : { allow: "Allow once", deny: "Deny" }; + ? { allow: t("approval.addToMySkills"), deny: t("approval.notNow") } + : { allow: t("approval.allowOnce"), deny: t("approval.deny") }; } // save_skill's review surface (SKILLS-SPEC §5.2): description, the full instructions @@ -45,6 +49,7 @@ export function approvalActionLabels(name?: string): { allow: string; deny: stri // answers "added WHERE, available WHEN". Shared verbatim with the parked Inbox card — // one decision, one dialect. export function SaveSkillPreview({ args }: { args: any }) { + const { t } = useTranslation(); return ( <> {args?.description &&
{String(args.description)}
} @@ -61,10 +66,7 @@ export function SaveSkillPreview({ args }: { args: any }) { ))}
)} -
- Approving adds it to your skills on this computer — usable in every conversation from - then on. -
+
{t("approval.addToSkillsNote")}
); } @@ -98,21 +100,30 @@ export function TitleText({ line }: { line: HumanLine }) { // Plain-words scope note (replaces the "local action" badge): where does this act? // Shared with the parked-approval card (InboxItemCard) so both dialects match (§35). export function scopeNote( + t: (key: string, opts?: Record) => string, name: string, args: any, category?: string, ): { text: string; external: boolean } { // save_skill's corner answers WHERE (SKILLS-SPEC §5.2): the exact place to find, edit, // or turn off the skill afterwards. - if (name === "save_skill") return { text: "saves to Settings ▸ Skills", external: false }; - if (category === "connector") return { text: "acts on a connected service", external: true }; + if (name === "save_skill") return { text: t("approval.savesToSkills"), external: false }; + if (category === "connector") return { text: t("approval.actsOnConnectedService"), external: true }; if (EXTERNAL.has(name)) { const platform = String(args?.target ?? "").split(":")[0]; const names: Record = { slack: "Slack", telegram: "Telegram" }; - return { text: `leaves this computer → ${names[platform] || platform || "a connected chat"}`, external: true }; + return { + text: t("approval.leavesMac", { + platform: names[platform] || platform || t("approval.connectedChat"), + }), + external: true, + }; } const overwrite = name === "write_file" && args?.overwrite; - return { text: "stays on this computer" + (overwrite ? " · overwrites the existing file" : ""), external: false }; + return { + text: t("approval.staysOnMac") + (overwrite ? t("approval.overwriteExisting") : ""), + external: false, + }; } // The proposed content/command, straight from the tool call's ARGS — the file/action @@ -123,6 +134,7 @@ const PREVIEW_LINES = 5; const PREVIEW_CHARS = 420; export function PreviewBlock({ text, mono = true }: { text: string; mono?: boolean }) { + const { t } = useTranslation(); const [all, setAll] = useState(false); const lines = text.split("\n"); const clipped = lines.length > PREVIEW_LINES || text.length > PREVIEW_CHARS; @@ -137,10 +149,10 @@ export function PreviewBlock({ text, mono = true }: { text: string; mono?: boole {clipped && ( )}
@@ -165,7 +177,7 @@ function Buttons({ onApprove, runTask, primaryLabel, - denyLabel = "Deny", + denyLabel, }: { item: ApprovalItem; onApprove: (decision: ApprovalDecision) => void; @@ -173,6 +185,7 @@ function Buttons({ primaryLabel: string; denyLabel?: string; }) { + const { t } = useTranslation(); const connector = item.category === "connector"; const offerStanding = !!(runTask && item.standingTarget); return ( @@ -183,10 +196,14 @@ function Buttons({ {offerStanding && ( )} {/* In a run context the task-persistent grant replaces the session-scoped one — @@ -199,20 +216,22 @@ function Buttons({ {!connector && !offerStanding && item.name !== "run_shell" && item.name !== "save_skill" && ( )} {item.name === "run_shell" && ( )}
); @@ -232,8 +251,9 @@ export function ApprovalCard({ compact?: boolean; }) { const [peek, setPeek] = useState(false); + const { t } = useTranslation(); const title = humanizeApprovalTitle(item.name, item.args); - const scope = scopeNote(item.name, item.args, item.category); + const scope = scopeNote(t, item.name, item.args, item.category); const grants = item.name === "create_scheduled_task" ? permissionLines(item.args) : []; // "requires approval" is the engine's default boilerplate — only surface a real reason. const reason = item.reason && item.reason !== "requires approval" ? item.reason : ""; @@ -250,11 +270,11 @@ export function ApprovalCard({ {content && ( )} - +
{peek && content && } {reason &&
{reason}
} @@ -266,7 +286,7 @@ export function ApprovalCard({
- + @@ -285,11 +305,11 @@ export function ApprovalCard({ - {String(item.args?.path ?? "").split("/").pop() || "file"} - {item.args?.as_screenshot ? " · as a PNG screenshot" : ""} + {String(item.args?.path ?? "").split("/").pop() || t("approval.fileLabel")} + {item.args?.as_screenshot ? t("approval.asPngScreenshot") : ""} {item.args?.comment && ( - + )} )} @@ -307,9 +327,9 @@ export function ApprovalCard({ {g.access === "write" ? "✓" : "·"} - {TOOL_VERBS[g.tool] || g.tool} {g.target} + {TOOL_VERBS[g.tool] ? t(TOOL_VERBS[g.tool]) : g.tool} {g.target} - {g.access === "write" ? " — always allowed once you approve" : " — read-only"} + {g.access === "write" ? t("approval.grantNoteWrite") : t("approval.grantNoteRead")}
@@ -324,14 +344,16 @@ export function ApprovalCard({ {reason &&
{reason}
} {item.resolved ? ( -
Approved: {item.resolved.replace("_", " ")}
+
+ {t("approval.approvedWith", { resolution: item.resolved.replace("_", " ") })} +
) : ( )}
diff --git a/surfaces/gui/src/components/AuditView.tsx b/surfaces/gui/src/components/AuditView.tsx index 02f4dcd0..00c1029c 100644 --- a/surfaces/gui/src/components/AuditView.tsx +++ b/surfaces/gui/src/components/AuditView.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { getAudit, type AuditEvent } from "../api"; import { PanelHead } from "./IntegrationsView"; @@ -10,6 +11,7 @@ const INPUT = "px-3 py-1.5 rounded-lg border border-line bg-paper text-[13px] te const BTN_ACCENT = "text-[12.5px] px-3 py-1.5 rounded-lg bg-accent text-white shrink-0"; export function AuditView() { + const { t } = useTranslation(); const [events, setEvents] = useState([]); const [sessionFilter, setSessionFilter] = useState(""); const [connectorFilter, setConnectorFilter] = useState(""); @@ -34,21 +36,21 @@ export function AuditView() {
- setSessionFilter(e.target.value)} /> - setConnectorFilter(e.target.value)} /> - setToolFilter(e.target.value)} /> + setSessionFilter(e.target.value)} /> + setConnectorFilter(e.target.value)} /> + setToolFilter(e.target.value)} />
{events.length === 0 ? ( -
No audit events yet.
+
{t("audit.noEvents")}
) : (
{events.map((ev) => ( @@ -63,18 +65,19 @@ export function AuditView() { } function AuditRow({ ev }: { ev: AuditEvent }) { + const { t } = useTranslation(); return (
{ev.tool} - {ev.connector || "tool"} · {ev.stage || ev.status || "event"} · {ev.timestamp} + {ev.connector || t("audit.fallbackTool")} · {ev.stage || ev.status || t("audit.fallbackEvent")} · {ev.timestamp}
- session {ev.session_id || "-"} {ev.approval ? `· ${ev.approval}` : ""} {ev.status ? `· ${ev.status}` : ""} + {t("audit.sessionLabel")} {ev.session_id || "-"} {ev.approval ? `· ${ev.approval}` : ""} {ev.status ? `· ${ev.status}` : ""}
- {ev.resource &&
resource: {ev.resource}
} + {ev.resource &&
{t("audit.resourceLabel")} {ev.resource}
} {ev.args && Object.keys(ev.args).length > 0 && (
{formatAuditArgs(ev.args)}
)} diff --git a/surfaces/gui/src/components/AutomationQuickstart.tsx b/surfaces/gui/src/components/AutomationQuickstart.tsx index 06184fe4..680946aa 100644 --- a/surfaces/gui/src/components/AutomationQuickstart.tsx +++ b/surfaces/gui/src/components/AutomationQuickstart.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { cloudLogin, connectManaged, @@ -22,16 +23,16 @@ import { SelectMenu } from "./SelectMenu"; // The `ob-*` testids moved here with the machinery. // "When" = day choice × free time (owner call 2026-07-11); the cron assembles from the two. -const DAYS: Record = { - mon: { label: "Mondays", dow: "1" }, - tue: { label: "Tuesdays", dow: "2" }, - wed: { label: "Wednesdays", dow: "3" }, - thu: { label: "Thursdays", dow: "4" }, - fri: { label: "Fridays", dow: "5" }, - sat: { label: "Saturdays", dow: "6" }, - sun: { label: "Sundays", dow: "0" }, - weekdays: { label: "Weekdays", dow: "1-5" }, - daily: { label: "Every day", dow: "*" }, +const DAYS: Record = { + mon: { labelKey: "scheduled.monday", dow: "1" }, + tue: { labelKey: "scheduled.tuesday", dow: "2" }, + wed: { labelKey: "scheduled.wednesday", dow: "3" }, + thu: { labelKey: "scheduled.thursday", dow: "4" }, + fri: { labelKey: "scheduled.friday", dow: "5" }, + sat: { labelKey: "scheduled.saturday", dow: "6" }, + sun: { labelKey: "scheduled.sunday", dow: "0" }, + weekdays: { labelKey: "scheduled.weekdays", dow: "1-5" }, + daily: { labelKey: "scheduled.everyDay", dow: "*" }, }; // §30 connect-state spinner (the app has no other spinner — waits elsewhere are label swaps). // Exported for Onboarding page 2's sign-in button (same states, same look). @@ -46,10 +47,10 @@ const cronFor = (dayKey: string, hhmm: string) => { interface QuickTemplate { key: string; - title: string; - blurb: string; - cadence: string; // the card's footer label - conns: { name: string; why: string }[]; // [] = no connections needed + titleKey: string; + blurbKey: string; + cadenceKey: string; // the card's footer label + conns: { name: string; whyKey: string }[]; // [] = no connections needed needsRepo?: boolean; needsChannel?: boolean; consent?: boolean; // write recipes carry the §25 consent line; reads carry disclosure @@ -62,12 +63,12 @@ interface QuickTemplate { const TEMPLATES: QuickTemplate[] = [ { key: "github", - title: "GitHub digest", - blurb: "Merged PRs and commits, posted to your team's Slack.", - cadence: "Weekly", + titleKey: "quickstart.github.title", + blurbKey: "quickstart.github.blurb", + cadenceKey: "quickstart.github.cadence", conns: [ - { name: "slack", why: "Where the digest posts" }, - { name: "github", why: "What the digest summarizes" }, + { name: "slack", whyKey: "quickstart.why.slack.digest" }, + { name: "github", whyKey: "quickstart.why.github.digest" }, ], needsRepo: true, needsChannel: true, @@ -81,12 +82,12 @@ const TEMPLATES: QuickTemplate[] = [ }, { key: "pipeline", - title: "Pipeline digest", - blurb: "Deals that moved — and deals going quiet — posted to Slack.", - cadence: "Weekly", + titleKey: "quickstart.pipeline.title", + blurbKey: "quickstart.pipeline.blurb", + cadenceKey: "quickstart.pipeline.cadence", conns: [ - { name: "slack", why: "Where the digest posts" }, - { name: "hubspot", why: "Pipeline and deal activity" }, + { name: "slack", whyKey: "quickstart.why.slack.pipeline" }, + { name: "hubspot", whyKey: "quickstart.why.hubspot.pipeline" }, ], needsChannel: true, consent: true, @@ -99,12 +100,12 @@ const TEMPLATES: QuickTemplate[] = [ }, { key: "brief", - title: "Morning brief", - blurb: "Calendar and unread email, summarized before your day starts.", - cadence: "Daily", + titleKey: "quickstart.brief.title", + blurbKey: "quickstart.brief.blurb", + cadenceKey: "quickstart.brief.cadence", conns: [ - { name: "google_calendar", why: "Today's meetings and gaps" }, - { name: "gmail", why: "What arrived overnight" }, + { name: "google_calendar", whyKey: "quickstart.why.google_calendar" }, + { name: "gmail", whyKey: "quickstart.why.gmail.brief" }, ], deliver: true, day: "daily", @@ -116,9 +117,9 @@ const TEMPLATES: QuickTemplate[] = [ }, { key: "news", - title: "Morning news briefing", - blurb: "A 5-bullet tech & world news digest, saved as markdown.", - cadence: "Daily", + titleKey: "quickstart.news.title", + blurbKey: "quickstart.news.blurb", + cadenceKey: "quickstart.news.cadence", conns: [], day: "daily", time: "08:00", @@ -128,19 +129,19 @@ const TEMPLATES: QuickTemplate[] = [ }, { key: "inboxdigest", - title: "Inbox digest", - blurb: "One short digest of your unread email.", - cadence: "Weekdays", - conns: [{ name: "gmail", why: "Your unread email" }], + titleKey: "quickstart.inboxdigest.title", + blurbKey: "quickstart.inboxdigest.blurb", + cadenceKey: "quickstart.inboxdigest.cadence", + conns: [{ name: "gmail", whyKey: "quickstart.why.gmail.inbox" }], day: "weekdays", time: "09:00", instructions: () => "Summarize my unread email into one short digest note.", }, { key: "cleanup", - title: "Folder cleanup", - blurb: "Sort recent Downloads into tidy folders by type.", - cadence: "Weekly", + titleKey: "quickstart.cleanup.title", + blurbKey: "quickstart.cleanup.blurb", + cadenceKey: "quickstart.cleanup.cadence", conns: [], day: "fri", time: "17:30", @@ -161,7 +162,8 @@ export function AutomationQuickstart({ }) => void; }) { const [pickedKey, setPickedKey] = useState(null); - const picked = TEMPLATES.find((t) => t.key === pickedKey) || null; + const picked = TEMPLATES.find((tpl) => tpl.key === pickedKey) || null; + const { t } = useTranslation(); const [connectors, setConnectors] = useState([]); const [cloud, setCloud] = useState(null); @@ -280,7 +282,7 @@ export function AutomationQuickstart({ const create = () => { if (!picked) return; onCreate({ - title: picked.title, + title: t(picked.titleKey), instructions: picked.instructions({ repo, channel, deliver }), cron: cronFor(day, time), permissions: @@ -291,12 +293,14 @@ export function AutomationQuickstart({ }; const gateHint = !allConnected - ? `Connect ${picked?.conns - .filter((c) => !connState(c.name)?.connected) - .map((c) => connState(c.name)?.title || c.name) - .join(" and ")} to continue` + ? t("quickstart.connectToContinue", { + names: picked?.conns + .filter((c) => !connState(c.name)?.connected) + .map((c) => connState(c.name)?.title || c.name) + .join(", "), + }) : picked?.needsChannel && !channel - ? "Pick a channel to post to first" + ? t("quickstart.pickChannelFirst") : ""; const label = "block text-[12px] text-muted mt-3 mb-1"; @@ -306,33 +310,33 @@ export function AutomationQuickstart({ return (
- Start from a template + {t("quickstart.startFromTemplate")}
{/* Equal-height cards (owner ask 2026-07-12): 1fr rows + h-full — @@ -360,15 +366,17 @@ export function AutomationQuickstart({ {/* §30: the card names its template — without this it starts abruptly after the grid. */}
- Set up + {t("quickstart.setUp")} - {picked.title} + {t(picked.titleKey)} - {picked.conns.length ? "Connections, delivery & schedule" : "Delivery & schedule"} ·{" "} - {picked.cadence} + {picked.conns.length + ? t("quickstart.connectionsDeliverySchedule") + : t("quickstart.deliverySchedule")} ·{" "} + {t(picked.cadenceKey)}
- {picked.conns.map(({ name, why }) => { + {picked.conns.map(({ name, whyKey }) => { const c = connState(name); const flow = connFlow?.name === name ? connFlow : null; return ( @@ -377,16 +385,16 @@ export function AutomationQuickstart({ {c && } {c?.title || name} - {why} + {t(whyKey)} {c?.connected ? ( - ✓ Connected + {t("quickstart.connected")} ) : flow ? ( {flow.phase === "opening" - ? "Opening browser…" - : `Waiting for ${c?.title || name}…`} + ? t("quickstart.openingBrowser") + : t("quickstart.waitingFor", { name: c?.title || name })} ) : ( )}
@@ -408,16 +416,16 @@ export function AutomationQuickstart({ - Finish connecting {c?.title || name} in your browser. + {t("quickstart.finishConnecting", { name: c?.title || name })} {" "} - Approve it there, then come back — this page updates by itself. + {t("quickstart.approveAndComeBack")}
)} @@ -431,25 +439,27 @@ export function AutomationQuickstart({ data-testid="ob-cloudpane" > - One sign-in unlocks every one-click connection + {t("quickstart.oneSigninUnlocksAll")} - Connections are brokered by OpenWorker Cloud — your tokens stay on this computer. + {t("quickstart.brokeredByCloud")}
{signinPhase ? ( <> - {signinPhase === "opening" ? "Opening browser…" : "Waiting for sign-in…"} + {signinPhase === "opening" + ? t("quickstart.openingBrowser") + : t("quickstart.waitingForSignin")} {signinPhase === "waiting" && ( - Finish signing in in your browser — this page updates by itself.{" "} + {t("quickstart.finishSigningIn")}{" "} )} @@ -460,7 +470,7 @@ export function AutomationQuickstart({ onClick={signInThenConnect} data-testid="ob-cloud-signin" > - Sign in to OpenWorker Cloud + {t("cloud.signInCloud")} )}
@@ -471,10 +481,10 @@ export function AutomationQuickstart({
{picked.needsRepo && ( <> - + setRepo(e.target.value)} data-testid="ob-repo" @@ -483,7 +493,7 @@ export function AutomationQuickstart({ )} {picked.needsChannel && ( <> - +
-

- The bot must be a member of the channel — invite @OpenWorker in Slack if it isn't. -

+

{t("quickstart.botMustBeMember")}

)} - +
({ value: k, label: v.label }))} + options={Object.entries(DAYS).map(([k, v]) => ({ value: k, label: t(v.labelKey) }))} onChange={setDay} />
setTime(e.target.value)} />
{picked.deliver && ( <> - + setDeliver(v as "app" | "slack")} /> @@ -541,19 +549,16 @@ export function AutomationQuickstart({ data-testid="ob-consent" /> - Allow this automation to post its digest to{" "} + {t("quickstart.consentBefore")} - {channelLabel || "the channel"} + {channelLabel || t("quickstart.theChannel")} {channelWorkspace ? ` (${channelWorkspace})` : ""} - {" "} - without asking each time. Anything else still asks first. + + {t("quickstart.consentAfter")} ) : picked.conns.length > 0 ? ( -

- This automation only reads on schedule — reading - never needs approval. -

+

{t("quickstart.readsOnly")}

) : null}
)} @@ -563,7 +568,7 @@ export function AutomationQuickstart({ className="text-[12.5px] text-faint hover:text-muted" onClick={() => setPickedKey(null)} > - Cancel + {t("quickstart.cancel")} {/* A silently-disabled primary reads as a bug — always name the missing piece. */} {gateHint && ( @@ -580,7 +585,7 @@ export function AutomationQuickstart({ onClick={create} data-testid="ob-create" > - {busy ? "Creating…" : "Create automation"} + {busy ? t("quickstart.creating") : t("quickstart.createAutomation")}
diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 532c437d..25242428 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import type { Attachment, SessionUsage } from "../types"; import { isPdfFile, readFile } from "../attach"; import { getSettings, inspectPdf, sessionSkills, type SessionSkillRow } from "../api"; @@ -20,11 +21,6 @@ import { // polished enough to ship, and Custom (config.toml auto-allow rules) is a power-user mode // with no in-app explanation. The server still honors both — a session already in one of // those modes keeps working; the picker just doesn't offer them. -const PERMISSION_OPTIONS: Option[] = [ - { value: "discuss", label: "Discuss", description: "Chat and explore — no edits or commands" }, - { value: "interactive", label: "Ask for approval", description: "Ask before edits and commands" }, - { value: "auto", label: "Full access", description: "Run everything without asking" }, -]; // No hardcoded model fallback: until the server supplies the list (a few seconds after a // cold app boot), the picker renders a disabled "Loading models…" chip. A baked-in list @@ -92,6 +88,7 @@ interface Props { } export function Composer(props: Props) { + const { t } = useTranslation(); const [text, setText] = useState(""); const [attachments, setAttachments] = useState([]); // "/" force-run (SKILLS-SPEC §4.1 #3). The popup derives from the draft: it is open while @@ -271,7 +268,11 @@ export function Composer(props: Props) { for (const file of list) { if (isPdfFile(file) && file.size > maxMb * 1024 * 1024) { showAttachNotice( - `${file.name} skipped — ${(file.size / 1024 / 1024).toFixed(1)} MB is over your ${maxMb} MB limit (Settings → Token savings)`, + t("composer.pdfTooBig", { + name: file.name, + size: (file.size / 1024 / 1024).toFixed(1), + max: maxMb, + }), ); continue; } @@ -284,12 +285,21 @@ export function Composer(props: Props) { const info = await inspectPdf(a.data_url).catch(() => null); if (info?.ok && (info.pages ?? 0) > maxPages) { showAttachNotice( - `${a.name} skipped — ${info.pages} pages is over your ${maxPages}-page limit (Settings → Token savings)`, + t("composer.pdfTooManyPages", { + name: a.name, + pages: info.pages, + max: maxPages, + }), ); continue; } if (info && !info.ok) { - showAttachNotice(`${a.name} skipped — ${info.error || "could not read PDF"}`); + showAttachNotice( + t("composer.pdfSkipped", { + name: a.name, + error: info.error || t("composer.pdfReadError"), + }), + ); continue; } } @@ -315,9 +325,9 @@ export function Composer(props: Props) { // The visible "/name " prefix is UI state, not message text — strip it for the send; // the skill rides as its own field. const skill = prefixIntact ? pendingSkill!.name : undefined; - const t = (skill ? text.slice(skill.length + 1) : text).trim(); + const msg = (skill ? text.slice(skill.length + 1) : text).trim(); if ( - (!t && attachments.length === 0 && !skill) || + (!msg && attachments.length === 0 && !skill) || props.running || dictation?.recording || dictationBusy @@ -328,7 +338,7 @@ export function Composer(props: Props) { props.onConnectModel?.(); return; } - props.onSend(t, attachments, skill); + props.onSend(msg, attachments, skill); setText(""); setAttachments([]); setPendingSkill(null); @@ -380,9 +390,9 @@ export function Composer(props: Props) { setDictationError(null); try { if (dictation?.recording) { - setDictationBusy("Transcribing…"); + setDictationBusy(t("composer.transcribing")); const transcript = await stopDictation(); - if (transcript === null) throw new Error("Could not transcribe your recording."); + if (transcript === null) throw new Error(t("composer.transcribeError")); if (transcript.trim()) { setText((draft) => (draft.trim() ? `${draft.trimEnd()} ${transcript.trim()}` : transcript.trim())); } @@ -392,17 +402,17 @@ export function Composer(props: Props) { } const status = dictation || (await getDictationStatus()); - if (!status) throw new Error("Voice dictation is unavailable."); + if (!status) throw new Error(t("composer.dictationUnavailable")); if (!status.supported || !status.model_verified || !status.test_passed) { props.onConfigureVoiceInput?.(); return; } - setDictationBusy("Starting microphone…"); + setDictationBusy(t("composer.startingMic")); const recording = await startDictation(); - if (!recording?.recording) throw new Error("Could not start the microphone."); + if (!recording?.recording) throw new Error(t("composer.micError")); setDictation(recording); } catch (error) { - setDictationError(error instanceof Error ? error.message : "Voice dictation is unavailable."); + setDictationError(error instanceof Error ? error.message : t("composer.dictationUnavailable")); const status = await getDictationStatus(); if (status) setDictation(status); } finally { @@ -447,7 +457,7 @@ export function Composer(props: Props) { @@ -482,11 +492,11 @@ export function Composer(props: Props) { {/* "/" force-run popup — in-flow above the textarea; rows are the session's effective menu only (muted/disabled skills never appear). */} {slashQuery !== null && ( -
+
{slashSkills === null ? ( -
Loading skills…
+
{t("composer.loadingSkills")}
) : slashMatches.length === 0 ? ( -
No matching skills.
+
{t("composer.noMatchingSkills")}
) : ( slashMatches.map((s, i) => ( ) : modelsLoaded ? ( @@ -618,9 +628,9 @@ export function Composer(props: Props) { className="pill chip text-faint cursor-default" disabled data-testid="models-loading" - title="Fetching the model list from the server" + title={t("composer.fetchingModels")} > - Loading models… + {t("composer.loadingModels")} ))} @@ -638,12 +648,12 @@ export function Composer(props: Props) { title={ dictationBusy || (dictation?.recording - ? "Stop recording and transcribe" + ? t("composer.stopRecordingTranscribe") : voiceReady - ? "Start local voice dictation" - : "Configure Voice Input in Settings") + ? t("composer.startVoiceDictation") + : t("composer.configureVoice")) } - aria-label={dictation?.recording ? "Stop dictation" : voiceReady ? "Start dictation" : "Configure Voice Input in Settings"} + aria-label={dictation?.recording ? t("composer.stopDictation") : voiceReady ? t("composer.startDictation") : t("composer.configureVoice")} aria-disabled={!voiceReady && !dictation?.recording} > @@ -653,7 +663,7 @@ export function Composer(props: Props) { {/* send / stop */} {props.running ? ( ) : (
- {dictation?.recording ? `Listening, ${recordingTime}` : dictationBusy || ""} + {dictation?.recording ? t("composer.listening", { time: recordingTime }) : dictationBusy || ""}
); @@ -699,6 +709,7 @@ function UsageChip({ model: string; modelLabels?: Record; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const total = totalTokens(usage); const pct = contextWindow @@ -707,7 +718,7 @@ function UsageChip({ // Settings can hide the bar; without a known window there is nothing to fill either. const showBar = pct !== null && contextBar === true; const labelFor = (id: string) => - id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id); + id === "unknown" ? t("composer.unknownModel") : modelLabels?.[id] || shortModel(id); // One field per line, session-summed (owner ask 2026-07-28). Values are cumulative // across the whole session, never just the last turn; "Input" is the fresh // (uncached) share — the cached share sits in the cache rows at its own price. @@ -724,11 +735,11 @@ function UsageChip({ onClick={() => setOpen((v) => !v)} aria-haspopup="menu" aria-expanded={open} - aria-label="Token usage" + aria-label={t("composer.tokenUsage")} title={ showBar - ? `Context window ${pct}% full · ${formatTokens(total)} tokens this session` - : `Token usage this session: ${formatTokens(total)}` + ? t("composer.contextFullTitle", { pct, tokens: formatTokens(total) }) + : t("composer.sessionTokensTitle", { tokens: formatTokens(total) }) } data-testid="usage-chip" > @@ -757,7 +768,7 @@ function UsageChip({ {contextWindow ? (
- Context window + {t("composer.contextWindow")}
- {formatTokens(usage.context)} of {formatTokens(contextWindow)} · {pct}% + {t("composer.ofX", { fmt: formatTokens(usage.context), window: formatTokens(contextWindow), pct })}
) : usage.context > 0 ? (
- In context now: {formatTokens(usage.context)} tokens + {t("composer.inContextNow", { fmt: formatTokens(usage.context) })}
) : null}
- Session totals + {t("composer.sessionTotals")}
- {Object.entries(usage.byModel).map(([id, t]) => ( + {Object.entries(usage.byModel).map(([id, u]) => (
{labelFor(id)} @@ -788,28 +799,28 @@ function UsageChip({ read as components: uncached + cache reads + cache writes = total. Without one (Ollama, compat vendors), plain "Input" says it all. */}
- {t.cache_read + t.cache_write > 0 ? ( + {u.cache_read + u.cache_write > 0 ? ( <> - {stat("Uncached input", t.input)} - {stat("Cache reads", t.cache_read)} - {stat("Cache writes", t.cache_write)} - {stat("Total input", t.input + t.cache_read + t.cache_write)} + {stat(t("composer.uncachedInput"), u.input)} + {stat(t("composer.cacheReads"), u.cache_read)} + {stat(t("composer.cacheWrites"), u.cache_write)} + {stat(t("composer.totalInput"), u.input + u.cache_read + u.cache_write)} ) : ( - stat("Input", t.input) + stat(t("composer.input"), u.input) )} - {stat("Output", t.output)} + {stat(t("composer.output"), u.output)}
))}
- Total - {formatTokens(total)} tokens + {t("composer.total")} + {t("composer.xTokens", { fmt: formatTokens(total) })}
{model && !modelLabels?.[model] && contextWindow === undefined && (
- Context meter unavailable for custom models. + {t("composer.contextMeterUnavailable")}
)}
@@ -833,7 +844,13 @@ function ModeMenu({ unattended?: boolean; onUnattendedChange?: (on: boolean) => void; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); + const PERMISSION_OPTIONS: Option[] = [ + { value: "discuss", label: t("composer.modeDiscuss"), description: t("composer.modeDiscussDesc") }, + { value: "interactive", label: t("composer.modeApproval"), description: t("composer.modeApprovalDesc") }, + { value: "auto", label: t("composer.modeAuto"), description: t("composer.modeAutoDesc") }, + ]; const current = PERMISSION_OPTIONS.find((o) => o.value === mode); return (
@@ -845,10 +862,10 @@ function ModeMenu({ onClick={() => setOpen((v) => !v)} aria-haspopup="menu" aria-expanded={open} - aria-label="Mode" + aria-label={t("composer.mode")} title={ - `Mode: ${current?.label || mode}` + - (unattended ? " · approvals go to the Inbox" : "") + t("composer.modeTitle", { mode: current?.label || mode }) + + (unattended ? t("composer.modeInboxSuffix") : "") } > {current?.label || mode} @@ -887,15 +904,15 @@ function ModeMenu({
- Send approvals to Inbox + {t("composer.sendApprovalsToInbox")} - Approvals & questions go to the Inbox; the agent keeps working. + {t("composer.sendApprovalsDesc")}
@@ -920,6 +937,7 @@ function attachItem(icon: "image" | "file" | "fileCode", label: string, onClick: } function AttachChip({ a, onRemove }: { a: Attachment; onRemove: () => void }) { + const { t } = useTranslation(); return (
{a.kind === "image" ? ( @@ -930,7 +948,7 @@ function AttachChip({ a, onRemove }: { a: Attachment; onRemove: () => void }) { {a.name} )} -
diff --git a/surfaces/gui/src/components/ConnectorMessageCard.tsx b/surfaces/gui/src/components/ConnectorMessageCard.tsx index 9f3026b5..e53af959 100644 --- a/surfaces/gui/src/components/ConnectorMessageCard.tsx +++ b/surfaces/gui/src/components/ConnectorMessageCard.tsx @@ -14,23 +14,24 @@ // for keyboard focus too; the ids are also mirrored into the header `title` for quick reference. import { useState, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; import type { MessageSource } from "../api"; import { ConnectorBadge, hexToRgba, NEUTRAL } from "../connectors/ConnectorIcon"; import { resolveConnector } from "../connectors/registry"; /** Coarse relative time from epoch seconds: "just now" / "5m ago" / "2h ago" / "3d ago" / a date. */ -function relativeTime(tsSeconds: number): string { +function relativeTime(t: (s: string, o?: Record) => string, tsSeconds: number): string { if (!tsSeconds || !isFinite(tsSeconds)) return ""; const then = tsSeconds * 1000; const diff = Date.now() - then; - if (diff < 0) return "just now"; - if (diff < 45_000) return "just now"; + if (diff < 0) return t("time.justNow"); + if (diff < 45_000) return t("time.justNow"); const mins = Math.round(diff / 60_000); - if (mins < 60) return `${mins}m ago`; + if (mins < 60) return t("time.minAgo", { count: mins }); const hrs = Math.round(diff / 3_600_000); - if (hrs < 24) return `${hrs}h ago`; + if (hrs < 24) return t("time.hourAgo", { count: hrs }); const days = Math.round(diff / 86_400_000); - if (days < 7) return `${days}d ago`; + if (days < 7) return t("time.dayAgo", { count: days }); return new Date(then).toLocaleDateString(); } @@ -47,6 +48,7 @@ export function ConnectorMessageCard({ source: MessageSource; brandColor?: string; }) { + const { t } = useTranslation(); const [showIds, setShowIds] = useState(false); const { key, entry } = resolveConnector(source.connector); const color = (brandColor || "").trim() || NEUTRAL; @@ -85,11 +87,11 @@ export function ConnectorMessageCard({ · {source.sender_name} - via {entry.label} + {t("connectorCard.via", { label: entry.label })} )}
{source.text}
diff --git a/surfaces/gui/src/components/DirectoryRequestCard.tsx b/surfaces/gui/src/components/DirectoryRequestCard.tsx index 7003f7a6..e5c7a05a 100644 --- a/surfaces/gui/src/components/DirectoryRequestCard.tsx +++ b/surfaces/gui/src/components/DirectoryRequestCard.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import type { Item } from "../types"; import { chooseFolder } from "../tauri"; import { Icon } from "./Icon"; @@ -14,6 +15,7 @@ export function DirectoryRequestCard({ item: DirReqItem; onRespond: (granted: boolean, path?: string, writable?: boolean) => void; }) { + const { t } = useTranslation(); const [path, setPath] = useState(item.path || ""); const [writable, setWritable] = useState(!!item.writable); @@ -26,31 +28,31 @@ export function DirectoryRequestCard({
- The agent is requesting access to a folder + {t("dirReq.requestingAccess")}
{item.reason &&
“{item.reason}”
}
setPath(e.target.value)} /> -
diff --git a/surfaces/gui/src/components/FolderGate.tsx b/surfaces/gui/src/components/FolderGate.tsx index 6533c392..64a55fa2 100644 --- a/surfaces/gui/src/components/FolderGate.tsx +++ b/surfaces/gui/src/components/FolderGate.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { getRecentWorkspaces, openWorkspace, type RecentWorkspace } from "../api"; import { chooseFolder } from "../tauri"; @@ -13,6 +14,7 @@ interface Props { } export function FolderGate({ onChoose, onCancel, create }: Props) { + const { t } = useTranslation(); const [recents, setRecents] = useState([]); const [path, setPath] = useState(""); const [error, setError] = useState(""); @@ -25,7 +27,7 @@ export function FolderGate({ onChoose, onCancel, create }: Props) { setError(""); const res = await openWorkspace(p.trim(), doCreate); if (res.ok) onChoose(res.path, res.git_branch); - else setError(res.error || "could not open that folder"); + else setError(res.error || t("folderGate.couldNotOpen")); }; const browse = async () => { @@ -40,33 +42,31 @@ export function FolderGate({ onChoose, onCancel, create }: Props) {
-

{create ? "New project" : "Choose a project folder"}

+

{create ? t("folderGate.newProject") : t("folderGate.chooseProjectFolder")}

- {create - ? "Pick a folder or enter a path. If the path doesn't exist, it will be created." - : "This coworker needs a workspace to read, edit, and run in."} + {create ? t("folderGate.createDesc") : t("folderGate.needsWorkspace")}

setPath(e.target.value)} onKeyDown={(e) => e.key === "Enter" && open(path, create)} autoFocus /> -
{error &&
{error}
} {recents.length > 0 && ( <> -
Recent
+
{t("folderGate.recent")}
{recents.map((w) => (
open(w.path)} title={w.path}> @@ -81,7 +81,7 @@ export function FolderGate({ onChoose, onCancel, create }: Props) { {onCancel && (
)} diff --git a/surfaces/gui/src/components/GalleryModal.tsx b/surfaces/gui/src/components/GalleryModal.tsx index dbb36ed8..30420c07 100644 --- a/surfaces/gui/src/components/GalleryModal.tsx +++ b/surfaces/gui/src/components/GalleryModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { cloudLogin, getCloudGallery, @@ -52,6 +53,7 @@ export function GalleryModal({ onClose: () => void; onInstalled?: () => void; }) { + const { t } = useTranslation(); const [cloud, setCloud] = useState(null); const [cards, setCards] = useState([]); const [installed, setInstalled] = useState>(new Set()); @@ -112,7 +114,7 @@ export function GalleryModal({ setMsg(null); setJustInstalled(false); const d = await getCloudGalleryDetail(slug).catch(() => null); - setDetail(d ?? { ok: false, error: "could not load details" }); + setDetail(d ?? { ok: false, error: t("gallery.couldNotLoadDetails") }); }; const install = async (slug: string) => { @@ -121,7 +123,7 @@ export function GalleryModal({ const r = await installPersona({ gallery_slug: slug }); setBusy(false); if (!r.ok) { - setMsg(r.error || "install failed"); + setMsg(r.error || t("gallery.installFailed")); return; } setInstalled((s) => new Set(s).add(slug)); @@ -143,9 +145,9 @@ export function GalleryModal({
{( [ - ["all", "All"], - ["openworker", "From OpenWorker"], - ["team", "From your team"], + ["all", t("gallery.all")], + ["openworker", t("gallery.fromOpenWorker")], + ["team", t("gallery.fromTeam")], ] as [Source, string][] ).map(([key, label]) => (
)} {featured.length > 0 && ( <>
- Featured + {t("gallery.featured")}
{featured.map((p) => ( @@ -198,7 +200,7 @@ export function GalleryModal({ )}
- All personas + {t("gallery.allPersonas")}
{visible.map((p) => { @@ -229,9 +231,9 @@ export function GalleryModal({
{isInstalled ? ( - Installed + {t("gallery.installed")} ) : ( - View & install → + {t("gallery.viewAndInstall")} )}
@@ -240,17 +242,17 @@ export function GalleryModal({ {visible.length === 0 && !unavailable && (
{source === "team" - ? "Nothing shared with your team yet." + ? t("gallery.nothingShared") : q - ? "No personas match your search." - : "No personas published yet."} + ? t("gallery.noMatch") + : t("gallery.noPersonasPublished")}
)}
{source !== "team" && teamCount === 0 && (
- From your team — nothing shared yet. Publishing a persona to your teammates is coming soon. + {t("gallery.teamTeaser")}
)}
@@ -264,12 +266,12 @@ export function GalleryModal({ className="text-[12.5px] text-muted hover:text-ink mb-3" onClick={() => setDetailSlug(null)} > - ← Gallery + {t("gallery.backToGallery")} {!detail ? ( -
Loading…
+
{t("common.loading")}
) : !detail.ok || !card ? ( -
{detail.error || "could not load details"}
+
{detail.error || t("gallery.couldNotLoadDetails")}
) : (
@@ -285,10 +287,10 @@ export function GalleryModal({
{installed.has(detailSlug) ? ( - Installed + {t("gallery.installed")} ) : ( )}
@@ -298,10 +300,10 @@ export function GalleryModal({ {justInstalled && (
- Installed — it’s waiting in Personas, disabled until you approve and enable it. + {t("gallery.installedWaiting")}
)} @@ -317,44 +319,42 @@ export function GalleryModal({ {caps && (
- What it can do — verified from its manifest + {t("gallery.whatItCanDo")}
- Read by this app’s own parser, so it matches exactly what the install - consent will ask you to approve. No executable code is installed. + {t("gallery.readByParser")}
- Tools: - {caps.tools.join(", ") || "none"} + {t("gallery.tools")} + {caps.tools.join(", ") || t("common.none")} {caps.risk.length > 0 && ( - · risk: {caps.risk.join(", ")} + {t("gallery.riskList", { list: caps.risk.join(", ") })} )}
- Permissions: - {caps.recommended_mode} mode - {caps.messaging ? " · can use messaging" : ""} - {caps.mcp.length > 0 ? ` · MCP: ${caps.mcp.join(", ")}` : ""} + {t("gallery.permissions")} + {caps.recommended_mode} {t("gallery.mode")} + {caps.messaging ? t("gallery.canUseMessaging") : ""} + {caps.mcp.length > 0 ? t("gallery.mcpList", { list: caps.mcp.join(", ") }) : ""}
{(detail.recommends?.length ?? 0) > 0 && (
-
Works with these connections:
+
{t("gallery.worksWith")}
{detail.recommends!.map((r) => (
{r.ref} - {r.tier === "core" ? " · core" : ""} + {r.tier === "core" ? t("gallery.coreSuffix") : ""} {r.reason}
))}
- You connect these yourself (one click when signed in) — installing the - coworker grants it nothing until you do. + {t("gallery.connectYourself")}
)} @@ -372,23 +372,23 @@ export function GalleryModal({
-
Persona Gallery
+
{t("gallery.title")}
- Curated coworkers · installs stay disabled until you approve them + {t("gallery.subtitle")}
{cloud?.signed_in && !detailSlug && ( setQuery(e.target.value)} - placeholder="Search personas" + placeholder={t("gallery.searchPersonas")} className="w-[180px] px-3 py-1.5 rounded-lg border border-line bg-paper text-[12.5px] text-ink outline-none focus:border-accent" /> )}
) : detailSlug ? ( diff --git a/surfaces/gui/src/components/InboxConfigure.tsx b/surfaces/gui/src/components/InboxConfigure.tsx index 86f23e4b..f18c4169 100644 --- a/surfaces/gui/src/components/InboxConfigure.tsx +++ b/surfaces/gui/src/components/InboxConfigure.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { getConnectors, getDmRoute, @@ -30,6 +31,7 @@ const SELECT = "px-2.5 py-1.5 rounded-lg border border-line bg-paper text-[13px] const BTN_ACCENT_SM = "text-[12px] px-2.5 py-1 rounded-md bg-accent text-white disabled:opacity-50"; export function InboxConfigure() { + const { t } = useTranslation(); return (
@@ -40,11 +42,8 @@ export function InboxConfigure() { {/* Unrouted = delivery FAILURES ("messages that never reached you"), so it lives with the Inbox now (§28; previously with routing under Connectors, §26). */}
-

Unrouted

-

- Inbound messages and background-turn failures nothing claimed — nothing vanishes - silently. -

+

{t("inboxConfig.unrouted")}

+

{t("inboxConfig.unroutedDesc")}

@@ -54,6 +53,7 @@ export function InboxConfigure() { // Where an Unattended session's approvals/questions get mirrored as interactive buttons. Targets // the "default" route (sessions fall back to it); pick a channel separate from any you subscribe to. function InboxRoutingCard() { + const { t } = useTranslation(); const [recent, setRecent] = useState([]); const [connectors, setConnectors] = useState([]); const [target, setTarget] = useState(""); // current default-binding address, e.g. "slack:C0123" @@ -72,8 +72,8 @@ function InboxRoutingCard() { }; useEffect(() => { load(); - const t = setInterval(load, 5000); - return () => clearInterval(t); + const iv = setInterval(load, 5000); + return () => clearInterval(iv); }, []); const save = async () => { @@ -83,7 +83,7 @@ function InboxRoutingCard() { const [platform, id] = addr.includes(":") ? addr.split(":", 2) : ["slack", addr]; const result = await setInboxBinding("default", platform, id); if (!result.ok) { - setError(result.error || "Could not update Inbox routing."); + setError(result.error || t("inboxConfig.couldNotUpdate")); return; } setError(null); @@ -93,7 +93,7 @@ function InboxRoutingCard() { const clear = async () => { const result = await setInboxBinding("default", null, ""); if (!result.ok) { - setError(result.error || "Could not clear Inbox routing."); + setError(result.error || t("inboxConfig.couldNotClear")); return; } setError(null); @@ -123,13 +123,13 @@ function InboxRoutingCard() { return (
-
Unattended approvals
+
{t("inboxConfig.unattendedApprovals")}

- Channel where an Unattended session posts Approve/Deny buttons. Currently mirroring to{" "} + {t("inboxConfig.mirrorDesc")}{" "} - {known ? `#${known}` : target || "in-app Inbox only"} + {known ? `#${known}` : target || t("inboxConfig.inAppInboxOnly")} - . + {t("inboxConfig.mirrorDescSuffix")}

@@ -141,18 +141,16 @@ function InboxRoutingCard() { disabled={!draft.trim() || missingSlackOwner} onClick={save} > - Set + {t("inboxConfig.set")} {target && ( )}
{missingSlackOwner && ( -

- Choose an approval owner under Integrations → Slack before routing approvals here. -

+

{t("inboxConfig.chooseApprovalOwner")}

)} {error &&

{error}

}
@@ -161,6 +159,7 @@ function InboxRoutingCard() { // Which session handles incoming DMs to the bot. None → DMs park in the Unrouted section below. function DmRouteCard() { + const { t } = useTranslation(); const [sessions, setSessions] = useState([]); const [dm, setDm] = useState(""); @@ -170,8 +169,8 @@ function DmRouteCard() { }; useEffect(() => { load(); - const t = setInterval(load, 5000); - return () => clearInterval(t); + const iv = setInterval(load, 5000); + return () => clearInterval(iv); }, []); const real = sessions.filter((s) => !s.session_id.startsWith("__")); @@ -183,16 +182,14 @@ function DmRouteCard() { return (
-
Direct messages
-

- Session that handles DMs to the bot. With none, DMs park under Unrouted below. -

+
{t("inboxConfig.directMessages")}
+

{t("inboxConfig.dmDesc")}

@@ -326,20 +322,19 @@ function SubscriptionsCard() { // Dead-letter view: inbound messages that had no destination (e.g. a DM with no session designated) // and background turns that failed (e.g. a dead model). Read-only — for visibility/debugging. function UnroutedTable() { + const { t } = useTranslation(); const [items, setItems] = useState(null); useEffect(() => { const load = () => getUnrouted().then(setItems).catch(() => setItems([])); load(); - const t = setInterval(load, 5000); - return () => clearInterval(t); + const iv = setInterval(load, 5000); + return () => clearInterval(iv); }, []); if (items && items.length === 0) return ( -
- Nothing here — no dropped messages or failed turns. -
+
{t("inboxConfig.nothingHere")}
); return ( @@ -347,10 +342,10 @@ function UnroutedTable() { - - - - + + + + diff --git a/surfaces/gui/src/components/InboxItemCard.tsx b/surfaces/gui/src/components/InboxItemCard.tsx index a4075636..03e76735 100644 --- a/surfaces/gui/src/components/InboxItemCard.tsx +++ b/surfaces/gui/src/components/InboxItemCard.tsx @@ -1,4 +1,5 @@ import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import type { InboxItem } from "../api"; import { humanizeApprovalTitle } from "../humanize"; import { @@ -44,6 +45,7 @@ export function InboxItemCard({ }) { const [answer, setAnswer] = useState(""); const [selected, setSelected] = useState([]); + const { t } = useTranslation(); const options = item.options || []; const multi = !!item.multi; const allowText = item.allow_text !== false; @@ -60,7 +62,7 @@ export function InboxItemCard({ }} /> ); @@ -80,7 +82,7 @@ export function InboxItemCard({
{(() => { - const s = scopeNote(item.data.tool, item.data.arguments); + const s = scopeNote(t, item.data.tool, item.data.arguments); return ( {s.text} @@ -111,7 +113,7 @@ export function InboxItemCard({ className={item.data?.tool ? BTN_ACCENT : BTN_PRIMARY} onClick={() => onResolve(item.id, "allow")} > - {item.data?.tool ? approvalActionLabels(item.data.tool).allow : "Approve"} + {item.data?.tool ? approvalActionLabels(t, item.data.tool).allow : t("approval.approve")} {/* Task-persistent standing grant (§25) — present only when the approval was raised inside an automation run AND the call can carry a tool+target rule. @@ -119,17 +121,20 @@ export function InboxItemCard({ {item.data?.task_id && item.data?.standing_target && ( )}
) : item.kind === "question" ? ( @@ -162,19 +167,20 @@ export function InboxItemCard({ disabled={!selected.length} onClick={() => onResolve(item.id, selected.join(", "))} > - Send{selected.length ? ` (${selected.length})` : ""} + {t("inboxItem.send")} + {selected.length ? ` (${selected.length})` : ""} )} {(allowText || options.length === 0) && - textRow(options.length ? "Or type your own answer…" : "Your answer…")} + textRow(options.length ? t("inboxItem.orTypeYourOwn") : t("inboxItem.yourAnswer"))} ) : item.kind === "directory" ? (
) : item.kind === "plan" ? ( @@ -194,19 +200,19 @@ export function InboxItemCard({ className={BTN_PRIMARY} onClick={() => onResolve(item.id, JSON.stringify({ approved: true, mode: "interactive" }))} > - Approve + {t("approval.approve")} ) : (
)} diff --git a/surfaces/gui/src/components/InboxView.tsx b/surfaces/gui/src/components/InboxView.tsx index 896f0cb7..a7c9db8c 100644 --- a/surfaces/gui/src/components/InboxView.tsx +++ b/surfaces/gui/src/components/InboxView.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { getConnectors, getInbox, @@ -23,10 +24,10 @@ const ICON_FOR: Record = { code: "code", }; -const KIND_TABS: { key: string; label: string }[] = [ - { key: "all", label: "All" }, - { key: "approval", label: "Approvals" }, - { key: "question", label: "Questions" }, +const KIND_TABS: { key: string; labelKey: string }[] = [ + { key: "all", labelKey: "common.all" }, + { key: "approval", labelKey: "inbox.kindApprovals" }, + { key: "question", labelKey: "inbox.kindQuestions" }, ]; const CHIP = (active: boolean) => @@ -57,6 +58,7 @@ export function InboxView({ onOpenSession: (sessionId: string, workspace: string, agent: string) => void; }) { const [tab, setTab] = useState<"pending" | "configure">("pending"); + const { t } = useTranslation(); const [items, setItems] = useState([]); const [personas, setPersonas] = useState(null); const [routing, setRouting] = useState(null); // e.g. "slack:C0123" or null @@ -85,11 +87,11 @@ export function InboxView({ .then((cs) => setSlackConnected(!!cs.find((c) => c.name === "slack" && c.connected))) .catch(() => {}); getRecentChannels().then(setRecent).catch(() => setRecent([])); - const t = setInterval(() => { + const iv = setInterval(() => { load(); loadRouting(); // edits happen on the Configure tab; keep Pending's status line honest }, 4000); - return () => clearInterval(t); + return () => clearInterval(iv); }, []); const resolve = async (id: string, resolution: string) => { @@ -122,7 +124,7 @@ export function InboxView({ return (
- {KIND_TABS.map((t) => ( - ))} {personasWithItems.length > 1 && ( @@ -225,7 +226,7 @@ export function InboxView({ className={CHIP(personaFilter === "all")} onClick={() => setPersonaFilter("all")} > - All coworkers + {t("inbox.allCoworkers")} {personasWithItems.map((p) => (
) : null} diff --git a/surfaces/gui/src/components/IntegrationsView.tsx b/surfaces/gui/src/components/IntegrationsView.tsx index d1674b3f..0fd149aa 100644 --- a/surfaces/gui/src/components/IntegrationsView.tsx +++ b/surfaces/gui/src/components/IntegrationsView.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { getConnectors } from "../api"; import { McpTab } from "./ManageTabs"; import { ConnectorsSection } from "./connectors/ConnectorsSection"; @@ -13,12 +14,13 @@ type IntTab = "connectors" | "mcp"; // Fixed sub-nav (UX-DECISIONS §21): connector detail lives as a SUBPAGE under // Connectors, never as a nav item — the nav must not grow per connector. -const INT_TABS: { key: IntTab; label: string; icon: "plug" | "code" }[] = [ - { key: "connectors", label: "Connectors", icon: "plug" }, - { key: "mcp", label: "MCP servers", icon: "code" }, +const INT_TABS: { key: IntTab; labelKey: string; icon: "plug" | "code" }[] = [ + { key: "connectors", labelKey: "integrations.connectors", icon: "plug" }, + { key: "mcp", labelKey: "integrations.mcpServers", icon: "code" }, ]; export function IntegrationsView() { + const { t } = useTranslation(); const [tab, setTab] = useState("connectors"); // Sub-nav count: how many connectors exist. Polled so the badge stays live. const [connCount, setConnCount] = useState(null); @@ -28,33 +30,33 @@ export function IntegrationsView() { getConnectors().then((cs) => setConnCount(cs.length)).catch(() => {}); }; load(); - const t = setInterval(load, 5000); - return () => clearInterval(t); + const iv = setInterval(load, 5000); + return () => clearInterval(iv); }, []); return (
WhenSourceReasonMessage{t("inboxConfig.when")}{t("inboxConfig.source")}{t("inboxConfig.reason")}{t("inboxConfig.message")}