From ebb5471b5bed8319ed27adafb5a9a851a39d63cc Mon Sep 17 00:00:00 2001 From: xuyi Date: Sat, 22 Aug 2026 16:19:33 +0800 Subject: [PATCH 1/4] feat(i18n): add Simplified Chinese UI --- src/App.tsx | 8 +- src/components/AndroidDevicePanel.tsx | 38 +- src/components/ApiKeys.tsx | 48 +- src/components/BotProfileAvatarCard.tsx | 56 +- src/components/CallView.tsx | 43 +- src/components/ChatView.tsx | 121 +-- src/components/CloudBackendPicker.tsx | 12 +- src/components/CompanionSection.tsx | 83 +- src/components/Composer.tsx | 62 +- src/components/ComputerPanel.tsx | 142 +-- src/components/EnginesSettings.tsx | 41 +- src/components/GroupView.tsx | 92 +- src/components/LinuxLocalControl.tsx | 25 +- src/components/LocalComputerAutoWarning.tsx | 10 +- src/components/LocalComputerSection.tsx | 104 +-- src/components/LocalScreenPreview.tsx | 30 +- src/components/MacLocalControl.tsx | 11 +- src/components/PluginsPanel.tsx | 76 +- src/components/RenameTitle.tsx | 16 +- src/components/RoomTurnTimeoutSettings.tsx | 8 +- src/components/RoutinesPage.tsx | 175 ++-- src/components/SettingsModal.tsx | 75 +- src/components/SettingsPanel.tsx | 155 ++-- src/components/SettingsPrimitives.tsx | 4 +- src/components/Sidebar.tsx | 208 ++--- src/components/SkinPicker.tsx | 4 +- src/components/TaskPicker.tsx | 24 +- src/components/TeamLibraryPanel.tsx | 84 +- src/components/UsageSection.tsx | 20 +- src/components/VoiceSettings.tsx | 43 +- src/components/WebhooksPanel.tsx | 113 +-- src/lib/i18n-context.tsx | 42 + src/lib/i18n.test.ts | 30 + src/lib/i18n.ts | 53 ++ src/locales/zh-CN.ts | 915 ++++++++++++++++++++ src/main.tsx | 7 +- 36 files changed, 2083 insertions(+), 895 deletions(-) create mode 100644 src/lib/i18n-context.tsx create mode 100644 src/lib/i18n.test.ts create mode 100644 src/lib/i18n.ts create mode 100644 src/locales/zh-CN.ts diff --git a/src/App.tsx b/src/App.tsx index 7b959a8aa..600859c2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,9 +16,11 @@ import { DesktopCapabilitiesProvider } from "@/components/DesktopCapabilities"; import { RoutinesPage } from "@/components/RoutinesPage"; import { NoEngines } from "@/components/NoEngines"; import { CommandPalette } from "@/components/CommandPalette"; +import { useI18n } from "@/lib/i18n-context"; function Shell() { const { state, dispatch } = useStore(); + const { t } = useI18n(); // Mobile-only drawer state. Above md, none of these properties are emitted // at all — Sidebar scopes every mobile class with max-md: rather than // cancelling them with md:, which would still emit a translate value and @@ -85,7 +87,7 @@ function Shell() {
- Click to tap, drag or use a trackpad to scroll, and type after selecting a field. + {t("Click to tap, drag or use a trackpad to scroll, and type after selecting a field.")}
)} @@ -316,26 +318,22 @@ export function AndroidDevicePanel({ status }: { status: AndroidDeviceStatus })
- First-time USB setup + {t("First-time USB setup")}
    -
  1. Connect the phone with a data-capable USB cable and keep it unlocked.
  2. +
  3. {t("Connect the phone with a data-capable USB cable and keep it unlocked.")}
  4. - Enable Developer options by tapping Build number seven times in - About phone, then turn on USB debugging. + {t("Enable Developer options by tapping Build number seven times in About phone, then turn on USB debugging.")}
  5. - Accept Allow USB debugging on the phone. You can choose Always allow - for this trusted computer. + {t("Accept Allow USB debugging on the phone. You can choose Always allow for this trusted computer.")}
