diff --git a/server/index.ts b/server/index.ts index 3ca2ca4ac..7f3d7e262 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3620,7 +3620,7 @@ const server = createServer(async (req, res) => { } else return json(res, 400, { error: "pinnedMessageId must be a message id" }); } if (section !== undefined) patch.section = section ?? undefined; - if (body.chiefOfStaff === false) patch.chiefOfStaff = false; + if (body.chiefOfStaff !== undefined) patch.chiefOfStaff = body.chiefOfStaff; // per-bot gate on the workspace's connected apps (Composio) if (body.composio !== undefined) { if (typeof body.composio !== "boolean") return json(res, 400, { error: "composio must be true or false" }); @@ -3689,18 +3689,8 @@ const server = createServer(async (req, res) => { ?.adapter.interruptTurn(existingBot.threadId) .catch(() => {}); } - const chiefMovedSections = - Boolean(existingBot?.chiefOfStaff) && - body.chiefOfStaff !== false && - section !== undefined && - sectionKey(existingBot?.section) !== sectionKey(section); const bot = store.patchBot(m[1], patch); if (!bot) return json(res, 404, { error: "no such bot" }); - const chiefChanges = - body.chiefOfStaff === true || chiefMovedSections - ? store.setChiefOfStaff(bot.id) - : []; - if (chiefChanges === null) return json(res, 404, { error: "no such bot" }); return json(res, 200, { bot: wireBot(store.bot(bot.id)!) }); } diff --git a/server/store.test.ts b/server/store.test.ts index 181c88eab..9152ed4f8 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -238,6 +238,22 @@ describe("Store", () => { expect(reloaded.bot(second.id)?.chiefOfStaff).toBe(false); }); + it("patches a Chief section change and handoff in one store operation", () => { + const store = new Store(selection); + const work = store.createBot({ section: "Work" }); + const personal = store.createBot({ section: "Personal" }); + store.setChiefOfStaff(work.id); + store.setChiefOfStaff(personal.id); + + store.patchBot(work.id, { section: "Personal" }); + + expect(store.bot(work.id)?.chiefOfStaff).toBe(true); + expect(store.bot(personal.id)?.chiefOfStaff).toBe(false); + const reloaded = new Store(selection); + expect(reloaded.bot(work.id)?.chiefOfStaff).toBe(true); + expect(reloaded.bot(personal.id)?.chiefOfStaff).toBe(false); + }); + it("patchMessage merges card patches and returns null for unknown ids", () => { const store = new Store(selection); const bot = store.createBot(); diff --git a/server/store.ts b/server/store.ts index 09027cd0e..89dd8a433 100644 --- a/server/store.ts +++ b/server/store.ts @@ -861,8 +861,17 @@ export class Store { const bot = this.bot(id); if (!bot) return null; Object.assign(bot, patch); + const changed = [bot]; + if (bot.chiefOfStaff) { + const section = sectionKey(bot.section); + for (const candidate of this.bots) { + if (candidate.id === bot.id || !candidate.chiefOfStaff || sectionKey(candidate.section) !== section) continue; + candidate.chiefOfStaff = false; + changed.push(candidate); + } + } this.saveBots(); - this.emit({ type: "bot", botId: id }); + for (const candidate of changed) this.emit({ type: "bot", botId: candidate.id }); return bot; } diff --git a/src/App.tsx b/src/App.tsx index 480922445..a96bd1db8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,10 +16,12 @@ 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"; import { SkillRecorderPage } from "@/components/SkillRecorderPage"; 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 @@ -86,7 +88,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 f61184458..13f557759 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-control text-danger hover:bg-raised-hover" : "bg-control 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 4cf1e8f16..1c392306b 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-control 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-control text-ink" : "text-ink-secondary hover:bg-control/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-control", 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-control 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-control 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 9e17ad015..b466488b6 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 channel member an ElevenLabs voice before starting a channel call." - : "Choose an ElevenLabs voice before starting a call." + ? t("Give every channel member an ElevenLabs voice before starting a channel 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)}
)} @@ -534,19 +537,19 @@ function Call({ bot }: { bot: Bot }) { }} className="rounded-full border border-hairline/50 px-4 py-2 text-[13.5px] text-ink hover:bg-raised" > - Interrupt + {t("Interrupt")} )}
- Hold Control + Option to talk · Space interrupts · Esc hangs up + {t("Hold Control + Option to talk · Space interrupts · Esc hangs up")}
); diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 21b030c14..df3665997 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -62,33 +62,39 @@ import { resolveTranscriptWindow, tailWindowStart, } from "@/lib/transcript-window"; +import { useI18n } from "@/lib/i18n-context"; +import type { TranslationValues } from "@/lib/i18n"; import { timelineEvents } from "@/lib/taskTimeline"; +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. */ 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, locale)}
); } function TaskTimeline({ messages, busy }: { messages: Message[]; busy: boolean }) { + const { locale, t } = useI18n(); const [open, setOpen] = useState(false); const events = useMemo(() => timelineEvents(messages), [messages]); if (events.length === 0) return null; @@ -101,7 +107,9 @@ function TaskTimeline({ messages, busy }: { messages: Message[]; busy: boolean } aria-expanded={open} className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left text-[12.5px] text-ink-secondary hover:bg-raised/50 hover:text-ink" > - Execution timeline{busy ? " · running" : ""} + + {t("Execution timeline")}{busy ? <> · {t("running")} : null} + {open && ( @@ -121,9 +129,9 @@ function TaskTimeline({ messages, busy }: { messages: Message[]; busy: boolean } : "bg-ink-secondary", )} /> - {event.state}: - {event.label} - + {t(event.state)}: + {event.kind === "tool" ? event.label : t(event.label)} + ))} @@ -135,6 +143,7 @@ function TaskTimeline({ messages, busy }: { messages: Message[]; busy: boolean } /** 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 ( @@ -211,6 +221,7 @@ function ErrorRow({ onRetry?: () => void; setupInstance?: InstanceInfo; }) { + const { t } = useI18n(); return (
@@ -227,7 +238,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")} ) )} @@ -267,6 +278,7 @@ function BubbleEditor({ onSubmit: (text: string) => void; }) { const [draft, setDraft] = useState(initial); + const { t } = useI18n(); const ref = useRef(null); useEffect(() => { const el = ref.current; @@ -299,14 +311,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")}
@@ -333,6 +345,7 @@ function Bubble({ onRegenerate?: () => void; }) { const { dispatch } = useStore(); + const { locale, t } = useI18n(); const user = message.role === "user"; const [expanded, setExpanded] = useState(false); const text = message.text ?? ""; @@ -365,9 +378,9 @@ function Bubble({ {user && message.kind === "text" && !webhookView && !bot.busy && ( @@ -382,12 +395,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 ? : } @@ -407,12 +420,12 @@ function Bubble({
- Webhook task + {t("Webhook task")}
{webhookView.task}
{webhookView.payload && (
- View event payload + {t("View event payload")}
{webhookView.payload}
)} @@ -432,7 +445,7 @@ function Bubble({ > Attached image @@ -446,18 +459,18 @@ function Bubble({ {visibleText}
{message.steered && ( -
- sent mid-turn +
+ {t("sent mid-turn")}
)} {collapsible && ( )} {expanded && ( )} @@ -476,8 +489,8 @@ function Bubble({ {isLastBotText && !bot.busy && onRegenerate && (
{/* busy-gated so a flag stranded by a server restart shows nothing */} {user && message.queued && bot.busy && (
)} @@ -509,7 +522,7 @@ function Bubble({ onClick={() => switchTo(versions[versionIndex - 1])} disabled={versionIndex <= 0 || bot.busy} className="rounded p-0.5 hover:bg-raised hover:text-ink disabled:opacity-30 disabled:hover:bg-transparent" - title="Previous version" + title={t("Previous version")} > @@ -520,7 +533,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")} > @@ -533,6 +546,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 @@ -542,7 +556,7 @@ function ActivityChip({ message }: { message: Message }) {
)} @@ -738,10 +755,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 ( @@ -751,15 +769,15 @@ function PinnedBanner({ )} @@ -1014,19 +1033,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")} > @@ -1089,7 +1108,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 && (
@@ -1097,7 +1116,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 })}
)} @@ -1120,7 +1139,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 })} )} @@ -1128,7 +1147,7 @@ export function ChatView({ bot }: { bot: Bot }) {
- Setting up this bot's computer… + {t("Setting up this bot's computer…")}
)} @@ -1156,10 +1175,10 @@ export function ChatView({ bot }: { bot: Bot }) { {!follow && ( )} @@ -1212,6 +1231,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; @@ -1223,7 +1243,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 aadafd30b..dfb816867 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")} ))}
@@ -410,22 +410,27 @@ export function Composer({ disabled={Boolean(approval) || locked} placeholder={ locked - ? "Finish room setup to start chatting" + ? t("Finish room setup to start chatting") : 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 ?? []), { + name: defaultResponderName(group, members ?? []) ?? t("Lead"), + }), + }) + : 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 && !locked && ( @@ -434,9 +439,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")} > @@ -444,14 +449,14 @@ export function Composer({ {!locked && !busy && !hasContent && capabilities.dictation.available && ( @@ -459,8 +464,8 @@ export function Composer({ {hasContent && !locked && ( @@ -660,7 +671,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { panelView === "computer" ? "bg-control text-ink" : "text-ink-secondary hover:text-ink", )} > - Computer + {t("Computer")}
) : ( - Computer + {t("Computer")} )} )} {phase === "vm-unavailable" && ( @@ -745,14 +756,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 +772,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { onClick={openConnectionSettings} className="mt-1 rounded-lg bg-control 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 +782,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { className="mt-1 rounded-lg bg-control 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 +791,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 +823,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 +849,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 +868,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-control 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 +879,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-control 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 +890,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 +906,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { } disabled={controlPending || pending === "join"} className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-control 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 +919,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-control 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 +927,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-control 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 +942,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 +980,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { ); })() @@ -1006,7 +1015,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
- Scheduled tasks + {t("Scheduled tasks")}
{botRoutines.length > 0 && ( @@ -1015,12 +1024,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 +1039,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 +1055,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {routine.name} - {routineScheduleLabel(routine)}{routine.runOn === "cloud" ? " · runs on VM" : ""} + {routineScheduleLabel(routine, locale, t)}{routine.runOn === "cloud" && <> · {t("runs on VM")}} - {nextRunLabel(routine.nextRunAt)} + {nextRunLabel(routine.nextRunAt, locale, t)} ))}
@@ -1060,15 +1069,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 4ac2953dc..ba05d24e3 100644 --- a/src/components/GroupView.tsx +++ b/src/components/GroupView.tsx @@ -16,7 +16,7 @@ import { } from "@/state/store"; import { MausAvatar } from "./Avatar"; import { normalizeState } from "@/lib/mascot"; -import { effectiveDefaultResponder, groupResponseHint } from "@/lib/group-routing"; +import { defaultResponderName, effectiveDefaultResponder, groupResponseHint } from "@/lib/group-routing"; import { ChatMarkdown } from "./ChatMarkdown"; import { Composer } from "./Composer"; import { ConnectorCard } from "./ConnectorCard"; @@ -37,15 +37,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. */ @@ -68,6 +72,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 ( @@ -97,6 +102,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 ( @@ -145,7 +151,7 @@ const Transcript = memo(function Transcript({ {!user && } - {formatTime(m.at)} + {formatTime(m.at, locale)} @@ -156,7 +162,7 @@ const Transcript = memo(function Transcript({
{newDay && (
- {dayLabel(m.at)} {formatTime(m.at)} + {dayLabel(m.at, locale, t)} {formatTime(m.at, locale)}
)} {!user && m.from && newCluster && ( @@ -184,15 +190,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 channel member; @mentions override this" + ? t("Plain messages go to every channel 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; @@ -205,21 +212,21 @@ function DefaultResponderSelect({ group, members }: { group: Group; members: Bot return (
(null); const [error, setError] = useState(null); @@ -268,28 +276,28 @@ function RoomWorkingFolder({ group }: { group: Group }) { return (
-
Working folder
-
Where every bot in this channel runs its shell and file tools.
+
{t("Working folder")}
+
{t("Where every bot in this channel 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 channel's first turn. Create a new channel and choose its folder before sending the first message to work somewhere else. + {t("Fixed after this channel's first turn. Create a new channel 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 && ( )}
@@ -304,12 +312,12 @@ function RoomWorkingFolder({ group }: { group: Group }) { > setDraft(e.target.value)} /> )} @@ -322,13 +330,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 ( @@ -339,7 +348,7 @@ function RoomWorkingFolderChip({ group, onToggle }: { group: Group; onToggle: () )}
- Default responder -

Choose who answers when nobody is mentioned.

-
+ {t("Default responder")} +

{t("Choose who answers when nobody is mentioned.")}

+
{behavior === "lead" && leadPickerOpen && (
-
Choose a lead
-
Plain messages go to this teammate.
+
{t("Choose a lead")}
+
{t("Plain messages go to this teammate.")}
{members.map((member) => { @@ -624,9 +634,9 @@ function RoomSetup({ group, members }: { group: Group; members: Bot[] }) { > {behavior === "everyone" && } - Everyone responds + {t("Everyone responds")} - All room members + {t("All room members")}