From c2adf7320c525530a3b8d313979bd05bb68b8b4b Mon Sep 17 00:00:00 2001 From: asklokesh Date: Thu, 23 Jul 2026 16:44:21 -0400 Subject: [PATCH 001/211] Fix automation scheduling: DST-correct 'local' times and off-by-one weekday labels Two schedule bugs, both in the default-local, everyday path. 1. DST: one-time 'local' tasks fired at the wrong wall-clock across a DST boundary (coworker/automation/store.py) _tz("local") returned datetime.now().astimezone().tzinfo, a FIXED offset equal to whatever was in effect at compute time. A "once" task created in summer (EDT, -04:00) for a winter date (EST, -05:00) bound the naive datetime to -04:00, so 08:00 fired at 07:00 - an hour early - and for a one-shot task next_run is computed once at creation and never self-heals. Fix: _tz returns None for 'local' (and for an unknown IANA name) instead of a frozen offset, and the naive datetime is left naive. datetime.timestamp() and croniter over a naive local base apply the correct local DST offset for the actual fire date via the C library. Named IANA zones still anchor via ZoneInfo. No new dependency. 2. Weekday labels were a day late (coworker/automation/models.py) Cron day-of-week is 0/7=Sunday, 1=Monday..6=Saturday, but _DOW started at Monday, so `_DOW[int(dow) % 7]` rendered dow 1 (Monday) as "Tuesday" and dow 0 (Sunday) as "Monday" on every weekly automation card. Reordered the list to start at Sunday to match cron semantics. Regression tests added; an existing assertion that encoded the off-by-one ("Monday" for cron dow 0) is corrected to "Sunday". --- coworker/automation/models.py | 5 ++++- coworker/automation/store.py | 24 ++++++++++++++------- tests/test_automation.py | 39 ++++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/coworker/automation/models.py b/coworker/automation/models.py index 4118e88af2..186e158fd7 100644 --- a/coworker/automation/models.py +++ b/coworker/automation/models.py @@ -10,7 +10,10 @@ from dataclasses import dataclass, field from typing import Any, Optional -_DOW = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] +# Indexed by cron day-of-week: 0 and 7 are Sunday, 1 is Monday … 6 is Saturday. Must start +# at Sunday — indexing a Monday-first list by the cron dow labelled every weekly schedule one +# day late (dow 1/Monday rendered "Tuesday", dow 0/Sunday rendered "Monday"). +_DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] def _now() -> float: diff --git a/coworker/automation/store.py b/coworker/automation/store.py index 116cec756f..3c0a558a33 100644 --- a/coworker/automation/store.py +++ b/coworker/automation/store.py @@ -32,8 +32,12 @@ def compute_next_run( dt = datetime.fromisoformat(sched.fire_at) except ValueError: return None - if dt.tzinfo is None: - dt = dt.replace(tzinfo=_tz(sched.timezone)) + tz = _tz(sched.timezone) + if dt.tzinfo is None and tz is not None: + dt = dt.replace(tzinfo=tz) + # Naive local dt: datetime.timestamp() interprets it in the machine's zone and is + # DST-aware for the actual fire DATE (via the C library), so a "once" task set in + # summer for a winter date fires at the right wall-clock instead of an hour off. ts = dt.timestamp() return ts if (task.run_count == 0 and ts > now) else None # cron @@ -43,19 +47,25 @@ def compute_next_run( return None if task.max_runs is not None and task.run_count >= task.max_runs: return None - base = datetime.fromtimestamp(now, tz=_tz(sched.timezone)) + tz = _tz(sched.timezone) + # Local: a naive base makes croniter compute in local wall-clock and .timestamp() apply + # the correct DST offset per occurrence. A named zone anchors the base in that zone. + base = datetime.fromtimestamp(now) if tz is None else datetime.fromtimestamp(now, tz=tz) return croniter(sched.cron, base).get_next(datetime).timestamp() def _tz(name: str): - """Resolve a schedule timezone. 'local'/empty → the machine's local zone (right for a - local-first tool: when you say '8:05 PM' you mean *your* clock, not UTC).""" + """Resolve a schedule timezone to a DST-aware tzinfo, or None for the machine's local + zone. None (not a fixed-offset tzinfo) is deliberate: naive datetimes let .timestamp()/ + the C library apply local DST at the fire date. A frozen `datetime.now().astimezone()` + offset baked in whatever offset was in effect at compute time and misfired across a DST + boundary. An unknown IANA name falls back to local (None) rather than raising.""" if not name or name.lower() == "local": - return datetime.now().astimezone().tzinfo + return None try: return ZoneInfo(name) except Exception: - return datetime.now().astimezone().tzinfo + return None def _epoch_now() -> float: diff --git a/tests/test_automation.py b/tests/test_automation.py index 4fa38a4347..80de0e8578 100644 --- a/tests/test_automation.py +++ b/tests/test_automation.py @@ -34,11 +34,27 @@ def _task(**kw) -> ScheduledTask: # -- model / schedule ---------------------------------------------------------- def test_schedule_human(): assert Schedule("cron", cron="10 19 * * *").human() == "Every day at ~7:10 PM" - assert "Monday" in Schedule("cron", cron="0 9 * * 0").human() + # Cron day-of-week: 0 (and 7) = Sunday, 1 = Monday … 6 = Saturday. + assert "Sunday" in Schedule("cron", cron="0 9 * * 0").human() + assert "Monday" in Schedule("cron", cron="0 9 * * 1").human() + assert "Saturday" in Schedule("cron", cron="0 9 * * 6").human() + assert "Sunday" in Schedule("cron", cron="0 9 * * 7").human() # 7 also Sunday assert Schedule("cron", cron="0 9 5 * *").human() == "Monthly on day 5 at ~9:00 AM" assert Schedule("once", fire_at="2026-07-01T09:00:00").human().startswith("Once at") +def test_weekly_label_matches_croniter_fire_day(): + """The rendered weekday must equal the day croniter actually fires on (regression: a + Monday-first name list indexed by cron dow labelled every weekly schedule a day late).""" + from croniter import croniter + + for dow in range(7): + label = Schedule("cron", cron=f"0 9 * * {dow}").human() + base = datetime(2026, 7, 20, 0, 0) # a Monday + fires = croniter(f"0 9 * * {dow}", base).get_next(datetime) + assert fires.strftime("%A") in label, (dow, label, fires.strftime("%A")) + + def test_task_gets_own_thread_id(): t = _task() assert t.task_session_id == f"__task__{t.id}" @@ -69,6 +85,27 @@ def test_compute_next_run_once_in_past_is_none(): assert compute_next_run(t) is None +def test_compute_next_run_once_local_is_dst_aware(monkeypatch): + """A one-time 'local' task set while EDT is in effect but firing on a winter EST date must + fire at the requested wall-clock, not an hour off. Binding the naive datetime to the + offset in effect at compute time (the old bug) misfired by the DST delta.""" + import time as _time + + monkeypatch.setenv("TZ", "America/New_York") + _time.tzset() + try: + # Compute "now" during summer (EDT, -04:00); the task fires on a winter date (EST). + summer_now = datetime(2026, 7, 1, 12, 0).timestamp() + t = _task(schedule=Schedule(kind="once", fire_at="2026-12-25T08:00:00")) + nxt = compute_next_run(t, after=summer_now) + fires_local = datetime.fromtimestamp(nxt) + assert (fires_local.hour, fires_local.minute) == (8, 0) + assert fires_local.date() == datetime(2026, 12, 25, 8, 0).date() + finally: + monkeypatch.delenv("TZ", raising=False) + _time.tzset() + + # -- store --------------------------------------------------------------------- def test_store_crud_and_due(tmp_path): store = TaskStore(tmp_path / "auto.db") From 03c4a16f95adcbb9578971836a24343e534f0e75 Mon Sep 17 00:00:00 2001 From: Saidheerajgollu <158853598+Saidheerajgollu@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:09:28 -0700 Subject: [PATCH 002/211] Reject path-traversal session ids in the conversation store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session id is joined straight into a filesystem path (`.jsonl`), and session ids come from client-controlled surfaces — the `/ws/session/{session_id}` WebSocket route and REST paths all take the id from the URL. Nothing validated it, so an id like `../evil` escaped the conversations directory: `ConversationStore.save(SessionRecord( session_id="../evil", ...))` wrote `evil.jsonl` one level ABOVE `conversations/`, and a crafted id could clobber or place files elsewhere under the state dir. `_file()` — the single chokepoint every conversation-file path flows through — now rejects ids that aren't a safe single path component and confirms the resolved path stays inside `conv_dir`. The accepted charset (`[A-Za-z0-9_-]{1,128}`) is a superset of every id the app generates (uuid4 hex, and the `__run__`/`__task__`-prefixed automation threads), so no legitimate session is affected; `load()` of an unknown or unsafe id still returns None (the DB lookup misses before any file IO), not an error. The public `is_safe_session_id` helper is exported so the one other site that turns a session id into a path — `_provision_scratch` in the server manager — can reuse the same guard in a follow-up. --- coworker/conversations.py | 24 +++++++++- tests/test_conversation_store.py | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/test_conversation_store.py diff --git a/coworker/conversations.py b/coworker/conversations.py index fd2131bfbe..f97c80116c 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -12,6 +12,7 @@ import json import os +import re import sqlite3 import threading from pathlib import Path @@ -19,6 +20,19 @@ from .sessions import SessionRecord +# A session id becomes a filename (`.jsonl`) and a scratch dir name, so it must be a +# single, benign path component. Every legitimate id is hex or a `__run__`/`__task__`- +# prefixed hex string, so this charset is a superset of what we generate; it excludes the +# path separators and dots (`/`, `\`, `..`) a client-supplied id would need to escape the +# store. Session ids arrive from client-controlled surfaces (the `/ws/session/{id}` route, +# REST paths), so without this an id like `../../evil` writes `/evil.jsonl` outside +# `conversations/`. +_SAFE_SESSION_ID = re.compile(r"\A[A-Za-z0-9_-]{1,128}\Z") + + +def is_safe_session_id(sid: str) -> bool: + return bool(isinstance(sid, str) and _SAFE_SESSION_ID.match(sid)) + def _load_roots(raw: Optional[str]) -> list[dict]: if not raw: @@ -105,7 +119,15 @@ def __init__(self, base_dir: str | Path) -> None: # -- file helpers ----------------------------------------------------------- def _file(self, sid: str) -> Path: - return self.conv_dir / f"{sid}.jsonl" + # Single chokepoint for every conversation-file path. Reject ids that aren't a + # safe path component, then confirm the resolved path stays inside conv_dir — so + # a crafted id can never read or clobber a file outside the store. + if not is_safe_session_id(sid): + raise ValueError(f"unsafe session id: {sid!r}") + path = (self.conv_dir / f"{sid}.jsonl").resolve() + if path.parent != self.conv_dir.resolve(): + raise ValueError(f"unsafe session id: {sid!r}") + return path def _read_jsonl(self, sid: str) -> Optional[list[dict]]: path = self._file(sid) diff --git a/tests/test_conversation_store.py b/tests/test_conversation_store.py new file mode 100644 index 0000000000..4413ac7435 --- /dev/null +++ b/tests/test_conversation_store.py @@ -0,0 +1,80 @@ +"""ConversationStore: session id path-traversal hardening. + +A session id becomes a filename (".jsonl"); ids arrive from client-controlled +surfaces (the /ws/session/{id} route, REST paths), so a crafted id must never let a +write or read escape the conversations/ directory. +""" + +from __future__ import annotations + +import pytest + +from coworker.conversations import ConversationStore, is_safe_session_id +from coworker.sessions import SessionRecord + + +def test_is_safe_session_id(): + # Every id shape the app actually generates is accepted. + for ok in ( + "0123456789abcdef0123456789abcdef", # uuid4().hex + "abc123def456", # uuid4().hex[:12] + "__run__run-abcdef1234", # automation run thread + "__task__task-0123456789", # automation task thread + ): + assert is_safe_session_id(ok), ok + # Anything that could escape a single path component is rejected. + for bad in ( + "../evil", + "../../etc/passwd", + "a/b", + "a\\b", + "..", + ".", + "with space", + "dot.dot", + "", + "x" * 129, # over the length cap + ): + assert not is_safe_session_id(bad), bad + + +def test_save_rejects_traversal_id_without_writing_outside(tmp_path): + """The verified vuln: saving a record with '../evil' used to create 'evil.jsonl' + OUTSIDE the conversations dir. It must raise and write nothing.""" + store = ConversationStore(tmp_path / "state") + rec = SessionRecord( + session_id="../evil", + workspace=str(tmp_path), + model="m", + mode="interactive", + messages=[{"role": "user", "content": "hi"}], + ) + with pytest.raises(ValueError): + store.save(rec) + # Nothing landed outside conversations/ (the previous behavior wrote here). + assert not (tmp_path / "state" / "evil.jsonl").exists() + assert list((tmp_path / "state" / "conversations").glob("*.jsonl")) == [] + + +def test_load_of_unknown_or_unsafe_id_is_none_not_crash(tmp_path): + store = ConversationStore(tmp_path / "state") + # A normal missing id: no DB row, returns None (never touches the filesystem). + assert store.load("deadbeef") is None + # An unsafe id also has no DB row, so load short-circuits to None before any file IO. + assert store.load("../evil") is None + + +def test_round_trip_with_valid_id_still_works(tmp_path): + store = ConversationStore(tmp_path / "state") + rec = SessionRecord( + session_id="abc123def456", + workspace=str(tmp_path), + model="m", + mode="interactive", + messages=[{"role": "user", "content": "hello"}], + ) + store.save(rec) + loaded = store.load("abc123def456") + assert loaded is not None + assert loaded.messages[0]["content"] == "hello" + assert (tmp_path / "state" / "conversations" / "abc123def456.jsonl").is_file() From 8c199ffdda42eea9d25a38bbf9e79a4f9499d756 Mon Sep 17 00:00:00 2001 From: Saidheerajgollu <158853598+Saidheerajgollu@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:13:07 -0700 Subject: [PATCH 003/211] Tolerate a corrupt line when loading a conversation .jsonl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_read_jsonl` parsed every line with a bare `json.loads` inside a list comprehension, so a single malformed line raised `JSONDecodeError` and took the whole `load()` down. An append interrupted mid-write (process crash, full disk) leaves exactly that: one truncated trailing line — and from then on every surface that opens the session errors on load, with no way back short of hand-editing the file. The session is effectively bricked, including its recoverable history. Skip unparseable lines and keep the good messages. This matches how the rest of this module already treats JSON (the inline-blob and roots/grants loaders all swallow `JSONDecodeError` and fall back) — `_read_jsonl` was the one strict outlier on the hot session-load path. --- coworker/conversations.py | 20 +++++-- tests/test_conversation_jsonl_robustness.py | 62 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 tests/test_conversation_jsonl_robustness.py diff --git a/coworker/conversations.py b/coworker/conversations.py index fd2131bfbe..674d2c4278 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -111,11 +111,21 @@ def _read_jsonl(self, sid: str) -> Optional[list[dict]]: path = self._file(sid) if not path.exists(): return None - return [ - json.loads(line) - for line in path.read_text(encoding="utf-8").splitlines() - if line.strip() - ] + # Tolerate a corrupt/truncated line rather than failing the whole load. An append + # interrupted mid-write (crash, disk full) leaves one malformed trailing line; a + # bare `json.loads` in a comprehension would raise JSONDecodeError and make load() + # throw every time thereafter — bricking that session on every surface that opens + # it. Skip the bad line(s) and keep the recoverable history. (Every other JSON read + # in this module is already tolerant; this one was the outlier.) + messages: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + messages.append(json.loads(line)) + except json.JSONDecodeError: + continue + return messages def _count(self, sid: str) -> int: path = self._file(sid) diff --git a/tests/test_conversation_jsonl_robustness.py b/tests/test_conversation_jsonl_robustness.py new file mode 100644 index 0000000000..30a553d361 --- /dev/null +++ b/tests/test_conversation_jsonl_robustness.py @@ -0,0 +1,62 @@ +"""ConversationStore: a corrupt line in a .jsonl must not brick session load. + +An append interrupted mid-write (crash, full disk) leaves one malformed trailing line. +load() must skip it and return the recoverable history, not raise on every open. +""" + +from __future__ import annotations + +from coworker.conversations import ConversationStore +from coworker.sessions import SessionRecord + + +def _seed(store: ConversationStore, sid: str, n: int) -> None: + store.save( + SessionRecord( + session_id=sid, + workspace="/tmp", + model="m", + mode="interactive", + messages=[{"role": "user", "content": f"m{i}"} for i in range(n)], + ) + ) + + +def test_load_skips_a_corrupt_trailing_line(tmp_path): + store = ConversationStore(tmp_path / "state") + sid = "abc123def456" + _seed(store, sid, 2) + + # Simulate a torn write: append a truncated JSON line to the session's log. + jsonl = tmp_path / "state" / "conversations" / f"{sid}.jsonl" + with open(jsonl, "a", encoding="utf-8") as f: + f.write('{"role": "user", "content": "unterm\n') # no closing brace/quote + + loaded = store.load(sid) # must not raise + assert loaded is not None + # The two good messages survive; the corrupt line is dropped. + assert [m["content"] for m in loaded.messages] == ["m0", "m1"] + + +def test_load_skips_a_corrupt_middle_line(tmp_path): + store = ConversationStore(tmp_path / "state") + sid = "def456abc123" + jsonl = tmp_path / "state" / "conversations" / f"{sid}.jsonl" + jsonl.parent.mkdir(parents=True, exist_ok=True) + jsonl.write_text( + '{"role": "user", "content": "first"}\n' + "not json at all\n" + '{"role": "assistant", "content": "third"}\n', + encoding="utf-8", + ) + # Register the session in the index so load() reaches the .jsonl. + store._conn.execute( + "INSERT INTO sessions (session_id, workspace, model, mode, title, n_msgs) " + "VALUES (?, '/tmp', 'm', 'interactive', 't', 2)", + (sid,), + ) + store._conn.commit() + + loaded = store.load(sid) + assert loaded is not None + assert [m["content"] for m in loaded.messages] == ["first", "third"] From 2b588fb8cc3f4524f096834bac7f03e5528f9e0b Mon Sep 17 00:00:00 2001 From: SEUNGWOO LEE <69357689+lifrary@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:28:16 +0900 Subject: [PATCH 004/211] Make the conversation-log shrink rewrite atomic ConversationStore.save() appends new messages on the common path, but when a turn reduces the message count (context compaction / summarization) it rewrites the whole .jsonl with open(..., "w"), which truncates the file at open(). A crash mid-rewrite then leaves a truncated or empty log, permanently losing the conversation history. Write the reduced log to a temp file and replace() it in one atomic step -- the same tmp-then-replace pattern subscriptions.ChannelBuffer._save() already uses. Add tests/test_conversation_atomicity.py covering the crash path (history preserved when the write fails partway) and the happy path (reduced set persisted, no leftover temp file). --- coworker/conversations.py | 9 ++- tests/test_conversation_atomicity.py | 96 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/test_conversation_atomicity.py diff --git a/coworker/conversations.py b/coworker/conversations.py index fd2131bfbe..73343b1d4d 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -184,9 +184,16 @@ def save(self, record: SessionRecord) -> None: if len(record.messages) > existing: self._append(sid, record.messages[existing:]) elif len(record.messages) < existing: # rare; not append-only - with open(self._file(sid), "w", encoding="utf-8") as f: + # Atomic rewrite: write the full log to a temp file, then replace in one + # step. An in-place open(..., "w") truncates the file immediately, so a + # crash mid-rewrite would erase the conversation history (same + # tmp-then-replace pattern as subscriptions.ChannelBuffer._save). + path = self._file(sid) + tmp = path.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as f: for m in record.messages: f.write(json.dumps(m) + "\n") + tmp.replace(path) title = record.title or title_from(record.messages) self._conn.execute( diff --git a/tests/test_conversation_atomicity.py b/tests/test_conversation_atomicity.py new file mode 100644 index 0000000000..d2279925d5 --- /dev/null +++ b/tests/test_conversation_atomicity.py @@ -0,0 +1,96 @@ +"""Crash-safety of ConversationStore's shrink rewrite (write-side atomicity). + +`save()` appends new messages on the common path, but when a turn *reduces* the message +count (context compaction / summarization) it rewrites the whole ``.jsonl``. That rewrite +must be atomic: a crash partway through must not truncate or erase the existing history — +the most valuable data the app holds. + +This is the write-side complement to read-side corrupt-line tolerance: prevent the +truncated file rather than cope with one after the fact. +""" + +from __future__ import annotations + +import os + +import pytest + +from coworker.conversations import ConversationStore +from coworker.sessions import SessionRecord + + +def _rec(sid: str, n: int) -> SessionRecord: + return SessionRecord( + session_id=sid, + workspace="/w", + model="m", + mode="interactive", + messages=[{"role": "user", "content": f"msg-{i}"} for i in range(n)], + ) + + +def test_shrink_rewrite_preserves_history_when_write_crashes(tmp_path, monkeypatch): + store = ConversationStore(tmp_path) + sid = "sess1" + + # Persist a 5-message history via the append path. + store.save(_rec(sid, 5)) + assert len(store.load(sid).messages) == 5 + + # Force a crash partway through the shrink rewrite (5 -> 2): the write fails after the + # first line. A non-atomic in-place open(..., "w") truncates the real file at open() and + # the crash then erases the history; an atomic tmp-then-replace leaves the original + # untouched because the swap never happens. + import coworker.conversations as conv + + real_open = open + writes = {"n": 0} + + class _CrashingFile: + def __init__(self, fh): + self._fh = fh + + def __enter__(self): + return self + + def __exit__(self, *exc): + self._fh.close() + return False + + def write(self, s): + writes["n"] += 1 + if writes["n"] >= 2: + raise OSError("simulated crash mid-write") + return self._fh.write(s) + + def crashing_open(file, mode="r", *args, **kwargs): + fh = real_open(file, mode, *args, **kwargs) + if "w" in mode and os.path.basename(str(file)).startswith(sid): + return _CrashingFile(fh) + return fh + + monkeypatch.setattr(conv, "open", crashing_open, raising=False) + + with pytest.raises(OSError): + store.save(_rec(sid, 2)) + + monkeypatch.undo() + + # The interrupted shrink must not have destroyed the existing history. + assert len(store.load(sid).messages) == 5 + + +def test_shrink_rewrite_persists_reduced_history(tmp_path): + """The (non-crash) shrink path still rewrites the log to exactly the reduced set.""" + store = ConversationStore(tmp_path) + sid = "sess2" + + store.save(_rec(sid, 5)) + assert len(store.load(sid).messages) == 5 + + store.save(_rec(sid, 3)) # shrink 5 -> 3 + reloaded = store.load(sid) + assert [m["content"] for m in reloaded.messages] == ["msg-0", "msg-1", "msg-2"] + + # No leftover temp file next to the conversation log. + assert not (tmp_path / "conversations" / f"{sid}.tmp").exists() From 6ff0f0fac96954d374b9859d452e2f5bfb02da47 Mon Sep 17 00:00:00 2001 From: malin1997 Date: Sat, 25 Jul 2026 22:19:40 +0800 Subject: [PATCH 005/211] gui: add i18n infrastructure with English and Chinese locales Introduces react-i18next across the GUI (fixes the frontend portion of #121): all user-facing strings in ~50 components move from hardcoded English literals to t() keys, with complete en and zh-Hans locale files under src/locales/. English remains the default; the language follows the system locale unless the user picks one explicitly in the new Settings > General language switcher. Vitest initializes i18n with the English resources so existing assertions keep passing. --- surfaces/gui/package-lock.json | 78 +- surfaces/gui/package.json | 2 + surfaces/gui/src/App.tsx | 85 +- surfaces/gui/src/components/AccessSection.tsx | 71 +- surfaces/gui/src/components/AddFolderForm.tsx | 16 +- surfaces/gui/src/components/ApprovalCard.tsx | 94 +- surfaces/gui/src/components/AuditView.tsx | 23 +- .../src/components/AutomationQuickstart.tsx | 235 ++-- surfaces/gui/src/components/Composer.tsx | 96 +- .../src/components/ConnectorMessageCard.tsx | 18 +- .../src/components/DirectoryRequestCard.tsx | 14 +- surfaces/gui/src/components/FolderGate.tsx | 22 +- surfaces/gui/src/components/GalleryModal.tsx | 92 +- .../gui/src/components/InboxConfigure.tsx | 67 +- surfaces/gui/src/components/InboxItemCard.tsx | 31 +- surfaces/gui/src/components/InboxView.tsx | 37 +- .../gui/src/components/IntegrationsView.tsx | 20 +- surfaces/gui/src/components/ManageTabs.tsx | 176 +-- surfaces/gui/src/components/Markdown.tsx | 4 +- .../gui/src/components/ModelChecklist.tsx | 12 +- surfaces/gui/src/components/Onboarding.tsx | 90 +- surfaces/gui/src/components/PersonaHero.tsx | 5 +- surfaces/gui/src/components/PersonaView.tsx | 44 +- surfaces/gui/src/components/PersonasTab.tsx | 67 +- surfaces/gui/src/components/PlanCard.tsx | 16 +- surfaces/gui/src/components/RightRail.tsx | 61 +- surfaces/gui/src/components/RootRow.tsx | 14 +- surfaces/gui/src/components/ScheduledView.tsx | 106 +- surfaces/gui/src/components/SearchModal.tsx | 10 +- surfaces/gui/src/components/SelectMenu.tsx | 4 +- surfaces/gui/src/components/SessionIntro.tsx | 48 +- surfaces/gui/src/components/SettingsView.tsx | 244 ++-- surfaces/gui/src/components/Sidebar.tsx | 141 +- .../gui/src/components/SubscriptionsChip.tsx | 19 +- surfaces/gui/src/components/TodoPanel.tsx | 4 +- surfaces/gui/src/components/Transcript.tsx | 47 +- surfaces/gui/src/components/UpdateBanner.tsx | 12 +- .../src/components/WorkspaceTrustPrompt.tsx | 14 +- .../components/connectors/AccountsDetail.tsx | 24 +- .../connectors/AddConnectionModal.tsx | 86 +- .../components/connectors/AvailableDetail.tsx | 24 +- .../components/connectors/CalendarDetail.tsx | 32 +- .../src/components/connectors/CloudSignIn.tsx | 7 +- .../components/connectors/ConnectorsList.tsx | 50 +- .../connectors/ConnectorsSection.tsx | 11 +- .../components/connectors/GithubDetail.tsx | 78 +- .../src/components/connectors/GmailDetail.tsx | 46 +- .../components/connectors/HubSpotDetail.tsx | 42 +- .../src/components/connectors/SlackDetail.tsx | 117 +- .../components/connectors/SlackHowItWorks.tsx | 168 +-- .../components/connectors/ToolsDisclosure.tsx | 10 +- surfaces/gui/src/i18n.ts | 74 + surfaces/gui/src/locales/en.json | 1227 +++++++++++++++++ surfaces/gui/src/locales/zh.json | 1225 ++++++++++++++++ surfaces/gui/src/main.tsx | 14 +- surfaces/gui/src/providers/ProviderSetup.tsx | 53 +- surfaces/gui/src/test-setup.ts | 14 + surfaces/gui/vitest.config.ts | 1 + 58 files changed, 4130 insertions(+), 1312 deletions(-) create mode 100644 surfaces/gui/src/i18n.ts create mode 100644 surfaces/gui/src/locales/en.json create mode 100644 surfaces/gui/src/locales/zh.json create mode 100644 surfaces/gui/src/test-setup.ts diff --git a/surfaces/gui/package-lock.json b/surfaces/gui/package-lock.json index 56baf1e14f..8e895216f0 100644 --- a/surfaces/gui/package-lock.json +++ b/surfaces/gui/package-lock.json @@ -8,9 +8,11 @@ "name": "openworker-gui", "version": "0.0.0", "dependencies": { + "i18next": "^26.3.6", "pdfjs-dist": "^4.10.38", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "simple-icons": "^16.26.0", @@ -305,7 +307,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3204,6 +3205,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -3242,6 +3252,34 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4937,6 +4975,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -5608,7 +5673,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5736,6 +5801,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/surfaces/gui/package.json b/surfaces/gui/package.json index c909514d62..aeceff8937 100644 --- a/surfaces/gui/package.json +++ b/surfaces/gui/package.json @@ -14,9 +14,11 @@ "tauri": "tauri" }, "dependencies": { + "i18next": "^26.3.6", "pdfjs-dist": "^4.10.38", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "simple-icons": "^16.26.0", diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 603d1e8e69..f8c2e395ad 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; +import { useTranslation } from "react-i18next"; import { announceInboxUnlock, finalizeAutomationRun, @@ -60,10 +61,12 @@ import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; const newId = () => (crypto as any).randomUUID ? crypto.randomUUID().slice(0, 12) : Math.random().toString(36).slice(2, 14); -const SUGGESTIONS = [ - { ico: "⚙", text: "Run the test suite and summarize any failures." }, - { ico: "✦", text: "Read the project and give me a 5-bullet overview." }, - { ico: "↻", text: "Find and fix the failing build." }, +// Hero task suggestions — translated at call time (module scope can't see React hooks). +// Keys live under `hero.suggest_*`; resolved in the component via useTranslation. +const SUGGESTION_KEYS = [ + { ico: "⚙", key: "hero.suggest_tests" }, + { ico: "✦", key: "hero.suggest_overview" }, + { ico: "↻", key: "hero.suggest_fix_build" }, ]; // Tools whose success means a new/changed file should show up under Artifacts right away. @@ -144,6 +147,7 @@ function fallbackWorkspace(current: string | null, projects: RecentWorkspace[]): } export function App() { + const { t } = useTranslation(); const [workspace, setWorkspace] = useState(null); const [branch, setBranch] = useState(null); const [showGate, setShowGate] = useState(false); @@ -681,23 +685,23 @@ export function App() { break; case "turn_end": if (d.status === "max_iterations_exceeded") - setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Stopped: max iterations reached." }]); + setItems((p) => [...p, { kind: "notice", tone: "warn", text: t("app.notice.max_iterations") }]); break; case "model_changed": // Mid-session switch (server-applied): update the header fact and drop the // persisted marker into the live transcript (replay renders it from history). if (d.model) setModel(d.model); - setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]); + setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || t("app.notice.model_switched") }]); break; case "interrupted": flushPartialStream(); - setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]); + setItems((p) => [...p, { kind: "notice", tone: "warn", text: t("app.notice.interrupted") }]); break; case "error": flushPartialStream(); setItems((p) => [ ...p, - { kind: "notice", tone: "warn", text: "Error: " + (d.error || "unknown"), retriable: true }, + { kind: "notice", tone: "warn", text: t("app.notice.error") + (d.error || "unknown"), retriable: true }, ]); break; case "input_rejected": @@ -1105,7 +1109,7 @@ export function App() { const subtitleParts = [modelDisplay]; if (isProjectScoped(personaOf(agent)) && workspace) subtitleParts.push(baseName(workspace)); const activeInfo = sessions.find((s) => s.session_id === sessionId); - const activeTitle = activeInfo?.title || "New session"; + const activeTitle = activeInfo?.title || t("sidebar.new_session"); const desktop = isTauri(); // Dev-only: `?overlay=1` simulates the desktop overlay layout in the browser (adds the @@ -1144,7 +1148,7 @@ export function App() {
- {resumedExisting ? "Restoring your session…" : "Starting OpenWorker…"} + {resumedExisting ? t("boot.restoring") : t("boot.starting")} BETA
@@ -1177,10 +1181,10 @@ export function App() { >
- Automation started + {t("toast.automation_started")}
- {runToast.title} · {runToast.time} run + {runToast.title} · {runToast.time} {t("toast.run_count")}
@@ -1328,24 +1332,24 @@ export function App() { @@ -1381,10 +1385,10 @@ export function App() { className="topbar-artifacts-btn" onMouseDown={(e) => e.stopPropagation()} onClick={() => setRailHidden(false)} - title="Show files this conversation produced" + title={t("topbar.show_artifacts")} > - Artifacts + {t("topbar.artifacts")} {artifactCount} )} @@ -1395,8 +1399,8 @@ export function App() { className="topbar-icon-btn" onMouseDown={(e) => e.stopPropagation()} onClick={() => setRailHidden((h) => !h)} - aria-label={railHidden ? "Show side panel" : "Hide side panel"} - title={railHidden ? "Show side panel" : "Hide side panel"} + aria-label={railHidden ? t("topbar.show_side_panel") : t("topbar.hide_side_panel")} + title={railHidden ? t("topbar.show_side_panel") : t("topbar.hide_side_panel")} > @@ -1416,14 +1420,14 @@ export function App() { > - Scheduled run + {t("run_banner.scheduled_run")} {runContext?.title ? ( <> {" — "} {runContext.title} ) : null}{" "} - · started by an automation + {t("run_banner.started_by_automation")}
)} @@ -1448,15 +1452,15 @@ export function App() {

- {agent === "chat" ? "How can I help?" : "Let's build something."} + {agent === "chat" ? t("hero.chat_greeting") : t("hero.build_greeting")}

{needsWorkspace(agent) && (
-
Try a task
- {SUGGESTIONS.map((s, i) => ( -
workspace && send(s.text)}> +
{t("hero.try_a_task")}
+ {SUGGESTION_KEYS.map((s, i) => ( +
workspace && send(t(s.key))}> {s.ico} - {s.text} + {t(s.key)}
))}
@@ -1490,7 +1494,7 @@ export function App() { {streaming && streamMode(streaming, items, running) === "answer" && (
-
assistant
+
{t("transcript.who_assistant")}
@@ -1511,7 +1515,7 @@ export function App() { onClick={followLatest} > - Jump to latest + {t("app.jump_to_latest")}
)} @@ -1537,10 +1541,10 @@ export function App() { resetKey={sessionId} placeholder={ agent === "code" - ? "Ask the coder to build, fix, or explain… (drop or paste files)" + ? t("composer.placeholder_code") : agent === "chat" - ? "Ask anything… (drop or paste files)" - : "Ask the coworker… (drop or paste files)" + ? t("composer.placeholder_chat") + : t("composer.placeholder_cowork") } approvalSlot={ // Live inline cards are for ATTENDED sessions only; when Unattended the prompt is @@ -1648,11 +1652,12 @@ function lastItemIsAssistant(items: Item[]): boolean { } function WaitingForAgent() { + const { t } = useTranslation(); return (
- Waiting for agent... + {t("app.waiting_for_agent")}
); diff --git a/surfaces/gui/src/components/AccessSection.tsx b/surfaces/gui/src/components/AccessSection.tsx index 5648852ae1..229848a002 100644 --- a/surfaces/gui/src/components/AccessSection.tsx +++ b/surfaces/gui/src/components/AccessSection.tsx @@ -10,6 +10,7 @@ // to expand it and scroll it into view. import { useCallback, useEffect, useRef, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { CLOUD_CHANGED, getCloudStatus, @@ -75,6 +76,7 @@ export function AccessSection({ const { roots, busy: rootsBusy, error: rootsError, addRoot, toggleAccess, removeRoot } = useRoots(sessionId, open ? 1 : 0); const rootEl = useRef(null); + const { t } = useTranslation(); const reload = useCallback(() => { // personaId hint: a brand-new session has no server-side record yet, so without it the @@ -212,23 +214,23 @@ export function AccessSection({ const names = live.map((c) => labelFor(c.connector, byName)); const sourcesPart = names.length === 0 - ? "no sources" + ? t("access.summary_no_sources") : names.length <= 2 ? names.join(", ") - : `${names.slice(0, 2).join(", ")} +${names.length - 2}`; + : t("access.summary_sources_more", { first: names.slice(0, 2).join(", "), more: names.length - 2 }); const folderPart = projectScoped ? baseName(workspace || roots.find((r) => r.primary)?.path || "") || null : roots.length > 0 - ? `${roots.length} folder${roots.length === 1 ? "" : "s"}` + ? t("access.summary_folder_count", { count: roots.length }) : null; - const summary = folderPart ? `${sourcesPart} · ${folderPart}` : sourcesPart; + const summary = folderPart ? t("access.summary_join", { sources: sourcesPart, folder: folderPart }) : sourcesPart; return (
{open && ( -
+
{connectFor ? ( {/* Sources — each toggle is a per-session override (mute for THIS session only). */}
-
Sources
+
{t("access.sources")}
{connected.length === 0 && (
- No connectors enabled for this session. + {t("access.no_connectors")}
)}
@@ -305,7 +307,7 @@ export function AccessSection({ setChannelsFor(c.connector); }} > - Channels · {channelsOf(c.connector).length} + {t("access.channels_link", { count: channelsOf(c.connector).length })} )} @@ -313,14 +315,17 @@ export function AccessSection({ toggleSession(c.connector, next)} - title="Enabled for this session — tap to mute here" + title={t("access.toggle_title")} />
))}
{connected.length > 0 && (

- Off mutes it for this session only — the connector stays connected. + }} + />

)} {/* §32 addendum (owner ask 2026-07-13; FB-012): the catalog's long tail, @@ -330,7 +335,7 @@ export function AccessSection({
setQuery(e.target.value)} onKeyDown={(e) => { @@ -346,7 +351,7 @@ export function AccessSection({ // Also covers a failed/empty catalog fetch: an open picker must never // be silently blank — point at the Connectors page either way.
- No match — see all on the Connectors page below. + {t("access.no_match")}
)}
@@ -380,14 +385,14 @@ export function AccessSection({ onClick={() => setAdding(true)} data-testid="access-add-source" > - + Add a source… + {t("access.add_source")} )}
{recommended.length > 0 && (
-
Recommended
+
{t("access.recommended")}
{recommended.map((r) => (
@@ -395,7 +400,7 @@ export function AccessSection({
{labelFor(r.connector, byName)} - {r.tier === "core" && core} + {r.tier === "core" && {t("access.core_tag")}}
{r.reason} @@ -423,7 +428,7 @@ export function AccessSection({ a quiet "+" link, structurally identical to Sources (owner ask 2026-07-13: the old drawer's card wrapper read too heavy in the rail). */}
-
Folders
+
{t("access.folders")}
{roots.map((r) => ( setAddingFolder(true)} > - + Give access to a folder… + + {t("access.give_folder")} )} {rootsError &&
{rootsError}
} @@ -461,7 +466,7 @@ export function AccessSection({ className="text-[12px] text-accent font-medium hover:underline text-left" onClick={() => onOpenIntegrations?.()} > - Manage all connectors (global) → + {t("access.manage_all")} →
)} @@ -485,6 +490,7 @@ function ConnectInline({ onDone: () => void; onBack: () => void; }) { + const { t: tt } = useTranslation(); useEffect(() => { const t = setInterval(async () => { try { @@ -502,9 +508,9 @@ function ConnectInline({ {c.blurb &&

{c.blurb}

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

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

); @@ -543,19 +548,20 @@ function ChannelsInline({ onRemove: (channel: string) => void; onBack: () => void; }) { + const { t: tt } = useTranslation(); return (
-
Subscribed channels · {channels.length}
+
{tt("access.subscribed", { count: channels.length })}
{channels.length === 0 ? (
- Not listening to any {label} channel yet. + {tt("access.no_channels", { label })}
) : (
@@ -568,14 +574,14 @@ function ChannelsInline({ {s.collision && ( )}
)} -
Add a channel
+
{tt("access.add_channel")}
{error && ( @@ -597,8 +603,7 @@ function ChannelsInline({

)}

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

); diff --git a/surfaces/gui/src/components/AddFolderForm.tsx b/surfaces/gui/src/components/AddFolderForm.tsx index b7159a951c..6fd673ba8f 100644 --- a/surfaces/gui/src/components/AddFolderForm.tsx +++ b/surfaces/gui/src/components/AddFolderForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { chooseFolder } from "../tauri"; import { Icon } from "./Icon"; @@ -20,6 +21,7 @@ export function AddFolderForm({ startOpen?: boolean; onDismiss?: () => void; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(!!startOpen); const [path, setPath] = useState(""); const [writable, setWritable] = useState(false); @@ -45,7 +47,7 @@ export function AddFolderForm({ if (!open) { return ( ); } @@ -56,7 +58,7 @@ export function AddFolderForm({ setPath(e.target.value)} @@ -65,21 +67,21 @@ export function AddFolderForm({ else if (e.key === "Escape") reset(); }} /> -
-
diff --git a/surfaces/gui/src/components/ApprovalCard.tsx b/surfaces/gui/src/components/ApprovalCard.tsx index b3a3ba7743..efc349a53b 100644 --- a/surfaces/gui/src/components/ApprovalCard.tsx +++ b/surfaces/gui/src/components/ApprovalCard.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { getI18n, useTranslation } from "react-i18next"; import type { ApprovalDecision, Item } from "../types"; import { humanizeApprovalTitle, type HumanLine } from "../humanize"; import { Icon } from "./Icon"; @@ -15,14 +16,15 @@ export function shortArgs(args: any): string { } // Human verbs kept for the §25 grant lines (the card title now comes from humanize.ts). +// Values are i18n keys resolved at render time. const TOOL_VERBS: Record = { - write_file: "Write a file", - replace_in_file: "Edit a file", - apply_patch: "Apply a patch", - apply_unified_diff: "Apply a patch", - run_shell: "Run a command", - send_message: "Send a message", - send_file: "Send a file", + write_file: "approval.verbs.write_file", + replace_in_file: "approval.verbs.edit_file", + apply_patch: "approval.verbs.apply_patch", + apply_unified_diff: "approval.verbs.apply_patch", + run_shell: "approval.verbs.run_command", + send_message: "approval.verbs.send_message", + send_file: "approval.verbs.send_file", }; // §35: routine workspace writes render as a compact ROW; everything else is a full card. @@ -60,19 +62,25 @@ export function TitleText({ line }: { line: HumanLine }) { // Plain-words scope note (replaces the "local action" badge): where does this act? // Shared with the parked-approval card (InboxItemCard) so both dialects match (§35). +// Uses the fixed-T form because this helper is also called from non-component modules. export function scopeNote( name: string, args: any, category?: string, ): { text: string; external: boolean } { - if (category === "connector") return { text: "acts on a connected service", external: true }; + const tt = getI18n().getFixedT(null, "translation"); + if (category === "connector") return { text: tt("approval.scope.connector"), external: true }; if (EXTERNAL.has(name)) { const platform = String(args?.target ?? "").split(":")[0]; const names: Record = { slack: "Slack", telegram: "Telegram" }; - return { text: `leaves this Mac → ${names[platform] || platform || "a connected chat"}`, external: true }; + const dest = names[platform] || platform || tt("approval.scope.connected_chat_fallback"); + return { text: tt("approval.scope.leaves_mac", { dest }), external: true }; } const overwrite = name === "write_file" && args?.overwrite; - return { text: "stays on this Mac" + (overwrite ? " · overwrites the existing file" : ""), external: false }; + return { + text: tt("approval.scope.stays_mac") + (overwrite ? tt("approval.scope.overwrite_suffix") : ""), + external: false, + }; } // The proposed content/command, straight from the tool call's ARGS — the file/action @@ -83,6 +91,7 @@ const PREVIEW_LINES = 5; const PREVIEW_CHARS = 420; export function PreviewBlock({ text, mono = true }: { text: string; mono?: boolean }) { + const { t } = useTranslation(); const [all, setAll] = useState(false); const lines = text.split("\n"); const clipped = lines.length > PREVIEW_LINES || text.length > PREVIEW_CHARS; @@ -97,10 +106,10 @@ export function PreviewBlock({ text, mono = true }: { text: string; mono?: boole {clipped && ( )}
@@ -131,8 +140,11 @@ function Buttons({ runTask?: { id: string; title: string } | null; primaryLabel: string; }) { + const { t } = useTranslation(); const connector = item.category === "connector"; const offerStanding = !!(runTask && item.standingTarget); + const verbKey = TOOL_VERBS[item.name]; + const verbName = verbKey ? t(verbKey).toLowerCase() : item.name; return (
)} {/* In a run context the task-persistent grant replaces the session-scoped one — @@ -155,20 +167,20 @@ function Buttons({ {!connector && !offerStanding && item.name !== "run_shell" && ( )} {item.name === "run_shell" && ( )}
); @@ -187,6 +199,7 @@ export function ApprovalCard({ runTask?: { id: string; title: string } | null; compact?: boolean; }) { + const { t } = useTranslation(); const [peek, setPeek] = useState(false); const title = humanizeApprovalTitle(item.name, item.args); const scope = scopeNote(item.name, item.args, item.category); @@ -206,11 +219,11 @@ export function ApprovalCard({ {content && ( )} - +
{peek && content && } {reason &&
{reason}
} @@ -222,7 +235,7 @@ export function ApprovalCard({
- + @@ -241,11 +254,11 @@ export function ApprovalCard({ - {String(item.args?.path ?? "").split("/").pop() || "file"} - {item.args?.as_screenshot ? " · as a PNG screenshot" : ""} + {String(item.args?.path ?? "").split("/").pop() || t("approval.file_fallback")} + {item.args?.as_screenshot ? t("approval.as_png_screenshot") : ""} {item.args?.comment && ( - + )} )} @@ -255,19 +268,22 @@ export function ApprovalCard({ {grants.length > 0 && (
- {grants.map((g, i) => ( -
- - {g.access === "write" ? "✓" : "·"} - - - {TOOL_VERBS[g.tool] || g.tool} {g.target} - - {g.access === "write" ? " — always allowed once you approve" : " — read-only"} + {grants.map((g, i) => { + const verbKey = TOOL_VERBS[g.tool]; + return ( +
+ + {g.access === "write" ? "✓" : "·"} - -
- ))} + + {verbKey ? t(verbKey) : g.tool} {g.target} + + {g.access === "write" ? t("approval.grant.always_after_approve") : t("approval.grant.read_only")} + + +
+ ); + })}
)} {/* Long-tail tools: no bespoke preview — fall back to the compact args line. */} @@ -278,9 +294,9 @@ export function ApprovalCard({ {reason &&
{reason}
} {item.resolved ? ( -
Approved: {item.resolved.replace("_", " ")}
+
{t("approval.resolved_prefix", { state: item.resolved.replace(/_/g, " ") })}
) : ( - + )}
); diff --git a/surfaces/gui/src/components/AuditView.tsx b/surfaces/gui/src/components/AuditView.tsx index 02f4dcd04e..4737ada9df 100644 --- a/surfaces/gui/src/components/AuditView.tsx +++ b/surfaces/gui/src/components/AuditView.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { getAudit, type AuditEvent } from "../api"; import { PanelHead } from "./IntegrationsView"; @@ -14,6 +15,7 @@ export function AuditView() { const [sessionFilter, setSessionFilter] = useState(""); const [connectorFilter, setConnectorFilter] = useState(""); const [toolFilter, setToolFilter] = useState(""); + const { t } = useTranslation(); const refresh = () => getAudit({ @@ -34,21 +36,21 @@ export function AuditView() {
- setSessionFilter(e.target.value)} /> - setConnectorFilter(e.target.value)} /> - setToolFilter(e.target.value)} /> + setSessionFilter(e.target.value)} /> + setConnectorFilter(e.target.value)} /> + setToolFilter(e.target.value)} />
{events.length === 0 ? ( -
No audit events yet.
+
{t("audit.no_events")}
) : (
{events.map((ev) => ( @@ -63,18 +65,19 @@ export function AuditView() { } function AuditRow({ ev }: { ev: AuditEvent }) { + const { t } = useTranslation(); return (
{ev.tool} - {ev.connector || "tool"} · {ev.stage || ev.status || "event"} · {ev.timestamp} + {ev.connector || t("audit.fallback_tool")} · {ev.stage || ev.status || t("audit.fallback_event")} · {ev.timestamp}
- session {ev.session_id || "-"} {ev.approval ? `· ${ev.approval}` : ""} {ev.status ? `· ${ev.status}` : ""} + {t("audit.session")} {ev.session_id || "-"} {ev.approval ? `· ${ev.approval}` : ""} {ev.status ? `· ${ev.status}` : ""}
- {ev.resource &&
resource: {ev.resource}
} + {ev.resource &&
{t("audit.resource", { value: ev.resource })}
} {ev.args && Object.keys(ev.args).length > 0 && (
{formatAuditArgs(ev.args)}
)} diff --git a/surfaces/gui/src/components/AutomationQuickstart.tsx b/surfaces/gui/src/components/AutomationQuickstart.tsx index e7ac533ea1..e43552a454 100644 --- a/surfaces/gui/src/components/AutomationQuickstart.tsx +++ b/surfaces/gui/src/components/AutomationQuickstart.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { useTranslation, getI18n } from "react-i18next"; import { cloudLogin, connectManaged, @@ -22,16 +23,17 @@ import { SelectMenu } from "./SelectMenu"; // The `ob-*` testids moved here with the machinery. // "When" = day choice × free time (owner call 2026-07-11); the cron assembles from the two. -const DAYS: Record = { - mon: { label: "Mondays", dow: "1" }, - tue: { label: "Tuesdays", dow: "2" }, - wed: { label: "Wednesdays", dow: "3" }, - thu: { label: "Thursdays", dow: "4" }, - fri: { label: "Fridays", dow: "5" }, - sat: { label: "Saturdays", dow: "6" }, - sun: { label: "Sundays", dow: "0" }, - weekdays: { label: "Weekdays", dow: "1-5" }, - daily: { label: "Every day", dow: "*" }, +// Labels are i18n keys (resolved in the component via t()). +const DAYS: Record = { + mon: { labelKey: "automations.day_mon", dow: "1" }, + tue: { labelKey: "automations.day_tue", dow: "2" }, + wed: { labelKey: "automations.day_wed", dow: "3" }, + thu: { labelKey: "automations.day_thu", dow: "4" }, + fri: { labelKey: "automations.day_fri", dow: "5" }, + sat: { labelKey: "automations.day_sat", dow: "6" }, + sun: { labelKey: "automations.day_sun", dow: "0" }, + weekdays: { labelKey: "automations.freq_weekdays", dow: "1-5" }, + daily: { labelKey: "automations.freq_daily", dow: "*" }, }; // §30 connect-state spinner (the app has no other spinner — waits elsewhere are label swaps). // Exported for Onboarding page 2's sign-in button (same states, same look). @@ -46,10 +48,10 @@ const cronFor = (dayKey: string, hhmm: string) => { interface QuickTemplate { key: string; - title: string; - blurb: string; - cadence: string; // the card's footer label - conns: { name: string; why: string }[]; // [] = no connections needed + titleKey: string; + blurbKey: string; + cadenceKey: string; // the card's footer label + conns: { name: string; whyKey: string }[]; // [] = no connections needed needsRepo?: boolean; needsChannel?: boolean; consent?: boolean; // write recipes carry the §25 consent line; reads carry disclosure @@ -62,89 +64,88 @@ interface QuickTemplate { const TEMPLATES: QuickTemplate[] = [ { key: "github", - title: "GitHub digest", - blurb: "Merged PRs and commits, posted to your team's Slack.", - cadence: "Weekly", + titleKey: "automations.tmpl_github_title", + blurbKey: "automations.tmpl_github_blurb", + cadenceKey: "automations.cadence_weekly", conns: [ - { name: "slack", why: "Where the digest posts" }, - { name: "github", why: "What the digest summarizes" }, + { name: "slack", whyKey: "automations.why_digest_posts" }, + { name: "github", whyKey: "automations.why_digest_summarizes" }, ], needsRepo: true, needsChannel: true, consent: true, day: "mon", time: "09:00", - instructions: ({ repo, channel }) => - `Summarize activity since the last digest in the GitHub repository ${repo || "(the connected repository)"}: ` + - `merged pull requests, notable commits, and anything needing attention. ` + - `Post the digest to the Slack channel ${channel} using send_message.`, + instructions: ({ repo, channel }) => { + const gt = getI18n().t; + return gt("automations.tmpl_github_instructions", { repo: repo || gt("automations.tmpl_github_repo_default"), channel }); + }, }, { key: "pipeline", - title: "Pipeline digest", - blurb: "Deals that moved — and deals going quiet — posted to Slack.", - cadence: "Weekly", + titleKey: "automations.tmpl_pipeline_title", + blurbKey: "automations.tmpl_pipeline_blurb", + cadenceKey: "automations.cadence_weekly", conns: [ - { name: "slack", why: "Where the digest posts" }, - { name: "hubspot", why: "Pipeline and deal activity" }, + { name: "slack", whyKey: "automations.why_digest_posts" }, + { name: "hubspot", whyKey: "automations.why_pipeline_activity" }, ], needsChannel: true, consent: true, day: "mon", time: "09:00", - instructions: ({ channel }) => - `Review HubSpot activity since the last digest: deals that changed stage, deals going ` + - `quiet, and deals past their close date. Post a short pipeline digest to the Slack ` + - `channel ${channel} using send_message.`, + instructions: ({ channel }) => { + const gt = getI18n().t; + return gt("automations.tmpl_pipeline_instructions", { channel }); + }, }, { key: "brief", - title: "Morning brief", - blurb: "Calendar and unread email, summarized before your day starts.", - cadence: "Daily", + titleKey: "automations.tmpl_brief_title", + blurbKey: "automations.tmpl_brief_blurb", + cadenceKey: "automations.cadence_daily", conns: [ - { name: "google_calendar", why: "Today's meetings and gaps" }, - { name: "gmail", why: "What arrived overnight" }, + { name: "google_calendar", whyKey: "automations.why_meetings_gaps" }, + { name: "gmail", whyKey: "automations.why_overnight_email" }, ], deliver: true, day: "daily", time: "08:00", - instructions: ({ deliver }) => - `Prepare a short morning brief: today's calendar events and gaps, plus email that ` + - `arrived since yesterday evening. ` + - (deliver === "app" ? "Save it as the session deliverable." : "Send it to me as a Slack DM."), + instructions: ({ deliver }) => { + const gt = getI18n().t; + return gt("automations.tmpl_brief_instructions_prefix") + + (deliver === "app" ? gt("automations.tmpl_brief_save") : gt("automations.tmpl_brief_slack")); + }, }, { key: "news", - title: "Morning news briefing", - blurb: "A 5-bullet tech & world news digest, saved as markdown.", - cadence: "Daily", + titleKey: "automations.tmpl_news_title", + blurbKey: "automations.tmpl_news_blurb", + cadenceKey: "automations.cadence_daily", conns: [], day: "daily", time: "08:00", - instructions: () => - "Search the web for the most important technology and world news from the last 24 hours " + - "and write a concise 5-bullet briefing, saved as a markdown file.", + instructions: () => getI18n().t("automations.tmpl_news_instructions"), }, { key: "inboxdigest", - title: "Inbox digest", - blurb: "One short digest of your unread email.", - cadence: "Weekdays", - conns: [{ name: "gmail", why: "Your unread email" }], + titleKey: "automations.tmpl_inbox_title", + blurbKey: "automations.tmpl_inbox_blurb", + cadenceKey: "automations.cadence_weekdays", + conns: [{ name: "gmail", whyKey: "automations.why_unread_email" }], day: "weekdays", time: "09:00", - instructions: () => "Summarize my unread email into one short digest note.", + instructions: () => getI18n().t("automations.tmpl_inbox_instructions"), }, { key: "cleanup", - title: "Folder cleanup", - blurb: "Sort recent Downloads into tidy folders by type.", - cadence: "Weekly", + titleKey: "automations.tmpl_cleanup_title", + blurbKey: "automations.tmpl_cleanup_blurb", + cadenceKey: "automations.cadence_weekly", conns: [], day: "fri", time: "17:30", - instructions: () => "Sort my recent Downloads into tidy folders by file type.", + instructions: () => getI18n().t("automations.tmpl_cleanup_instructions"), }, ]; @@ -160,8 +161,9 @@ export function AutomationQuickstart({ permissions?: { tool: string; target: string; access: "read" | "write" }[]; }) => void; }) { + const { t } = useTranslation(); const [pickedKey, setPickedKey] = useState(null); - const picked = TEMPLATES.find((t) => t.key === pickedKey) || null; + const picked = TEMPLATES.find((tpl) => tpl.key === pickedKey) || null; const [connectors, setConnectors] = useState([]); const [cloud, setCloud] = useState(null); @@ -225,10 +227,10 @@ export function AutomationQuickstart({ if (pickedKey) cfgRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [pickedKey]); - const pick = (t: QuickTemplate) => { - setPickedKey(t.key); - setDay(t.day); - setTime(t.time); + const pick = (tpl: QuickTemplate) => { + setPickedKey(tpl.key); + setDay(tpl.day); + setTime(tpl.time); setConsent(true); setConnFlow(null); }; @@ -280,7 +282,7 @@ export function AutomationQuickstart({ const create = () => { if (!picked) return; onCreate({ - title: picked.title, + title: t(picked.titleKey), instructions: picked.instructions({ repo, channel, deliver }), cron: cronFor(day, time), permissions: @@ -291,12 +293,14 @@ export function AutomationQuickstart({ }; const gateHint = !allConnected - ? `Connect ${picked?.conns - .filter((c) => !connState(c.name)?.connected) - .map((c) => connState(c.name)?.title || c.name) - .join(" and ")} to continue` + ? t("automations.gate_connect", { + names: picked?.conns + .filter((c) => !connState(c.name)?.connected) + .map((c) => connState(c.name)?.title || c.name) + .join(t("automations.gate_join")), + }) : picked?.needsChannel && !channel - ? "Pick a channel to post to first" + ? t("automations.gate_pick_channel") : ""; const label = "block text-[12px] text-muted mt-3 mb-1"; @@ -306,33 +310,33 @@ export function AutomationQuickstart({ return (
- Start from a template + {t("automations.start_from_template")}
{/* Equal-height cards (owner ask 2026-07-12): 1fr rows + h-full — @@ -360,15 +364,15 @@ export function AutomationQuickstart({ {/* §30: the card names its template — without this it starts abruptly after the grid. */}
- Set up + {t("automations.set_up")} - {picked.title} + {t(picked.titleKey)} - {picked.conns.length ? "Connections, delivery & schedule" : "Delivery & schedule"} ·{" "} - {picked.cadence} + {picked.conns.length ? t("automations.conns_delivery_sched") : t("automations.delivery_sched")} ·{" "} + {t(picked.cadenceKey)}
- {picked.conns.map(({ name, why }) => { + {picked.conns.map(({ name, whyKey }) => { const c = connState(name); const flow = connFlow?.name === name ? connFlow : null; return ( @@ -377,16 +381,16 @@ export function AutomationQuickstart({ {c && } {c?.title || name} - {why} + {t(whyKey)} {c?.connected ? ( - ✓ Connected + {t("automations.connected_ok")} ) : flow ? ( {flow.phase === "opening" - ? "Opening browser…" - : `Waiting for ${c?.title || name}…`} + ? t("automations.opening_browser") + : t("automations.waiting_for", { name: c?.title || name })} ) : ( )}
@@ -408,16 +412,16 @@ export function AutomationQuickstart({ - Finish connecting {c?.title || name} in your browser. + {t("automations.finish_connecting", { name: c?.title || name })} {" "} - Approve it there, then come back — this page updates by itself. + {t("automations.finish_connecting_desc")}
)} @@ -431,25 +435,25 @@ export function AutomationQuickstart({ data-testid="ob-cloudpane" > - One sign-in unlocks every one-click connection + {t("automations.one_signin_unlocks")} - Connections are brokered by OpenWorker Cloud — your tokens stay on this Mac. + {t("automations.cloud_brokered")}
{signinPhase ? ( <> - {signinPhase === "opening" ? "Opening browser…" : "Waiting for sign-in…"} + {signinPhase === "opening" ? t("automations.opening_browser") : t("automations.waiting_signin")} {signinPhase === "waiting" && ( - Finish signing in in your browser — this page updates by itself.{" "} + {t("automations.finish_signin_desc")}{" "} )} @@ -460,7 +464,7 @@ export function AutomationQuickstart({ onClick={signInThenConnect} data-testid="ob-cloud-signin" > - Sign in to OpenWorker Cloud + {t("automations.sign_in_to_cloud")} )}
@@ -471,10 +475,10 @@ export function AutomationQuickstart({
{picked.needsRepo && ( <> - + setRepo(e.target.value)} data-testid="ob-repo" @@ -483,7 +487,7 @@ export function AutomationQuickstart({ )} {picked.needsChannel && ( <> - +

- The bot must be a member of the channel — invite @OpenWorker in Slack if it isn't. + {t("automations.bot_member_hint")}

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

- This automation only reads on schedule — reading - never needs approval. + {t("automations.read_only_pref")}{t("automations.reads")}{t("automations.read_only_suff")}

) : null}
@@ -563,7 +566,7 @@ export function AutomationQuickstart({ className="text-[12.5px] text-faint hover:text-muted" onClick={() => setPickedKey(null)} > - Cancel + {t("automations.cancel")} {/* A silently-disabled primary reads as a bug — always name the missing piece. */} {gateHint && ( @@ -580,7 +583,7 @@ export function AutomationQuickstart({ onClick={create} data-testid="ob-create" > - {busy ? "Creating…" : "Create automation"} + {busy ? t("automations.creating") : t("automations.create_btn")}
diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 852e4d6f27..61ea92833a 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import type { Attachment } from "../types"; import { isPdfFile, readFile } from "../attach"; import { getSettings, inspectPdf } from "../api"; @@ -19,10 +20,12 @@ import { // polished enough to ship, and Custom (config.toml auto-allow rules) is a power-user mode // with no in-app explanation. The server still honors both — a session already in one of // those modes keeps working; the picker just doesn't offer them. +// Labels/descriptions are i18n keys (resolved at render via t()); kept as keys here so the +// module-level constant stays outside the component without losing translation. const PERMISSION_OPTIONS: Option[] = [ - { value: "discuss", label: "Discuss", description: "Chat and explore — no edits or commands" }, - { value: "interactive", label: "Ask for approval", description: "Ask before edits and commands" }, - { value: "auto", label: "Full access", description: "Run everything without asking" }, + { value: "discuss", label: "composer.mode.discuss", description: "composer.mode.discuss_desc" }, + { value: "interactive", label: "composer.mode.interactive", description: "composer.mode.interactive_desc" }, + { value: "auto", label: "composer.mode.auto", description: "composer.mode.auto_desc" }, ]; // No hardcoded model fallback: until the server supplies the list (a few seconds after a @@ -80,6 +83,7 @@ interface Props { } export function Composer(props: Props) { + const { t } = useTranslation(); const [text, setText] = useState(""); const [attachments, setAttachments] = useState([]); const [dragging, setDragging] = useState(false); @@ -216,7 +220,7 @@ export function Composer(props: Props) { for (const file of list) { if (isPdfFile(file) && file.size > maxMb * 1024 * 1024) { showAttachNotice( - `${file.name} skipped — ${(file.size / 1024 / 1024).toFixed(1)} MB is over your ${maxMb} MB limit (Settings → Token savings)`, + t("composer.pdf_too_big", { name: file.name, mb: (file.size / 1024 / 1024).toFixed(1), limit: maxMb }), ); continue; } @@ -229,12 +233,12 @@ export function Composer(props: Props) { const info = await inspectPdf(a.data_url).catch(() => null); if (info?.ok && (info.pages ?? 0) > maxPages) { showAttachNotice( - `${a.name} skipped — ${info.pages} pages is over your ${maxPages}-page limit (Settings → Token savings)`, + t("composer.pdf_too_many_pages", { name: a.name, pages: info.pages, limit: maxPages }), ); continue; } if (info && !info.ok) { - showAttachNotice(`${a.name} skipped — ${info.error || "could not read PDF"}`); + showAttachNotice(t("composer.pdf_unreadable", { name: a.name, error: info.error || t("composer.pdf_could_not_read") })); continue; } } @@ -255,14 +259,14 @@ export function Composer(props: Props) { const needsModel = props.modelReady === false; const submit = () => { - const t = text.trim(); - if ((!t && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return; + const body = text.trim(); + if ((!body && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return; // No model connected: keep the draft (don't drop it) and send the user to setup instead. if (needsModel) { props.onConnectModel?.(); return; } - props.onSend(t, attachments); + props.onSend(body, attachments); setText(""); setAttachments([]); }; @@ -290,9 +294,9 @@ export function Composer(props: Props) { setDictationError(null); try { if (dictation?.recording) { - setDictationBusy("Transcribing…"); + setDictationBusy(t("composer.starting_transcribe")); const transcript = await stopDictation(); - if (transcript === null) throw new Error("Could not transcribe your recording."); + if (transcript === null) throw new Error(t("composer.err_transcribe")); if (transcript.trim()) { setText((draft) => (draft.trim() ? `${draft.trimEnd()} ${transcript.trim()}` : transcript.trim())); } @@ -302,17 +306,17 @@ export function Composer(props: Props) { } const status = dictation || (await getDictationStatus()); - if (!status) throw new Error("Voice dictation is unavailable."); + if (!status) throw new Error(t("composer.err_dictation_unavailable")); if (!status.supported || !status.model_verified || !status.test_passed) { props.onConfigureVoiceInput?.(); return; } - setDictationBusy("Starting microphone…"); + setDictationBusy(t("composer.starting_mic")); const recording = await startDictation(); - if (!recording?.recording) throw new Error("Could not start the microphone."); + if (!recording?.recording) throw new Error(t("composer.err_mic_start")); setDictation(recording); } catch (error) { - setDictationError(error instanceof Error ? error.message : "Voice dictation is unavailable."); + setDictationError(error instanceof Error ? error.message : t("composer.err_dictation_unavailable")); const status = await getDictationStatus(); if (status) setDictation(status); } finally { @@ -355,7 +359,7 @@ export function Composer(props: Props) { @@ -390,7 +394,7 @@ export function Composer(props: Props) {