- Agent control uses this same authorized USB connection. No phone companion app, account, Tailscale, or - wireless pairing is needed. + {t("Agent control uses this same authorized USB connection. No phone companion app, account, Tailscale, or wireless pairing is needed.")}
- Once connected, ask any compatible Maus to open an Android app or complete a task on your phone. The - bundled Phone Harness skill loads automatically for phone requests. + {t("Once connected, ask any compatible Maus to open an Android app or complete a task on your phone. The bundled Phone Harness skill loads automatically for phone requests.")}
diff --git a/src/components/ApiKeys.tsx b/src/components/ApiKeys.tsx index 835d757e9..b1c454e93 100644 --- a/src/components/ApiKeys.tsx +++ b/src/components/ApiKeys.tsx @@ -5,6 +5,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { Check, CircleHelp, ExternalLink, Loader2, TriangleAlert } from "lucide-react"; import { api, useStore, type ConfigStatus } from "@/state/store"; import { cn } from "@/lib/cn"; +import { useI18n } from "@/lib/i18n-context"; export type ConfigSection = "composio" | "box" | "opencodeGo"; @@ -67,6 +68,7 @@ const CREDENTIALS: Record< function CredentialHelp({ section }: { section: ConfigSection }) { const credential = CREDENTIALS[section]; + const { t } = useI18n(); const [open, setOpen] = useState(false); const rootRef = useRef(null); const buttonRef = useRef(null); @@ -97,7 +99,7 @@ function CredentialHelp({ section }: { section: ConfigSection }) { {error &&
{error}
} @@ -219,6 +222,7 @@ export function ApiKeyRow({ /** Non-secret Docker-over-SSH target. Keys and passwords stay with SSH. */ export function VpsConnection() { const { state, dispatch } = useStore(); + const { t } = useI18n(); const [alias, setAlias] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -248,24 +252,24 @@ export function VpsConnection() {
- Self-hosted VPS + {t("Self-hosted VPS")} - Optional + {t("Optional")} - {configured && Connected} + {configured && {t("Connected")}}
- SSH config alias for the Linux VPS. OpenMausBot uses your normal SSH config and agent; it does not store keys or passwords.{" "} - See the{" "} + {t("SSH config alias for the Linux VPS. OpenMausBot uses your normal SSH config and agent; it does not store keys or passwords.")}{" "} + {t("See the")}{" "} - setup guide + {t("setup guide")} {" "} - for the required SSH alias shape. + {t("for the required SSH alias shape.")}
setAlias(e.target.value)} onKeyDown={(e) => e.key === "Enter" && save()} placeholder="my-vps" - aria-label="Self-hosted VPS SSH config alias" + aria-label={t("Self-hosted VPS SSH config alias")} autoComplete="off" className="w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" /> @@ -286,9 +290,9 @@ export function VpsConnection() { !alias.trim() && configured ? "bg-raised text-danger hover:bg-raised-hover" : "bg-raised text-ink hover:bg-raised-hover", "disabled:cursor-not-allowed disabled:opacity-50", )} - title={!alias.trim() && configured ? "Remove the saved alias" : "Save"} + title={!alias.trim() && configured ? t("Remove the saved alias") : t("Save")} > - {saving ? : !alias.trim() && configured ? "Clear" : <>Save} + {saving ? : !alias.trim() && configured ? t("Clear") : <>{t("Save")}}
{error &&
{error}
} diff --git a/src/components/BotProfileAvatarCard.tsx b/src/components/BotProfileAvatarCard.tsx index 979ca9616..537283618 100644 --- a/src/components/BotProfileAvatarCard.tsx +++ b/src/components/BotProfileAvatarCard.tsx @@ -17,6 +17,7 @@ import { type BotAvatarCrop, } from "../../shared/bot-avatar"; import { BotAvatar, MausAvatar } from "./Avatar"; +import { useI18n } from "@/lib/i18n-context"; type AvatarPatch = Partial< Pick @@ -41,6 +42,7 @@ export function BotProfileAvatarCard({ onPatch: (patch: AvatarPatch) => void; }) { const { state, dispatch, flushBotPatches } = useStore(); + const { t } = useI18n(); const fileRef = useRef(null); const [uploading, setUploading] = useState(false); const [imageKey, setImageKey] = useState(""); @@ -129,12 +131,12 @@ export function BotProfileAvatarCard({ return (
- Avatar + {t("Avatar")}
@@ -164,25 +166,25 @@ export function BotProfileAvatarCard({ className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-raised px-3 py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50" > {uploading ? : } - Upload image + {t("Upload image")} {bot.avatarUrl && ( )}
-
PNG, JPEG, GIF, or WebP · up to 10 MB
+
{t("PNG, JPEG, GIF, or WebP · up to 10 MB")}
- Shape + {t("Shape")}
{BOT_AVATAR_CROPS.map((candidate, index) => ( @@ -197,7 +199,7 @@ export function BotProfileAvatarCard({ crop === candidate ? "bg-raised text-ink" : "text-ink-secondary hover:bg-raised/60 hover:text-ink", )} > - {CROP_LABEL[candidate]} + {t(CROP_LABEL[candidate])} ))}
@@ -205,7 +207,7 @@ export function BotProfileAvatarCard({ {crop === "mascot" && ( <>
- Expression + {t("Expression")}
{PICKABLE_STATES.map((expression) => ( @@ -218,8 +220,8 @@ export function BotProfileAvatarCard({ "flex h-[58px] items-center justify-center rounded-xl bg-inset transition-colors hover:bg-raised", activeState === expression && "ring-2 ring-accent-border", )} - title={expression} - aria-label={`Use ${expression} expression`} + title={t(expression)} + aria-label={t("Use {expression} expression", { expression: t(expression) })} > @@ -227,7 +229,7 @@ export function BotProfileAvatarCard({
- Color + {t("Color")}
{MAUS_COLOR_NAMES.map((color) => ( @@ -241,8 +243,8 @@ export function BotProfileAvatarCard({ bot.color === color && "ring-2 ring-accent-border ring-offset-2 ring-offset-card", )} style={{ backgroundColor: MAUS_COLORS[color] }} - title={color} - aria-label={`Use ${color} mascot color`} + title={t(color)} + aria-label={t("Use {color} mascot color", { color: t(color) })} /> ))}
@@ -251,10 +253,10 @@ export function BotProfileAvatarCard({
- Generate with GPT Image 2 + {t("Generate with GPT Image 2")}
- Uses a low-quality square draft to keep cost down. OpenAI bills your API account. + {t("Uses a low-quality square draft to keep cost down. OpenAI bills your API account.")}
{!imageConfigured ? ( @@ -265,8 +267,8 @@ export function BotProfileAvatarCard({ value={imageKey} onChange={(event) => setImageKey(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void saveImageKey()} - placeholder="Paste OpenAI image API key" - aria-label="OpenAI image API key" + placeholder={t("Paste OpenAI image API key")} + aria-label={t("OpenAI image API key")} autoComplete="off" className="min-w-0 flex-1 rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[12.5px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" /> @@ -276,10 +278,10 @@ export function BotProfileAvatarCard({ disabled={savingKey || !imageKey.trim()} className="flex w-[72px] items-center justify-center gap-1.5 rounded-lg bg-raised text-[12.5px] text-ink hover:bg-raised-hover disabled:opacity-50" > - {savingKey ? : <> Save} + {savingKey ? : <> {t("Save")}}
-
Stored in the operating system's encrypted credential store in the installed app.
+
{t("Stored in the operating system's encrypted credential store in the installed app.")}
) : (
@@ -287,8 +289,8 @@ export function BotProfileAvatarCard({ value={direction} onChange={(event) => setDirection(event.target.value.slice(0, 400))} maxLength={400} - placeholder={`Optional direction, e.g. “a calm navigator inspired by ${bot.title || bot.name}”`} - aria-label="Avatar generation direction" + placeholder={t("Optional direction, e.g. “a calm navigator inspired by {name}”", { name: bot.title || bot.name })} + aria-label={t("Avatar generation direction")} className="min-h-[72px] w-full resize-none rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[12.5px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" />
@@ -300,19 +302,19 @@ export function BotProfileAvatarCard({ className="flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white hover:brightness-110 disabled:opacity-50" > {generating ? : } - {generating ? "Generating…" : "Generate avatar"} + {generating ? t("Generating…") : t("Generate avatar")}
- Replace OpenAI image key + {t("Replace OpenAI image key")}
setImageKey(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void saveImageKey()} - placeholder="Paste replacement key" - aria-label="Replacement OpenAI image API key" + placeholder={t("Paste replacement key")} + aria-label={t("Replacement OpenAI image API key")} autoComplete="off" className="min-w-0 flex-1 rounded-lg border border-hairline/40 bg-card px-3 py-2 text-[12px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" /> @@ -322,7 +324,7 @@ export function BotProfileAvatarCard({ disabled={savingKey || !imageKey.trim()} className="flex w-[72px] items-center justify-center gap-1.5 rounded-lg bg-raised text-[12px] text-ink hover:bg-raised-hover disabled:opacity-50" > - {savingKey ? : <> Save} + {savingKey ? : <> {t("Save")}}
diff --git a/src/components/CallView.tsx b/src/components/CallView.tsx index e9da08d75..64414c766 100644 --- a/src/components/CallView.tsx +++ b/src/components/CallView.tsx @@ -30,6 +30,7 @@ import { pendingApprovals } from "./PendingApproval"; import { cn } from "@/lib/cn"; import { track } from "@/lib/analytics"; import { useDesktopCapabilities } from "./DesktopCapabilities"; +import { useI18n } from "@/lib/i18n-context"; /** Spoken answers to a permission card. Anything else is read as a reply * to the bot, not as consent — an approval must never be granted by a @@ -71,6 +72,7 @@ export function CallTargetButton({ onStart: () => void; }) { const { state, dispatch } = useStore(); + const { t } = useI18n(); const { capabilities, ready: capabilitiesReady } = useDesktopCapabilities(); const active = useOnCall() === targetId; const supported = capabilities.dictation.available && Boolean(window.ogb?.speechStart); @@ -85,29 +87,29 @@ export function CallTargetButton({ const buttonRef = useRef(null); const helpId = useId(); const label = active - ? `Hang up on ${targetName}` + ? t("Hang up on {name}", { name: targetName }) : !capabilitiesReady - ? "Checking call availability" + ? t("Checking call availability") : !supported - ? "Calls currently need the macOS desktop app" + ? t("Calls currently need the macOS desktop app") : !configured - ? "Add an ElevenLabs key in an agent profile to make calls" + ? t("Add an ElevenLabs key in an agent profile to make calls") : !voiceReady - ? "Pick a voice in an agent profile to make calls" - : `Call ${targetName}`; + ? t("Pick a voice in an agent profile to make calls") + : t("Call {name}", { name: targetName }); const reason = !capabilitiesReady - ? "Checking whether this device can make calls." + ? t("Checking whether this device can make calls.") : !capabilities.dictation.available - ? "Calls require OpenMausBot for macOS because speech recognition runs on-device." + ? t("Calls require OpenMausBot for macOS because speech recognition runs on-device.") : !window.ogb?.speechStart - ? "The speech service is unavailable in this app build. Restart or update OpenMausBot." + ? t("The speech service is unavailable in this app build. Restart or update OpenMausBot.") : !configured - ? "Add an ElevenLabs API key so the bot can speak during calls." + ? t("Add an ElevenLabs API key so the bot can speak during calls.") : !voiceReady ? voices.length > 1 - ? "Give every room member an ElevenLabs voice before starting a room call." - : "Choose an ElevenLabs voice before starting a call." + ? t("Give every room member an ElevenLabs voice before starting a room call.") + : t("Choose an ElevenLabs voice before starting a call.") : ""; useEffect(() => { @@ -164,10 +166,10 @@ export function CallTargetButton({
-
Call unavailable
+
{t("Call unavailable")}
{reason}
{voiceSetupRequired && ( )}
@@ -196,6 +198,7 @@ export function CallOverlay({ bot }: { bot: Bot }) { function Call({ bot }: { bot: Bot }) { const { dispatch } = useStore(); + const { t } = useI18n(); const speech = useSpeech(); const initialPhase: Phase = bot.busy ? "working" : "listening"; const [phase, setPhase] = useState(initialPhase); @@ -482,7 +485,7 @@ function Call({ bot }: { bot: Bot }) {
@@ -503,7 +506,7 @@ function Call({ bot }: { bot: Bot }) { {phase === "listening" ? ( heard || ( - {pushToTalk ? "Release Control + Option to send…" : "Say something…"} + {pushToTalk ? t("Release Control + Option to send…") : t("Say something…")} ) ) : ( @@ -513,12 +516,12 @@ function Call({ bot }: { bot: Bot }) { {note && (
- {note} + {t(note)}
)} diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index c6096ec0d..3f536b639 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -61,6 +61,10 @@ import { resolveTranscriptWindow, tailWindowStart, } from "@/lib/transcript-window"; +import { useI18n } from "@/lib/i18n-context"; +import type { TranslationValues } from "@/lib/i18n"; + +type Translate = (source: string, values?: TranslationValues) => string; /** Long user messages collapse behind a fade so pasted walls of text don't * bury the conversation; bots get full markdown. */ @@ -68,20 +72,21 @@ const USER_COLLAPSE_CHARS = 600; const USER_COLLAPSE_LINES = 8; /** "Today" / "Yesterday" / "Mon, Aug 11" — real dates, not a hardcoded label. */ -function dayLabel(at: number): string { +function dayLabel(at: number, locale: string, t: Translate): string { const d = new Date(at); const now = new Date(); const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); const diffDays = Math.round((startOfDay(now) - startOfDay(d)) / 86_400_000); - if (diffDays === 0) return "Today"; - if (diffDays === 1) return "Yesterday"; - return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); + if (diffDays === 0) return t("Today"); + if (diffDays === 1) return t("Yesterday"); + return d.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" }); } function DaySeparator({ at }: { at: number }) { + const { locale, t } = useI18n(); return (
- {dayLabel(at)} {formatTime(at)} + {dayLabel(at, locale, t)} {formatTime(at)}
); } @@ -89,6 +94,7 @@ function DaySeparator({ at }: { at: number }) { /** Hover/focus-revealed copy control shared by user + bot bubbles. */ function CopyButton({ text, className }: { text: string; className?: string }) { const [copied, setCopied] = useState(false); + const { t } = useI18n(); return ( @@ -165,6 +172,7 @@ function ErrorRow({ onRetry?: () => void; setupInstance?: InstanceInfo; }) { + const { t } = useI18n(); return (
@@ -181,7 +189,7 @@ function ErrorRow({ onClick={onRetry} className="mt-1.5 flex items-center gap-1.5 rounded-full border border-danger/30 px-2.5 py-1 text-[12.5px] hover:bg-danger/15" > - Retry + {t("Retry")} ) )} @@ -221,6 +229,7 @@ function BubbleEditor({ onSubmit: (text: string) => void; }) { const [draft, setDraft] = useState(initial); + const { t } = useI18n(); const ref = useRef(null); useEffect(() => { const el = ref.current; @@ -253,14 +262,14 @@ function BubbleEditor({ onClick={onCancel} className="rounded-full px-3 py-1 text-[13px] text-ink-secondary hover:bg-raised hover:text-ink" > - Cancel + {t("Cancel")}
@@ -287,6 +296,7 @@ function Bubble({ onRegenerate?: () => void; }) { const { dispatch } = useStore(); + const { t } = useI18n(); const user = message.role === "user"; const [expanded, setExpanded] = useState(false); const text = message.text ?? ""; @@ -319,9 +329,9 @@ function Bubble({ {user && message.kind === "text" && !webhookView && !bot.busy && ( @@ -336,12 +346,12 @@ function Bubble({ patch: { pinnedMessageId: bot.pinnedMessageId === message.id ? "" : message.id }, }) } - aria-label={bot.pinnedMessageId === message.id ? "Unpin message" : "Pin message"} + aria-label={bot.pinnedMessageId === message.id ? t("Unpin message") : t("Pin message")} className="rounded-md p-1.5 text-ink-secondary opacity-0 transition-opacity hover:bg-raised hover:text-ink focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100" title={ bot.pinnedMessageId === message.id - ? "Unpin this message" - : "Pin this message to the top of the thread" + ? t("Unpin this message") + : t("Pin this message to the top of the thread") } > {bot.pinnedMessageId === message.id ? : } @@ -361,12 +371,12 @@ function Bubble({
- Webhook task + {t("Webhook task")}
{webhookView.task}
{webhookView.payload && (
- View event payload + {t("View event payload")}
{webhookView.payload}
)} @@ -386,7 +396,7 @@ function Bubble({ > Attached image @@ -400,18 +410,18 @@ function Bubble({ {visibleText}
{message.steered && ( -
- sent mid-turn +
+ {t("sent mid-turn")}
)} {collapsible && ( )} {expanded && ( )} @@ -430,8 +440,8 @@ function Bubble({ {isLastBotText && !bot.busy && onRegenerate && ( @@ -474,7 +484,7 @@ function Bubble({ onClick={() => switchTo(versions[versionIndex + 1])} disabled={versionIndex >= versions.length - 1 || bot.busy} className="rounded p-0.5 hover:bg-raised hover:text-ink disabled:opacity-30 disabled:hover:bg-transparent" - title="Next version" + title={t("Next version")} > @@ -487,6 +497,7 @@ function Bubble({ /** A tool run: spinner while live, check/cross once settled. */ function ActivityChip({ message }: { message: Message }) { const { dispatch } = useStore(); + const { t } = useI18n(); const tool = message.tool; if (!tool) return null; // bot⇄bot comm chip: opens the channel where the exchange lives @@ -496,7 +507,7 @@ function ActivityChip({ message }: { message: Message }) {
)} @@ -689,10 +703,11 @@ function PinnedBanner({ onJump: (messageId: string) => void; onUnpin: () => void; }) { + const { t } = useI18n(); const pinned = messages.find((m) => m.id === pinnedId); if (!pinned || pinned.kind !== "text") return null; const sender = - pinned.role === "user" ? "You" : (pinned.from?.name ?? bot.name); + pinned.role === "user" ? t("You") : (pinned.from?.name ?? bot.name); const text = (pinned.text ?? "").replace(/\s+/g, " ").trim(); if (!text) return null; return ( @@ -702,15 +717,15 @@ function PinnedBanner({ )} @@ -963,19 +979,19 @@ export function ChatView({ bot }: { bot: Bot }) { "rounded-md p-1.5 hover:bg-raised", state.computerOpen ? "text-accent" : "text-ink-secondary hover:text-ink", )} - title="Bot's computer" + title={t("Bot's computer")} > @@ -1036,7 +1052,7 @@ export function ChatView({ bot }: { bot: Bot }) { className="mx-auto flex max-w-[900px] flex-col gap-3 pb-4" role="log" aria-live="polite" - aria-label={`Conversation with ${bot.name}`} + aria-label={t("Conversation with {name}", { name: bot.name })} > {hiddenCount > 0 && (
@@ -1044,7 +1060,7 @@ export function ChatView({ bot }: { bot: Bot }) { onClick={showEarlier} className="rounded-full border border-hairline/40 bg-panel px-3 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink" > - Show earlier messages ({hiddenCount} more) + {t("Show earlier messages ({count} more)", { count: hiddenCount })}
)} @@ -1066,7 +1082,7 @@ export function ChatView({ bot }: { bot: Bot }) { onClick={showLater} className="rounded-full border border-hairline/40 bg-panel px-3 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink" > - Show later messages ({laterCount} more) + {t("Show later messages ({count} more)", { count: laterCount })}
)} @@ -1074,7 +1090,7 @@ export function ChatView({ bot }: { bot: Bot }) {
- Setting up this bot's computer… + {t("Setting up this bot's computer…")}
)} @@ -1102,10 +1118,10 @@ export function ChatView({ bot }: { bot: Bot }) { {!follow && ( )} @@ -1158,6 +1174,7 @@ function UsageChip({ bot }: { bot: Bot }) { * folder a first turn would pin. Click opens bot settings to change it. */ function WorkingFolderChip({ bot }: { bot: Bot }) { const { dispatch } = useStore(); + const { t } = useI18n(); const task = bot.tasks?.find((t) => t.threadId === bot.threadId); const folder = task?.cwd === undefined ? bot.cwd : (task.cwd ?? undefined); if (!folder) return null; @@ -1169,7 +1186,7 @@ function WorkingFolderChip({ bot }: { bot: Bot }) { "flex max-w-[180px] items-center gap-1.5 rounded-full border border-hairline/40 bg-raised/60 px-2.5 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink", COMPACT_SQUARE, )} - title={`Working folder: ${folder}`} + title={t("Working folder: {folder}", { folder })} > {name} diff --git a/src/components/CloudBackendPicker.tsx b/src/components/CloudBackendPicker.tsx index 35e099aa9..3621d8b4f 100644 --- a/src/components/CloudBackendPicker.tsx +++ b/src/components/CloudBackendPicker.tsx @@ -4,6 +4,7 @@ // rules can never drift apart. import type { CloudBackend } from "../../server/contracts.ts"; import { cn } from "@/lib/cn"; +import { useI18n } from "@/lib/i18n-context"; export function CloudBackendPicker({ value, @@ -14,13 +15,14 @@ export function CloudBackendPicker({ vpsSupported: boolean; onChange: (backend: CloudBackend) => void; }) { + const { t } = useI18n(); return (
-
Cloud backend
+
{t("Cloud backend")}
{value === "vps" - ? "Auto only attaches to a VPS container that is already running — a stopped or missing one is never provisioned or started, and the bot quietly works as if no cloud computer existed. Choose Cloud to provision or start it. No interactive desktop tunnel is exposed." - : "Box is the default hosted computer. Choose Self-hosted VPS to use your SSH-configured Linux Docker host."} + ? t("Auto only attaches to a VPS container that is already running — a stopped or missing one is never provisioned or started, and the bot quietly works as if no cloud computer existed. Choose Cloud to provision or start it. No interactive desktop tunnel is exposed.") + : t("Box is the default hosted computer. Choose Self-hosted VPS to use your SSH-configured Linux Docker host.")}
{(["box", "vps"] as const).map((backend, i) => { @@ -29,7 +31,7 @@ export function CloudBackendPicker({ ); })} diff --git a/src/components/CompanionSection.tsx b/src/components/CompanionSection.tsx index 0708a5cba..feeed91f1 100644 --- a/src/components/CompanionSection.tsx +++ b/src/components/CompanionSection.tsx @@ -16,6 +16,8 @@ import { useCallback, useEffect, useState } from "react"; import { Loader2, Smartphone, Trash2 } from "lucide-react"; import { QRCodeSVG } from "qrcode.react"; import { companionPairingLink } from "../lib/companion-pairing"; +import { useI18n } from "../lib/i18n-context"; +import type { TranslationValues } from "../lib/i18n"; import { Card } from "./SettingsPrimitives"; interface Device { @@ -64,17 +66,18 @@ const bridge = (): Bridge | null => // SAFETY: the preload owns `ogb.companion`; every call is still guarded for browser builds where it is absent. (globalThis as { ogb?: { companion?: Bridge } }).ogb?.companion ?? null; -const relative = (at: number) => { +const relative = (at: number, t: (source: string, values?: TranslationValues) => string) => { const seconds = Math.round((Date.now() - at) / 1000); - if (seconds < 90) return "just now"; + if (seconds < 90) return t("just now"); const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes} min ago`; + if (minutes < 60) return t("{count} min ago", { count: minutes }); const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours} h ago`; - return `${Math.round(hours / 24)} d ago`; + if (hours < 24) return t("{count} h ago", { count: hours }); + return t("{count} d ago", { count: Math.round(hours / 24) }); }; export function CompanionSection() { + const { t } = useI18n(); const [state, setState] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -133,8 +136,8 @@ export function CompanionSection() { if (!bridge()) { return (
@@ -143,7 +146,7 @@ export function CompanionSection() { if (!state) { return ( - + ); @@ -178,19 +181,19 @@ export function CompanionSection() { return (
-
{state.enabled ? "On" : "Off"}
+
{state.enabled ? t("On") : t("Off")}
{!state.enabled - ? "Nothing on this computer is reachable from the network." + ? t("Nothing on this computer is reachable from the network.") : !address - ? `Listening on port ${state.port} — no network address yet.` + ? t("Listening on port {port} — no network address yet.", { port: state.port }) : tailnet - ? `Enter ${tailnet}:${state.port} on your phone — that works from anywhere on your tailnet, on any network.` + ? t("Enter {address}:{port} on your phone — that works from anywhere on your tailnet, on any network.", { address: tailnet, port: state.port }) : state.discovery?.advertising ? // The address is shown even when Bonjour is working. // Discovery can be advertising happily and still not @@ -198,14 +201,14 @@ export function CompanionSection() { // clients blocks multicast — and when that happens the // typed address is the way out. Hiding it behind a // failure the panel cannot detect is no help at all. - `Your phone will find this computer as "${state.discovery.name}", or you can enter ${address}:${state.port}.` - : `Listening on ${address}:${state.port} — enter that on your phone.`} + t("Your phone will find this computer as \"{name}\", or you can enter {address}:{port}.", { name: state.discovery.name, address, port: state.port }) + : t("Listening on {address}:{port} — enter that on your phone.", { address, port: state.port })}
{state.enabled && tailnet && state.lan && (
- On this network only: {state.lan}:{state.port} + {t("On this network only: {address}:{port}", { address: state.lan, port: state.port })}
)} {/* A tailnet address with no name is workable on a laptop and not on @@ -223,16 +226,12 @@ export function CompanionSection() { unexplained policy error. */} {state.enabled && state.tailscale && !state.tailnetName && (
- You're on a tailnet, but this computer's MagicDNS name couldn't be read from the - Tailscale app — either MagicDNS is off, or the Tailscale command line tool isn't - where we looked. iPhones can't connect to a bare tailnet address, so check the - OpenMausBot log for which paths were tried. + {t("You're on a tailnet, but this computer's MagicDNS name couldn't be read from the Tailscale app — either MagicDNS is off, or the Tailscale command line tool isn't where we looked. iPhones can't connect to a bare tailnet address, so check the OpenMausBot log for which paths were tried.")}
)} {state.enabled && !state.tailscale && (
- Only reachable on this network. Install Tailscale on both this computer and your phone - to reach it from anywhere — including networks that stop devices from seeing each other. + {t("Only reachable on this network. Install Tailscale on both this computer and your phone to reach it from anywhere — including networks that stop devices from seeing each other.")}
)} {(error || state.error) && ( @@ -241,33 +240,33 @@ export function CompanionSection() {
{state.pairing ? (
{pairingLink && ( -
+
)}
-
Manual code
+
{t("Manual code")}
{state.pairing.code}
- Expires in {secondsLeft}s{address ? ` · ${address}:${state.port}` : ""} + {t("Expires in {seconds}s", { seconds: secondsLeft })}{address ? ` · ${address}:${state.port}` : ""}
{state.discovery?.advertising && (
- Or open the mobile app and choose “{state.discovery.name}” under On this network. + {t("Or open the mobile app and choose “{name}” under On this network.", { name: state.discovery.name })}
)}
@@ -285,17 +284,17 @@ export function CompanionSection() { onClick={beginSetup} className="rounded-lg bg-accent px-3 py-2 text-[13px] font-medium text-white hover:opacity-90 disabled:opacity-40" > - {busy ? "Preparing…" : state.devices.length ? "Pair another phone" : "Set up a phone"} + {busy ? t("Preparing…") : state.devices.length ? t("Pair another phone") : t("Set up a phone")} )} {state.devices.length > 0 && ( @@ -305,14 +304,14 @@ export function CompanionSection() {
{device.name}
-
Last seen {relative(device.lastSeenAt)}
+
{t("Last seen {time}", { time: relative(device.lastSeenAt, t) })}
- Cloud desktop + {t("Cloud desktop")} ))}
@@ -398,20 +400,20 @@ export function Composer({ disabled={Boolean(approval)} placeholder={ approval - ? "Answer the approval above to continue" + ? t("Answer the approval above to continue") : recording - ? "Listening…" + ? t("Listening…") : busy && canSteer - ? `${busyName} is working — Enter sends this into the running turn` + ? t("{name} is working — Enter sends this into the running turn", { name: busyName }) : busy ? group - ? `${busyName} is working — Enter queues your message` - : `${busyName} is working — sends when this turn finishes` + ? t("{name} is working — Enter queues your message", { name: busyName }) + : t("{name} is working — sends when this turn finishes", { name: busyName }) : group - ? `Message ${group.name} — ${groupComposerHint(group, members ?? [])}` - : `Message ${bot?.name ?? ""}` + ? t("Message {name} — {hint}", { name: group.name, hint: t(groupComposerHint(group, members ?? [])) }) + : t("Message {name}", { name: bot?.name ?? "" }) } - aria-label={`Message ${group ? group.name : (bot?.name ?? "")}`} + aria-label={t("Message {name}", { name: group ? group.name : (bot?.name ?? "") })} className="max-h-40 w-full resize-none self-center bg-transparent py-1 text-[15px] leading-6 text-ink placeholder:text-ink-secondary focus:outline-none" /> {busy && ( @@ -420,9 +422,9 @@ export function Composer({ if (group) dispatch({ type: "interruptGroup", groupId: group.id }); else if (bot) dispatch({ type: "interrupt", botId: bot.id }); }} - aria-label="Stop this turn" + aria-label={t("Stop this turn")} className="flex size-8 shrink-0 items-center justify-center rounded-full text-ink-secondary hover:bg-raised hover:text-ink" - title="Stop" + title={t("Stop")} > @@ -430,14 +432,14 @@ export function Composer({ {!busy && !hasContent && capabilities.dictation.available && ( @@ -445,8 +447,8 @@ export function Composer({ {hasContent && ( @@ -660,7 +662,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { panelView === "computer" ? "bg-raised text-ink" : "text-ink-secondary hover:text-ink", )} > - Computer + {t("Computer")}
) : ( - Computer + {t("Computer")} )} )} {phase === "vm-unavailable" && ( @@ -745,14 +747,14 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {(pending === "vm-create" || pending === "vm-recreate") && ( )} - {vmStatus.container === "missing" ? `Create ${bot.name}'s VM` : `Replace ${bot.name}'s VM`} + {vmStatus.container === "missing" ? t("Create {name}'s VM", { name: bot.name }) : t("Replace {name}'s VM", { name: bot.name })} ) : ( ) )} @@ -761,7 +763,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { onClick={openConnectionSettings} className="mt-1 rounded-lg bg-raised px-3 py-1.5 text-[12px] text-ink hover:bg-raised-hover" > - Open VPS settings + {t("Open VPS settings")} )} {phase === "vps-stopped" && bot.computer === "cloud" && ( @@ -771,7 +773,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { className="mt-1 rounded-lg bg-raised px-3 py-1.5 text-[12px] text-ink hover:bg-raised-hover disabled:opacity-50" > {pending === "provision" && } - Start VPS computer + {t("Start VPS computer")} )}
@@ -780,13 +782,13 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {error && (
- {error} + {t(error)}
)} {phase === "unconfigured" && (
- Add a Box API key to give this bot a cloud computer — it spins up right here. + {t("Add a Box API key to give this bot a cloud computer — it spins up right here.")}
- Configure the VPS SSH alias in App Settings → Connections. Auto only reuses an existing ready container. + {t("Configure the VPS SSH alias in App Settings → Connections. Auto only reuses an existing ready container.")}
)} @@ -812,7 +814,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {(phase === "ready" || phase === "vm") && control.helpReason && !control.held && (
- {bot.name} asked for your hands: {control.helpReason} + {t("{name} asked for your hands: {reason}", { name: bot.name, reason: control.helpReason })}
@@ -838,9 +840,9 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {(phase === "ready" || phase === "vm") && control.held && (
- You have the wheel — the bot's clicks and keystrokes are refused until you hand it back. - {phase === "ready" && cloudBackend === "box" && " Use Open desktop to drive."} - {phase === "vm" && " Use Open desktop to drive — the preview here is watch-only."} + {t("You have the wheel — the bot's clicks and keystrokes are refused until you hand it back.")} + {phase === "ready" && cloudBackend === "box" && t(" Use Open desktop to drive.")} + {phase === "vm" && t(" Use Open desktop to drive — the preview here is watch-only.")}
)} @@ -857,10 +859,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { onClick={() => void openDesktop()} disabled={pending === "join"} className="mt-3 flex w-full items-center justify-center gap-2 rounded-lg bg-raised py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50" - title="Open the Local VM's live desktop inside OpenMausBot" + title={t("Open the Local VM's live desktop inside OpenMausBot")} > {pending === "join" ? : } - Open live desktop + {t("Open live desktop")} )} {phase === "vm" && !control.held && !control.helpReason && ( @@ -868,10 +870,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { onClick={() => void openDesktop()} disabled={controlPending || pending === "join" || !vmViewerUrl} className="mt-3 flex w-full items-center justify-center gap-2 rounded-lg bg-raised py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50" - title="Pause the bot's hands and open the Local VM's live desktop" + title={t("Pause the bot's hands and open the Local VM's live desktop")} > {pending === "join" ? : } - Take control + {t("Take control")} )} {phase === "vm" && vmStatus?.mode === "per-bot" && ( @@ -879,10 +881,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { onClick={() => void runVmAction("vm-delete")} disabled={pending !== null || bot.busy} className="mt-2 flex w-full items-center justify-center gap-2 rounded-lg border border-danger/30 py-2 text-[13px] text-danger hover:bg-danger/10 disabled:opacity-50" - title={bot.busy ? "Stop this bot's turn before deleting its VM" : `Delete ${bot.name}'s Local VM`} + title={bot.busy ? t("Stop this bot's turn before deleting its VM") : t("Delete {name}'s Local VM", { name: bot.name })} > {pending === "vm-delete" ? : } - Delete this bot's VM + {t("Delete this bot's VM")} )} {/* Cloud-only actions */} @@ -895,10 +897,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { } disabled={controlPending || pending === "join"} className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-raised py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50" - title="Pause the bot's hands and drive this computer yourself" + title={t("Pause the bot's hands and drive this computer yourself")} > {pending === "join" ? : } - Take control + {t("Take control")} )} {cloudBackend === "box" && control.held && ( @@ -908,7 +910,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-raised py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50" > {pending === "join" ? : } - Open live desktop + {t("Open live desktop")} )} {(cloudBackend === "vps" || boxState !== "archived") && ( @@ -916,10 +918,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { onClick={() => run("sleep")} disabled={pending === "sleep"} className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-raised px-3 py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50" - title="Put the computer to sleep" + title={t("Put the computer to sleep")} > {pending === "sleep" ? : } - Sleep + {t("Sleep")} )}
@@ -931,19 +933,17 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {/* Computer source */}
-
Runs on
+
{t("Runs on")}
{!bot.computer && (isLinux || !localSelectable ? cloudBackend === "vps" - ? "Auto reuses a ready VPS when one is configured; otherwise computer use stays off. " - : `${linuxAutoDescription()} ` + ? t("Auto reuses a ready VPS when one is configured; otherwise computer use stays off. ") + : `${t(linuxAutoDescription())} ` : cloudBackend === "vps" - ? "Auto reuses a ready VPS when one exists, otherwise this computer. " - : "Auto uses a cloud box when one exists, otherwise this computer. ")} - Pick where this bot's computer lives. Local VM is a Cua-controlled Linux desktop - in a container on this machine — free and separate from your own desktop. Set it up in App - Settings → Local VM. + ? t("Auto reuses a ready VPS when one exists, otherwise this computer. ") + : t("Auto uses a cloud box when one exists, otherwise this computer. "))} + {t("Pick where this bot's computer lives. Local VM is a Cua-controlled Linux desktop in a container on this machine — free and separate from your own desktop. Set it up in App Settings → Local VM.")}
{( @@ -971,7 +971,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { ); })() @@ -1006,7 +1006,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
- Scheduled tasks + {t("Scheduled tasks")}
{botRoutines.length > 0 && ( @@ -1015,12 +1015,12 @@ export function ComputerPanel({ bot }: { bot: Bot }) { )}
- Schedule work for {bot.name}. Use its current setup, or run the whole job inside its cloud VM. + {t("Schedule work for {name}. Use its current setup, or run the whole job inside its cloud VM.", { name: bot.name })}
{!computerDestination && (
- Scheduled tasks on this computer will not have desktop access while this is Off. Choose Cloud VM in the schedule editor to run the whole job there. + {t("Scheduled tasks on this computer will not have desktop access while this is Off. Choose Cloud VM in the schedule editor to run the whole job there.")}
)} {activeRoutineRun && ( @@ -1030,7 +1030,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { > - {activeRoutineRun.routineName} · {activeRoutineRun.status === "waiting" ? "needs you" : activeRoutineRun.status} + {activeRoutineRun.routineName} · {activeRoutineRun.status === "waiting" ? t("needs you") : t(activeRoutineRun.status)} )} @@ -1046,10 +1046,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {routine.name} - {routineScheduleLabel(routine)}{routine.runOn === "cloud" ? " · runs on VM" : ""} + {routineScheduleLabel(routine, t)}{routine.runOn === "cloud" ? t(" · runs on VM") : ""} - {nextRunLabel(routine.nextRunAt)} + {nextRunLabel(routine.nextRunAt, t)} ))}
@@ -1060,15 +1060,15 @@ export function ComputerPanel({ bot }: { bot: Bot }) { className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-accent py-2 text-[13px] font-medium text-white hover:brightness-110" > - Create schedule + {t("Create schedule")}
diff --git a/src/components/EnginesSettings.tsx b/src/components/EnginesSettings.tsx index 312e438b1..3d999c2c4 100644 --- a/src/components/EnginesSettings.tsx +++ b/src/components/EnginesSettings.tsx @@ -12,6 +12,7 @@ import { EngineGroupLabel } from "./EngineGroupLabel"; import { ProviderMark } from "./ProviderIcons"; import { splitEngineRail } from "@/lib/engine-rail"; import { cn } from "@/lib/cn"; +import { useI18n } from "@/lib/i18n-context"; interface ProbeResult { ok: boolean; @@ -25,6 +26,7 @@ function CustomPicker({ instance, cliDefault, onClose, onSaved }: { onClose: () => void; onSaved: () => Promise; }) { + const { t } = useI18n(); const [candidates, setCandidates] = useState(instance.cliCandidates ?? null); // `selected` starts EMPTY, never at instance.cli: a wrapper override // ("/ag claude agp") has no matching + {candidates.map((p) => ( ))} @@ -136,8 +138,8 @@ function CustomPicker({ instance, cliDefault, onClose, onSaved }: { if (!value || !dirty) return; // nothing to save — same hint the disabled button gives save(); }} - placeholder={candidates?.length ? "Enter path manually…" : "/absolute/path/to/cli"} - aria-label={`${instance.displayName} custom CLI path`} + placeholder={candidates?.length ? t("Enter path manually…") : "/absolute/path/to/cli"} + aria-label={t("{name} custom CLI path", { name: instance.displayName })} spellCheck={false} disabled={busy} className="w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 font-mono text-[12px] text-ink placeholder:font-sans placeholder:text-ink-secondary focus:border-hairline focus:outline-none disabled:opacity-50" @@ -146,13 +148,13 @@ function CustomPicker({ instance, cliDefault, onClose, onSaved }: {
- Test failed — {probe.message} - {" "}Register this path anyway? + {t("Test failed — {message}", { message: probe.message })} + {" "}{t("Register this path anyway?")}
)} {probe?.ok && probe.version && ( -
Test passed — {probe.version}
+
{t("Test passed — {version}", { version: probe.version })}
)} {error &&
{error}
}
@@ -161,7 +163,7 @@ function CustomPicker({ instance, cliDefault, onClose, onSaved }: { disabled={busy} className="rounded-lg px-3 py-1.5 text-[13px] text-ink-secondary hover:bg-raised/50 hover:text-ink disabled:opacity-50" > - Cancel + {t("Cancel")} {probe && !probe.ok ? ( <> @@ -170,14 +172,14 @@ function CustomPicker({ instance, cliDefault, onClose, onSaved }: { disabled={busy} className="rounded-lg px-3 py-1.5 text-[13px] text-ink-secondary hover:bg-raised/50 hover:text-ink disabled:opacity-50" > - Edit path + {t("Edit path")} ) : ( @@ -190,7 +192,7 @@ function CustomPicker({ instance, cliDefault, onClose, onSaved }: { "disabled:cursor-not-allowed disabled:opacity-50", )} > - {busy ? : <>Save} + {busy ? : <>{t("Save")}} )}
@@ -200,6 +202,7 @@ function CustomPicker({ instance, cliDefault, onClose, onSaved }: { function EngineRow({ instance }: { instance: InstanceInfo }) { const { refreshInstances } = useStore(); + const { t } = useI18n(); const [open, setOpen] = useState(false); const [switching, setSwitching] = useState(false); const [error, setError] = useState(null); @@ -243,7 +246,7 @@ function EngineRow({ instance }: { instance: InstanceInfo }) { ) : ( instance.cliDefault && ( - {instance.cliDefault} · default + {instance.cliDefault} · {t("default")} ) )} @@ -253,7 +256,7 @@ function EngineRow({ instance }: { instance: InstanceInfo }) { disabled={switching} className="shrink-0 text-[11.5px] text-ink-secondary hover:text-ink disabled:opacity-50" > - {switching ? "Resetting…" : "Reset"} + {switching ? t("Resetting…") : t("Reset")} )}
{error &&
{error}
} @@ -282,6 +285,7 @@ function EngineRow({ instance }: { instance: InstanceInfo }) { export function EnginesSettings() { const { state } = useStore(); + const { t } = useI18n(); // every KNOWN-driver instance has cliDefault; unknown-driver shadows have // neither unless an override was set. Including them keeps a Reset-able row // (and a Set CLI… path) for engines the running build doesn't recognize. @@ -290,17 +294,17 @@ export function EnginesSettings() { return (
{rows.length === 0 && ( -
No CLI engines detected yet.
+
{t("No CLI engines detected yet.")}
)} {(() => { const { subscription, custom } = splitEngineRail(rows); return ( <> - {subscription.length > 0 && Cloud} + {subscription.length > 0 && {t("Cloud")}} {subscription.map((i) => ( ))} - {custom.length > 0 && Local} + {custom.length > 0 && {t("Local")}} {custom.map((i) => ( ))} @@ -308,8 +312,7 @@ export function EnginesSettings() { ); })()}
- Set CLI points an engine at a specific binary — a versioned build, a wrapper script, or an - absolute path. Saving reloads providers and interrupts any running turns. + {t("Set CLI points an engine at a specific binary — a versioned build, a wrapper script, or an absolute path. Saving reloads providers and interrupts any running turns.")}
); diff --git a/src/components/GroupView.tsx b/src/components/GroupView.tsx index a28a02ee1..b341dbd11 100644 --- a/src/components/GroupView.tsx +++ b/src/components/GroupView.tsx @@ -36,15 +36,19 @@ import { resolveTranscriptWindow, tailWindowStart, } from "@/lib/transcript-window"; +import { useI18n } from "@/lib/i18n-context"; +import type { TranslationValues } from "@/lib/i18n"; -function dayLabel(at: number): string { +type Translate = (source: string, values?: TranslationValues) => string; + +function dayLabel(at: number, locale: string, t: Translate): string { const d = new Date(at); const now = new Date(); const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); const diffDays = Math.round((startOfDay(now) - startOfDay(d)) / 86_400_000); - if (diffDays === 0) return "Today"; - if (diffDays === 1) return "Yesterday"; - return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); + if (diffDays === 0) return t("Today"); + if (diffDays === 1) return t("Yesterday"); + return d.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" }); } /** 16px maus + name, shown once per sender cluster. */ @@ -67,6 +71,7 @@ function ClusterLabel({ bot, name, color }: { bot?: Bot; name: string; color: st /** Pin toggle for one room message — one pin per room, patchGroup path. */ function PinToggle({ group, message }: { group: Group; message: Message }) { const { dispatch } = useStore(); + const { t } = useI18n(); const pinned = group.pinnedMessageId === message.id; return ( @@ -96,6 +101,7 @@ const Transcript = memo(function Transcript({ /** The windowed suffix of group.messages — the boundary lives in GroupView. */ messages: Message[]; }) { + const { locale, t } = useI18n(); const memberOf = (id?: string) => members.find((b) => b.id === id); const textMessages = messages; return ( @@ -155,7 +161,7 @@ const Transcript = memo(function Transcript({
{newDay && (
- {dayLabel(m.at)} {formatTime(m.at)} + {dayLabel(m.at, locale, t)} {formatTime(m.at)}
)} {!user && m.from && newCluster && ( @@ -183,15 +189,16 @@ function StreamingBubble({ text }: { text: string }) { function DefaultResponderSelect({ group, members }: { group: Group; members: Bot[] }) { const { dispatch } = useStore(); + const { t } = useI18n(); const responder = effectiveDefaultResponder(group, members); const value = responder.kind === "member" ? `member:${responder.botId}` : responder.kind; const lead = responder.kind === "member" ? members.find((member) => member.id === responder.botId) : undefined; const title = responder.kind === "everyone" - ? "Plain messages go to every room member; @mentions override this" + ? t("Plain messages go to every room member; @mentions override this") : responder.kind === "mentions" - ? "Only explicitly @mentioned bots respond" - : `Plain messages go to ${lead?.name ?? "the lead bot"}; @mentions override this`; + ? t("Only explicitly @mentioned bots respond") + : t("Plain messages go to {name}; @mentions override this", { name: lead?.name ?? t("the lead bot") }); const change = (nextValue: string) => { let next: GroupDefaultResponder; @@ -204,21 +211,21 @@ function DefaultResponderSelect({ group, members }: { group: Group; members: Bot return (
(null); const [error, setError] = useState(null); @@ -267,28 +275,28 @@ function RoomWorkingFolder({ group }: { group: Group }) { return (
-
Working folder
-
Where every bot in this room runs its shell and file tools.
+
{t("Working folder")}
+
{t("Where every bot in this room runs its shell and file tools.")}
{locked ? (
- {shownCwd ? shortPath(shownCwd, home) : Each bot's own folder} + {shownCwd ? shortPath(shownCwd, home) : {t("Each bot's own folder")}}
- Fixed after this room's first turn. Create a new room and choose its folder before sending the first message to work somewhere else. + {t("Fixed after this room's first turn. Create a new room and choose its folder before sending the first message to work somewhere else.")}
) : canPick ? (
- {group.cwd ? shortPath(group.cwd, home) : Each bot's own folder} + {group.cwd ? shortPath(group.cwd, home) : {t("Each bot's own folder")}}
{group.cwd && ( )}
@@ -303,12 +311,12 @@ function RoomWorkingFolder({ group }: { group: Group }) { > setDraft(e.target.value)} /> )} @@ -321,13 +329,14 @@ function RoomWorkingFolder({ group }: { group: Group }) { * else the room folder a first turn would pin. Always present so the desk * is settable before any folder exists; quiet (icon only) until then. */ function RoomWorkingFolderChip({ group, onToggle }: { group: Group; onToggle: () => void }) { + const { t } = useI18n(); const folder = group.pinnedCwd === undefined ? group.cwd : (group.pinnedCwd ?? undefined); if (!folder) { return ( @@ -338,7 +347,7 @@ function RoomWorkingFolderChip({ group, onToggle }: { group: Group; onToggle: () )} @@ -558,7 +568,7 @@ export function GroupView({ group }: { group: Group }) { const pinned = group.messages.find((m) => m.id === group.pinnedMessageId && m.kind === "text"); const text = pinned ? (pinned.text ?? "").replace(/\s+/g, " ").trim() : ""; if (!pinned || !text) return null; - const sender = pinned.role === "user" ? "You" : (pinned.from?.name ?? "A bot"); + const sender = pinned.role === "user" ? t("You") : (pinned.from?.name ?? t("A bot")); return (
@@ -566,15 +576,15 @@ export function GroupView({ group }: { group: Group }) {
)} @@ -645,7 +655,7 @@ export function GroupView({ group }: { group: Group }) { onClick={showEarlier} className="rounded-full border border-hairline/40 bg-panel px-3 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink" > - Show earlier messages ({hiddenCount} more) + {t("Show earlier messages ({count} more)", { count: hiddenCount })}
)} @@ -656,7 +666,7 @@ export function GroupView({ group }: { group: Group }) { onClick={showLater} className="rounded-full border border-hairline/40 bg-panel px-3 py-1 text-[12.5px] text-ink-secondary hover:bg-raised hover:text-ink" > - Show later messages ({laterCount} more) + {t("Show later messages ({count} more)", { count: laterCount })}
)} @@ -690,10 +700,10 @@ export function GroupView({ group }: { group: Group }) { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }); }} - aria-label="Jump to latest messages" + aria-label={t("Jump to latest messages")} className="animate-pop-in absolute bottom-24 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-hairline/40 bg-raised px-3 py-1.5 text-[12.5px] text-ink shadow-lg hover:bg-raised-hover" > - Jump to latest + {t("Jump to latest")} )} diff --git a/src/components/LinuxLocalControl.tsx b/src/components/LinuxLocalControl.tsx index 8aca4f0a4..633acc9c5 100644 --- a/src/components/LinuxLocalControl.tsx +++ b/src/components/LinuxLocalControl.tsx @@ -11,11 +11,13 @@ import { import { cn } from "@/lib/cn"; import { useDesktopCapabilities } from "./DesktopCapabilities"; +import { useI18n } from "@/lib/i18n-context"; const LINUX_GUIDE_URL = "https://github.com/milind-soni/OpenMausBot/blob/main/docs/linux-desktop.md#enable-local-control"; export function LinuxLocalControl() { + const { t } = useI18n(); const { capabilities } = useDesktopCapabilities(); const local = capabilities.localComputer; const [pending, setPending] = useState<"enable" | "disable" | "retry" | null>(null); @@ -50,7 +52,7 @@ export function LinuxLocalControl() {
- Local control + {t("Local control")}
Beta · Ubuntu 24.04 GNOME/{wayland ? "Wayland" : "Xorg"} · Cua Driver 0.19.3 @@ -62,7 +64,7 @@ export function LinuxLocalControl() { ready ? "bg-success/10 text-success" : local.enabled ? "bg-warning/10 text-warning" : "bg-raised text-ink-secondary", )} > - {ready ? "Ready" : local.enabled ? "Needs attention" : "Off"} + {ready ? t("Ready") : local.enabled ? t("Needs attention") : t("Off")}
@@ -71,9 +73,8 @@ export function LinuxLocalControl() {
- Enabling lets bots you explicitly assign to This computer{" "} - inspect the active desktop and request mouse or keyboard actions. Every local action asks you first. - {wayland && " GNOME may also ask you to allow foreground input for this desktop session."} + {t("Enabling lets bots you explicitly assign to This computer inspect the active desktop and request mouse or keyboard actions. Every local action asks you first.")} + {wayland && t(" GNOME may also ask you to allow foreground input for this desktop session.")}
@@ -89,13 +90,13 @@ export function LinuxLocalControl() { )} {ready - ? "Ready for bots explicitly assigned to this computer." - : local.message ?? "Checking the driver and desktop session…"} + ? t("Ready for bots explicitly assigned to this computer.") + : local.message ? t(local.message) : t("Checking the driver and desktop session…")}
{local.driverPath && (
- {bundledDriver ? "Bundled Cua Driver" : local.driverPath} + {bundledDriver ? t("Bundled Cua Driver") : local.driverPath} {local.driverVersion ? ` · ${local.driverVersion}` : ""}
)} @@ -113,7 +114,7 @@ export function LinuxLocalControl() { className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-accent py-2 text-[13px] font-medium text-white hover:opacity-90 disabled:opacity-50" > {pending === "enable" ? : } - Enable local control (Beta) + {t("Enable local control (Beta)")} ) : ( <> @@ -125,7 +126,7 @@ export function LinuxLocalControl() { className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-raised py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50" > {pending === "retry" ? : } - Try again + {t("Try again")} )} )} @@ -146,7 +147,7 @@ export function LinuxLocalControl() { onClick={() => window.open(LINUX_GUIDE_URL, "_blank", "noopener,noreferrer")} className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-lg py-1.5 text-[11px] text-ink-secondary hover:bg-raised hover:text-ink" > - {capabilities.host.packaged ? "Local control guide" : "Driver setup and troubleshooting"}{" "} + {capabilities.host.packaged ? t("Local control guide") : t("Driver setup and troubleshooting")}{" "} diff --git a/src/components/LocalComputerAutoWarning.tsx b/src/components/LocalComputerAutoWarning.tsx index 82883cdae..a768ff75f 100644 --- a/src/components/LocalComputerAutoWarning.tsx +++ b/src/components/LocalComputerAutoWarning.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { AlertTriangle } from "lucide-react"; +import { useI18n } from "@/lib/i18n-context"; export const LOCAL_COMPUTER_AUTO_WARNING = "Auto mode will let this bot click, type, and run tools on this computer without asking first. Destructive and sensitive actions still stop. Continue only if you are watching."; @@ -13,6 +14,7 @@ export function LocalComputerAutoWarning({ onCancel: () => void; onConfirm: () => void; }) { + const { t } = useI18n(); const confirmRef = useRef(null); useEffect(() => { @@ -46,10 +48,10 @@ export function LocalComputerAutoWarning({

- Allow Auto mode on this computer? + {t("Allow Auto mode on this computer?")}

- {LOCAL_COMPUTER_AUTO_WARNING} + {t(LOCAL_COMPUTER_AUTO_WARNING)}

@@ -59,7 +61,7 @@ export function LocalComputerAutoWarning({ onClick={onCancel} className="rounded-xl px-4 py-2 text-[13px] text-ink-secondary hover:bg-raised hover:text-ink" > - Cancel + {t("Cancel")}
diff --git a/src/components/LocalComputerSection.tsx b/src/components/LocalComputerSection.tsx index a7c27c99c..820b8eb7a 100644 --- a/src/components/LocalComputerSection.tsx +++ b/src/components/LocalComputerSection.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import { Card, CommandLine } from "./SettingsPrimitives"; import { cn } from "@/lib/cn"; +import { useI18n } from "@/lib/i18n-context"; type Action = "pull" | "run" | "start" | "stop" | "remove" | "recreate"; @@ -101,6 +102,7 @@ function ActionButton({ } export function LocalComputerSection() { + const { t } = useI18n(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [pending, setPending] = useState(null); @@ -111,10 +113,10 @@ export function LocalComputerSection() { const refresh = useCallback(async (signal?: AbortSignal) => { const response = await fetch("/api/local-computer", { signal }); const body = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(body.error ?? `Status request failed (${response.status})`); + if (!response.ok) throw new Error(body.error ?? t("Status request failed ({status})", { status: response.status })); setStatus(body as Status); setError(null); - }, []); + }, [t]); useEffect(() => { let active = true; @@ -158,11 +160,11 @@ export function LocalComputerSection() { const act = async (action: Action) => { if ( action === "remove" && - !window.confirm("Delete the Local VM? Files and browser sign-ins in its durable workspace will remain.") + !window.confirm(t("Delete the Local VM? Files and browser sign-ins in its durable workspace will remain.")) ) return; if ( action === "recreate" && - !window.confirm("Replace the existing Local VM with the pinned image and safety limits? Files and browser sign-ins in its durable workspace will remain.") + !window.confirm(t("Replace the existing Local VM with the pinned image and safety limits? Files and browser sign-ins in its durable workspace will remain.")) ) return; setPending(action); setError(null); @@ -193,7 +195,7 @@ export function LocalComputerSection() { body: JSON.stringify({ localVm: { mode, maxInstances } }), }); const body = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(body.error ?? "Could not save the Local VM isolation policy"); + if (!response.ok) throw new Error(body.error ?? t("Could not save the Local VM isolation policy")); setStatus((current) => current ? { ...current, mode, max_instances: maxInstances } : current); await refresh(); } catch (e) { @@ -216,7 +218,7 @@ export function LocalComputerSection() { status?.persistence === "unsafe"), ); const unavailable = !loading && !status; - const host = status?.platform === "darwin" ? "Mac" : "computer"; + const host = status?.platform === "darwin" ? "Mac" : t("computer"); const perBot = status?.mode === "per-bot"; const perBotRuntimeUnsupported = perBot && status?.runtime === "container"; const headerReady = perBot ? Boolean(status?.daemonUp && status?.image && !perBotRuntimeUnsupported) : ready; @@ -224,10 +226,10 @@ export function LocalComputerSection() { return ( <>
{ready && !perBot && ( - Watch screen + {t("Watch screen")} )}
@@ -274,8 +276,8 @@ export function LocalComputerSection() {
{(["shared", "per-bot"] as const).map((mode, index) => ( @@ -290,17 +292,17 @@ export function LocalComputerSection() { status?.mode === mode ? "bg-raised text-ink" : "text-ink-secondary hover:text-ink", )} > - {mode === "shared" ? "Shared" : "Per bot"} + {mode === "shared" ? t("Shared") : t("Per bot")} ))}
-
Maximum per-bot desktops
-
Limits storage and host resource use; each running desktop may use up to 4 GB and 2 CPUs.
+
{t("Maximum per-bot desktops")}
+
{t("Limits storage and host resource use; each running desktop may use up to 4 GB and 2 CPUs.")}
- {policyPending &&
Saving…
} + {policyPending &&
{t("Saving…")}
}
- +
- +
- Podman and Colima are free. Docker Desktop may require a paid licence for larger companies and government use. + {t("Podman and Colima are free. Docker Desktop may require a paid licence for larger companies and government use.")}
{c?.install ? ( ) : ( - Open the Podman installation guide + {t("Open the Podman installation guide")} )}
{!status?.runtime ? null : c?.runtimeStart ? ( ) : ( -
Open the installed runtime and start its engine, then re-check.
+
{t("Open the installed runtime and start its engine, then re-check.")}
)}
- + {status?.daemonUp && ( - void act("pull")}>Prepare Cua desktop + void act("pull")}>{t("Prepare Cua desktop")} )} - {c?.pull &&
Show base-image download
} + {c?.pull &&
{t("Show base-image download")}
}
{perBot ? (
{perBotRuntimeUnsupported - ? "Apple container requires an explicit host port, so OpenMausBot will not guess or expose one. Install or start Docker or Podman for safe per-bot dynamic loopback ports." + ? t("Apple container requires an explicit host port, so OpenMausBot will not guess or expose one. Install or start Docker or Podman for safe per-bot dynamic loopback ports.") : <> - Choose Local VM for a bot, open that bot's Computer panel, then create its desktop there. OpenMausBot assigns a private workspace and an available loopback viewer port automatically. + {t("Choose Local VM for a bot, open that bot's Computer panel, then create its desktop there. OpenMausBot assigns a private workspace and an available loopback viewer port automatically.")} }
) : needsRecreate ? ( <>
- {status?.problem} + {status?.problem ? t(status.problem) : null}
{status?.image ? ( void act("recreate")} danger> - Delete and recreate + {t("Delete and recreate")} ) : ( -
Prepare the pinned Cua desktop above before replacing this VM.
+
{t("Prepare the pinned Cua desktop above before replacing this VM.")}
)} ) : status?.container === "stopped" ? ( - void act("start")}>Start Local VM + void act("start")}>{t("Start Local VM")} ) : status?.container === "running" ? ( -
Waiting for the desktop…
+
{t("Waiting for the desktop…")}
) : status?.image ? ( - void act("run")}>Create Local VM + void act("run")}>{t("Create Local VM")} ) : null} - {c?.run &&
Show command
} + {c?.run &&
{t("Show command")}
}
@@ -389,33 +391,33 @@ export function LocalComputerSection() {
- OpenMausBot could not inspect the container runtime. Re-check, or review the app logs. + {t("OpenMausBot could not inspect the container runtime. Re-check, or review the app logs.")}
)} {existing && (
{status?.container === "running" && ( void act("stop")}> - Stop + {t("Stop")} )} void act("remove")} danger> - {perBot ? "Delete legacy shared VM" : "Delete VM"} + {perBot ? t("Delete legacy shared VM") : t("Delete VM")}
)}
- Durable workspace: {status?.workspace_path ?? "not created"} ·{" "} - Cua Driver: {status?.driver_version ?? "0.20.0"} · Local image: {status?.image_ref ?? "not prepared"} - {status?.base_image_ref ? <> · Base: {status.base_image_ref} : null} + {t("Durable workspace")}: {status?.workspace_path ?? t("not created")} ·{" "} + Cua Driver: {status?.driver_version ?? "0.20.0"} · {t("Local image")}: {status?.image_ref ?? t("not prepared")} + {status?.base_image_ref ? <> · {t("Base image")}: {status.base_image_ref} : null}
diff --git a/src/components/LocalScreenPreview.tsx b/src/components/LocalScreenPreview.tsx index 00dcf6c38..fd11b1cd7 100644 --- a/src/components/LocalScreenPreview.tsx +++ b/src/components/LocalScreenPreview.tsx @@ -3,6 +3,7 @@ import { Loader2, Monitor, RotateCcw, Square } from "lucide-react"; import { requestScreenPreview, stopScreenPreview } from "@/lib/screen-preview"; import { useDesktopCapabilities } from "./DesktopCapabilities"; +import { useI18n } from "@/lib/i18n-context"; type PreviewPhase = | "idle" @@ -22,6 +23,7 @@ const phaseCopy: Record, strin }; export function LocalScreenPreview() { + const { t } = useI18n(); const { capabilities, ready } = useDesktopCapabilities(); const preview = capabilities.screenPreview; const isLinux = capabilities.host.platform === "linux"; @@ -122,14 +124,14 @@ export function LocalScreenPreview() {
- Preview this computer + {t("Preview this computer")}
- Preview only — starting a preview does not grant local control. + {t("Preview only — starting a preview does not grant local control.")}
- Preview only + {t("Preview only")}
@@ -139,7 +141,7 @@ export function LocalScreenPreview() { autoPlay muted playsInline - aria-label="Live preview of the selected screen" + aria-label={t("Live preview of the selected screen")} className={phase === "streaming" ? "h-full w-full object-contain" : "hidden"} /> {phase !== "streaming" && ( @@ -151,10 +153,10 @@ export function LocalScreenPreview() { )} {!ready - ? "Checking screen preview…" + ? t("Checking screen preview…") : preview.available - ? message - : phaseCopy.unavailable} + ? t(message) + : t(phaseCopy.unavailable)} )} @@ -163,10 +165,10 @@ export function LocalScreenPreview() { {phase === "streaming" && (
- {preview.interaction === "portal-picker" ? sourceLabel : "This computer"} + {preview.interaction === "portal-picker" ? sourceLabel : t("This computer")} - Sharing + {t("Sharing")}
)} @@ -191,14 +193,14 @@ export function LocalScreenPreview() { )} {phase === "requesting" - ? "Choose a screen…" + ? t("Choose a screen…") : phase === "streaming" - ? "Stop preview" + ? t("Stop preview") : retry - ? "Try again" + ? t("Try again") : preview.interaction === "portal-picker" - ? "Choose a screen" - : "Start preview"} + ? t("Choose a screen") + : t("Start preview")} ); diff --git a/src/components/MacLocalControl.tsx b/src/components/MacLocalControl.tsx index eb73ed3b4..c10b24be5 100644 --- a/src/components/MacLocalControl.tsx +++ b/src/components/MacLocalControl.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; import { AlertTriangle, Loader2, Shield } from "lucide-react"; import { useDesktopCapabilities } from "./DesktopCapabilities"; +import { useI18n } from "@/lib/i18n-context"; export function MacLocalControl() { + const { t } = useI18n(); const { capabilities } = useDesktopCapabilities(); const [pending, setPending] = useState(false); const [awaitingGrant, setAwaitingGrant] = useState(false); @@ -58,10 +60,9 @@ export function MacLocalControl() {
-
Allow control of this computer
+
{t("Allow control of this computer")}

- OpenMausBot needs Accessibility and Screen Recording in System Settings before a bot can - use this Mac. After you grant both, click Retry — macOS may still ask you to relaunch the app. + {t("OpenMausBot needs Accessibility and Screen Recording in System Settings before a bot can use this Mac. After you grant both, click Retry — macOS may still ask you to relaunch the app.")}

{error && (
@@ -76,7 +77,7 @@ export function MacLocalControl() { disabled={pending} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white hover:brightness-110 disabled:opacity-50" > - Open System Settings + {t("Open System Settings")}
diff --git a/src/components/PluginsPanel.tsx b/src/components/PluginsPanel.tsx index 08e81174c..a06b4c4d9 100644 --- a/src/components/PluginsPanel.tsx +++ b/src/components/PluginsPanel.tsx @@ -6,6 +6,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Loader2, RefreshCw, Search, X } from "lucide-react"; import { api, useStore } from "@/state/store"; import { cn } from "@/lib/cn"; +import { useI18n } from "@/lib/i18n-context"; + +type Translate = ReturnType["t"]; interface ToolkitCard { slug: string; @@ -29,9 +32,13 @@ export interface ConnectorStatus { export function disconnectAccountConfirmation( service: string, account: { id: string; alias?: string }, + t?: Translate, ) { const identity = account.alias ? `“${account.alias}” (${account.id})` : `“${account.id}”`; - return `Disconnect ${identity} from ${service}? Only this ${service} account will be revoked. Your other ${service} accounts will stay connected.`; + const source = "Disconnect {identity} from {service}? Only this {service} account will be revoked. Your other {service} accounts will stay connected."; + return t + ? t(source, { identity, service }) + : source.replaceAll("{identity}", identity).replaceAll("{service}", service); } export function mergeCurrentConnectorStatus( @@ -89,6 +96,7 @@ function ServiceIcon({ card }: { card: ToolkitCard }) { export function PluginsPanel() { const { dispatch } = useStore(); + const { t } = useI18n(); const dialogRef = useRef(null); const [cards, setCards] = useState(null); const [source, setSource] = useState<"api" | "curated">("curated"); @@ -238,7 +246,7 @@ export function PluginsPanel() { // asynchronous open, the visible Continue button retries from a direct // user gesture using the URL retained in pendingUrls. const opened = window.open("", "_blank"); - if (!opened) throw new Error("Your browser blocked the connection page. Click Continue to open it."); + if (!opened) throw new Error(t("Your browser blocked the connection page. Click Continue to open it.")); // Open a same-origin blank page first so the OAuth origin never receives // an opener reference, while a real null remains a reliable blocked signal. opened.opener = null; @@ -322,20 +330,20 @@ export function PluginsPanel() { >
-

Connected apps

-

Connect the apps your bots can use.

+

{t("Connected apps")}

+

{t("Connect the apps your bots can use.")}

-
+
@@ -382,7 +390,7 @@ export function PluginsPanel() { {!configured && (
- Connected apps are temporarily unavailable. You can retry after restarting, or configure your own connection service.{" "} + {t("Connected apps are temporarily unavailable. You can retry after restarting, or configure your own connection service.")}{" "}
)} {configured && source === "curated" && mode === "self-hosted" && (
- Showing featured apps.{" "} + {t("Showing featured apps.")}{" "} {" "} - for the full catalog. + {t("for the full catalog.")}
)} {error &&
{error}
} @@ -414,12 +422,12 @@ export function PluginsPanel() {
{cards === null ? (
- Loading catalog… + {t("Loading catalog…")}
) : (
- {tab === "connected" ? "Your connections" : search ? "Search results" : "Available apps"} + {t(tab === "connected" ? "Your connections" : search ? "Search results" : "Available apps")}
{visible.map((card) => { @@ -443,7 +451,7 @@ export function PluginsPanel() {
{card.label}
- {pending ? "Finish setup in your browser" : failed && !accounts.length ? "Authorization expired — try again" : card.blurb} + {pending ? t("Finish setup in your browser") : failed && !accounts.length ? t("Authorization expired — try again") : t(card.blurb)}
@@ -487,20 +495,20 @@ export function PluginsPanel() { {account.alias || account.id}
- {account.alias ? `${account.id} · ` : ""}{account.status.toLowerCase()} + {account.alias ? `${account.id} · ` : ""}{t(account.status.toLowerCase())}
); @@ -514,7 +522,7 @@ export function PluginsPanel() { event.preventDefault(); const alias = aliasDraft.trim(); if (!alias) { - setError("Enter a label for the account, such as work or personal."); + setError(t("Enter a label for the account, such as work or personal.")); return; } void connect(card.slug, alias); @@ -525,8 +533,8 @@ export function PluginsPanel() { value={aliasDraft} maxLength={64} onChange={(event) => setAliasDraft(event.target.value)} - placeholder="Account label (work, personal…)" - aria-label={`Label for another ${card.label} account`} + placeholder={t("Account label (work, personal…)")} + aria-label={t("Label for another {service} account", { service: card.label })} className="min-w-0 flex-1 rounded-lg bg-raised px-3 py-2 text-[12px] text-ink placeholder:text-ink-secondary focus:outline-none focus:ring-1 focus:ring-accent" /> )} @@ -547,10 +555,10 @@ export function PluginsPanel() { {cards !== null && visible.length === 0 && (
- {tab === "connected" ? "No connected apps yet" : "No apps found"} + {t(tab === "connected" ? "No connected apps yet" : "No apps found")}
- {tab === "connected" ? "Connect an app from Marketplace and it will appear here." : "Try a different search."} + {t(tab === "connected" ? "Connect an app from Marketplace and it will appear here." : "Try a different search.")}
)} diff --git a/src/components/RenameTitle.tsx b/src/components/RenameTitle.tsx index b9689cbdf..b7a19b72f 100644 --- a/src/components/RenameTitle.tsx +++ b/src/components/RenameTitle.tsx @@ -6,6 +6,7 @@ import { Pencil } from "lucide-react"; import { nextRename } from "@/lib/rename"; import { cn } from "@/lib/cn"; import { BOT_PROFILE_LIMITS } from "../../shared/bot-profile"; +import { useI18n } from "@/lib/i18n-context"; export function RenameTitle({ value, @@ -26,6 +27,7 @@ export function RenameTitle({ className?: string; inputClassName?: string; }) { + const { t } = useI18n(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -51,7 +53,7 @@ export function RenameTitle({ autoFocus value={draft} maxLength={BOT_PROFILE_LIMITS.name} - aria-label="Rename" + aria-label={t("Rename")} onFocus={(event) => event.currentTarget.select()} onChange={(event) => setDraft(event.target.value)} onBlur={() => finish(true)} @@ -87,9 +89,9 @@ export function RenameTitle({ @@ -99,8 +101,8 @@ export function RenameTitle({
{animated && } - {niceTime(item.at)} + {niceTime(item.at, locale)} · - {item.run?.triggerSource === "webhook" && <>Webhook·} + {item.run?.triggerSource === "webhook" && <>{t("Webhook")}·} - {status ? status.replace("waiting", "needs you") : bot.name} + {status ? t(status === "waiting" ? "Needs you" : status) : bot.name} {(item.routine?.runOn ?? item.run?.runOn) === "cloud" ? " · VM" : ""}
@@ -241,6 +247,7 @@ function CalendarGrid({ bots: Bot[]; onOpen: (item: CalendarItem) => void; }) { + const { locale, t } = useI18n(); const scrollRef = useRef(null); const today = startOfDay(Date.now()); const starts = Array.from({ length: days }, (_, index) => addDays(anchor, index)); @@ -260,7 +267,7 @@ function CalendarGrid({ const date = new Date(start); return (
-
{DAY_NAMES[date.getDay()]}
+
{t(DAY_NAMES[date.getDay()]!)}
{date.getDate()}
); @@ -270,7 +277,7 @@ function CalendarGrid({
{Array.from({ length: 24 }, (_, hour) => (
- {hour === 0 ? "" : new Date(2000, 0, 1, hour).toLocaleTimeString([], { hour: "numeric" })} + {hour === 0 ? "" : new Date(2000, 0, 1, hour).toLocaleTimeString(locale, { hour: "numeric" })}
))}
@@ -315,6 +322,7 @@ export function RoutineEditor({ onClose: () => void; }) { const { state, dispatch } = useStore(); + const { t } = useI18n(); const [name, setName] = useState(routine?.name ?? ""); const [prompt, setPrompt] = useState(routine?.prompt ?? ""); const [botId, setBotId] = useState(lockedBotId ?? routine?.botId ?? bots[0]?.id ?? ""); @@ -367,18 +375,18 @@ export function RoutineEditor({
-
{routine ? "Edit routine" : "New routine"}
-
Each run starts a fresh task for this agent. No cron syntax required.
+
{routine ? t("Edit routine") : t("New routine")}
+
{t("Each run starts a fresh task for this agent. No cron syntax required.")}
-
Where does it run?
+
{t("Where does it run?")}
{runOn === "cloud" && (
{cloudReady - ? "The VM wakes automatically for each run. Keep OpenMausBot running so its scheduler can launch the job." - : "Cloud VM needs a working Box API key in App Settings before this routine can run."} + ? t("The VM wakes automatically for each run. Keep OpenMausBot running so its scheduler can launch the job.") + : t("Cloud VM needs a working Box API key in App Settings before this routine can run.")}
)}
-
Who does it?
+
{t("Who does it?")}
{bots.map((bot) => ( ))}