Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 ff647fba0a release 2026.08.23.04
The web UI can be read in Portuguese. The shell and shared components are fully
keyed, 581 keys across two complete catalogues, with the build failing on a
missing one. Plus an endpoint for spawning an agent, and spawned agents becoming
visible in both directions.

No schema change and no dependency change. db:push should report nothing, and the
plan says to stop and report it if it does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:51:50 +00:00
pastilhasandClaude Opus 5 2f8a2b60d5 localization: the 'what exists today' table described a world three phases ago
Every row was wrong — bare en.json, 46 keys, three components, 5% deployed — and
it is the first thing whoever picks up phase 3 reads. platform-59 flagged it and
left it alone rather than colliding with me mid-edit, which was the right call.

Kept as a before/now table rather than replaced. The shape of the gap is the
argument for the phases, and deleting the 'before' column would leave them looking
arbitrary.

Also records two things the plan did not anticipate, both found by doing the work:
TranslationKeys walked into a plural object so t('key', {count}) — the whole point
of D7 — would not compile while the workaround did, which no test could have caught
because phase 1 had no plurals. And the typed wrappers rebuilt t every render,
which is the trap CLAUDE.md already documents against useClient().

And the shape this catalogue format cannot express: a sentence with an inline link
splits into a key whose Portuguese value is '.'. Flagged rather than solved, since
a node-level interpolation helper is a design decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:43:49 +00:00
pastilhas 454a0f4244 hold officer-web at zero hardcoded strings
Phase 2 is complete: 555 -> 0. Verified the guard both ways -- exit 1 naming the
string and file when one is reintroduced, exit 0 once it is gone.

officerdev is still at 578 and stays out of ZEROED; that is phase 3.
2026-08-23 05:40:01 +00:00
pastilhas a48a7ff696 the last two shell strings were the composer's pre-filled prompt (2 -> 0)
CapabilityPage puts a suggestion in the chat composer, which the reader sees and
edits before sending -- user-facing, so translated. The hidden promptFrontmatter
beside it is NOT: that one instructs the agent and carries file paths. The scanner
never flagged it, correctly, because it opens with a tag rather than a capital.

officer-web is now at zero convertible strings.
2026-08-23 05:38:17 +00:00
pastilhas bde59eb74e activity, capabilities, chat history and the locale picker (21 -> 2)
The locale picker's labels are now AUTONYMS computed from Intl -- each language named
in its own language, so "Português europeu" reads as Portuguese whichever locale the
UI is in. That is the convention for a language picker and the useful behaviour:
somebody who cannot read the current language still has to find theirs.

CapabilityPage interpolated the capability KIND into six strings by lowercasing it in
code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now.

What is left is two strings, both deliberate: see the next commit message.
2026-08-23 05:37:04 +00:00
pastilhas 0b85d42197 jobs keyed (73 -> 21)
The three job detail panes and the jobs page. Status labels share one jobs.status.*
group across all three panes rather than three copies of "Running".

statusBadge in two of the panes is a module-level helper, not a component, so it
takes `t` as a parameter -- the alternative is calling a hook outside a component
body. ScriptJobDetail's version built "Failed (exit N)" by string concatenation;
that is a key with a parameter now.
2026-08-23 05:30:45 +00:00
pastilhas 2e846933e9 shortcut registry keyed (96 -> 73)
shortcuts.ts is data with no component around it, so it carries catalog keys and
ShortcutsModal resolves them -- same shape as the settings sections.

gen-platform-skill.ts also reads this registry to generate the agent-facing SKILL.md,
which docs:check validates. That doc stays in one language, so the generator resolves
against en-GB directly and throws if a key is missing rather than emitting a dot-path
into a file an agent will quote at somebody. The regenerated skill is byte-identical,
which is the check that keying the registry changed nothing an agent reads.
2026-08-23 05:26:43 +00:00
pastilhas 5e032898f8 integrations keyed, and the Google guide's foreign labels marked (163 -> 96)
Applies the rule added to docs/localization.md in 91b9bec9: a label quoted from
another product's interface is an identifier, so the prose around it is translated
and the label itself is left in English and NOT keyed.

Gives that decision a name in the code. <Untranslated reason="foreign-ui-label">
states it at each of the ~30 call sites, where whoever next edits the sentence will
see it, and gives the scanner something exact to skip -- without which labels that
MUST stay English count as debt forever and the area can never be held at zero. That
is the same trap as counting new Error() messages. `reason` is a closed union so a
second excuse has to be argued for rather than smuggled in as free text.

The sentences needed rephrasing, not just splitting. "Go to the [New Project] page."
decomposes into "Go to the" + link + "page." -- and the Portuguese value of that
second key is "." A key that holds a full stop is the fragmentation smell, so the
English now puts the label at a clause boundary ("Open the project creation page:"
then the link), which reads naturally in both languages and needs no node-level
interpolation. Worth knowing for phase 3: sentences with an inline link are the one
shape this catalog format cannot express, and rephrasing is the cheap way out.

Scanner: the existing SCREAMING_CONSTANT rule now allows . and - so Google's
client-secret prefix example GOCSPX-... falls under it. Same rule, wider class.
2026-08-23 05:24:32 +00:00
pastilhasandClaude Opus 5 91b9bec926 localization: a label quoted from another product's UI is an identifier
Sonnet stopped phase 2 on GoogleOAuthConfig.tsx, where 55 of 67 strings are
quoted from Google's own console — 'Click Enable', 'set App name'. It called it an
editorial choice and asked. It is not a choice; it is already covered by the
principle the never-translate list is built on, and the list just did not say so.

Those labels read like prose and behave like identifiers: their whole job is to
match what the reader sees on Google's screen, and Google localises by the reader's
Google account language rather than ours. Translating them sends somebody hunting
for 'Ativar' in a console that says 'Enable'.

So: translate the prose around them, leave the label in English, and do not key it
with identical values on both sides — a key whose two halves can never legitimately
differ is a maintenance cost bought for nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:18:14 +00:00
pastilhas b978e09327 authentication and calendar-sync passwords keyed (214 -> 163)
Authentication reaches zero -- sign-in, bootstrap, forgot and reset password. These
are the first words a new account reads, which is why the brief ranked them above
the larger screens.

DavAppPasswords had the third toLocaleDateString(undefined, ...) in the shell; it now
uses useFormat().date like the other two.

DebugPanel's three labels are keyed too. Worth knowing rather than acting on: that
panel is rendered behind showDebug, which Background.tsx hardcodes to false, so it
never appears. Keyed rather than deleted because removing it is not my call.
2026-08-23 05:15:22 +00:00
pastilhas 7f8455f1af user management keyed (267 -> 214)
Accounts, permissions and the create-user form. The confirm dialogs for removing an
account and resetting a password were multi-line JSX the scanner cannot see, so they
were converted on sight rather than from its list.

Cancel comes from ui.button.cancel in all four places it appears here.
2026-08-23 05:10:17 +00:00
pastilhasandClaude Opus 5 f6b9ddc877 a spawned session says who started it
The other half of making a spawned agent visible. The commander's pane now lists
its children; opening one gave no way back — no link to the conversation that owns
it, and nothing to tell it apart from a chat somebody had started themselves and
forgotten.

`parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from
the other end. So this needed no table and no write path: a commander's children
were recorded because deleting one has to take them with it, and that record
answers "who is my parent" for free. Nothing had asked.

`GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line
above the transcript linking back to the commander. Null for almost every session,
which is the normal case rather than a miss — a chat somebody opened has no parent
— so the bar renders nothing at all rather than saying so. Never fatal: a session
must open when this lookup will not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:09:28 +00:00
pastilhas 44f5246e38 server settings keyed (352 -> 267)
ServerSettings reaches zero: OCR, STT, TTS, SMTP, maintenance and the AI harnesses.

Two more hand-rolled plurals became real plural entries -- maintenance said
"commit" with a ternary on length === 1, and both read correctly at 0, 1 and 2 in
both locales now. The three Cancel buttons in AIHarnessesSection take ui.button.cancel
rather than adding a fourth app-level copy of the word, which is what phase 1 put it
there for.

Scanner: a single token carrying digits and punctuation is a name, not a sentence --
the OCR placeholder is the example model file Qwen2.5-VL-7B-Instruct-q4_k_m.gguf.
Prose has spaces, and requiring whitespace to be absent is what keeps this from
eating real one-word labels. Verified the delta is exactly that one string.

Note for the owner: another session has src/servers/api/sub-agents/router.ts in
flight with three type errors. Untouched by me and excluded from my gate; the full
tree will not typecheck clean until they land it.
2026-08-23 05:04:22 +00:00
pastilhasandClaude Opus 5 9b0376ec39 todo: close the spawning endpoint
Both modes verified against the live server, not reasoned about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:02:45 +00:00
pastilhasandClaude Opus 5 66234cd3e0 an endpoint for spawning an agent, so a caller that is not a chat can
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.

So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.

The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.

`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.

Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.

Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:02:29 +00:00
pastilhas ad8ed88519 profile settings keyed (425 -> 352)
ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the
model defaults.

Language names now come from Intl.DisplayNames rather than the catalog. The runtime
already knows "German" in English and "alemao" in Portuguese, so 28 names would have
been 56 hand-written entries that Intl gets right for free -- including for locales
not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays
hardcoded: which languages you may claim to speak is a product choice.

ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale --
the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key
toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now.

Scanner: an Authorization header value like "Bearer <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
2026-08-23 04:57:48 +00:00
57 changed files with 2082 additions and 677 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds: - `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days. never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every - `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`. and `confined` stripped for an account with no `osUser`.
+61
View File
@@ -0,0 +1,61 @@
# 2026.08.23.04
The web UI can be read in Portuguese. The shell and the shared components are fully keyed — 581 keys across
two locales, with the build failing on a missing one — plus an endpoint for spawning an agent, and spawned
agents becoming visible in both directions.
**No schema change.** Nothing under `src/databases/**/schema.ts` moved across
`deploy/2026.08.23.03..HEAD`. `bun db:push` is still a step because it is cheap and it verifies the
database agrees with the code, but it should report no changes.
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
## What is in it
- **Localization, phases 0 through 2.** `src/apps/officer-web` and `src/workspaces/components` have no
hardcoded user-facing strings left: 555 and 47 occurrences respectively, both now zero. `en-GB` and
`pt-PT` are complete and in step at 581 keys.
- **Every locale ships a complete catalogue.** There is no fallback between regional variants, so a
Portuguese reader is never served Brazilian wording. What enforces that is a **key-parity test that fails
the build** on a gap — verified by removing one key and watching it go red.
- **The language picker offers only locales that exist.** It listed about twenty codes, eighteen of which
silently rendered English. Stored bare codes widen on read (`en` → `en-GB`), with no database migration.
- **Dates, numbers and plurals follow the account's locale**, not the browser's, through `useFormat()` and
`Intl.PluralRules`. Roughly 23 call sites moved.
- **`POST /api/sub-agents/spawn`** — an endpoint for starting an agent from a prompt, for callers that are
not a chat and so cannot reach the harness's own tool. Budget-checked before spawning, and it refuses with
a 429 naming the window rather than burning the last of one.
- **Spawned agents are visible in both directions.** A commander's pane lists its children as links; opening
one shows which commander started it, linking back.
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
error — so answering a question during a restart window vanished and the card spun forever.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.04`
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: that means
this tree and that host's database disagree about something this release did not touch.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## What to check, this release
The visible change is the language picker in profile settings. It should offer exactly **English (UK)** and
**Português (Portugal)** and nothing else. A host still offering twenty languages did not pick up the
frontend bundle.
No direct SQL is needed and none is authorised.
## Hosts
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
- **`production-pertento-bridge`** — deployed cleanly last release after being two behind. Nothing expected.
## `officer-dev` — skip it
The rollout runs from here. It is already at this commit and `officer` has been restarted on it.
+46 -25
View File
@@ -25,35 +25,44 @@ the server does instead, which is the one decision that reaches outside this bou
## What exists today, and what it cannot do ## What exists today, and what it cannot do
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is **Updated 2026-08-23, after phases 0, 1 and 2.** This section described the pre-phase-0 world for long
also about **5% deployed**, and that gap is the whole job. enough that the next reader would have started from a false picture — bare `en.json`, 46 keys, three
components. All of that is now history and is kept below only as the "before" column, because the shape of
the gap is the argument for the plan and deleting it would leave the phases looking arbitrary.
| | | | | before phase 0 | now |
| ----------------------------------------- | -------------------------------------------------------------------------- | | -------------------------- | ---------------------- | --------------------------------------------- |
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` | | catalogs | `en.json`, `pt.json` | `en-GB.json`, `pt-PT.json` per workspace |
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` | | keys | 46 | **581** — 538 `app.*`, 43 `ui.*` |
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider | | workspaces with a catalog | 1 (`officer-web`) | 3 (`officer-web`, `components`, `officerdev`) |
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** | | components using it | 3 | all of `officer-web` and `components` |
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` | | plural rules | none | `Intl.PluralRules`, per locale |
| dates and numbers | browser locale | `useFormat()`, the account's locale |
| a missing key | rendered its dot-path | falls back to English, warns in dev |
| locales offered vs shipped | ~20 offered, 2 existed | only shipped locales are offered |
| parity enforcement | none | **a gap fails the build** |
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation, Still standing from the original description, and still true: the package is hand-rolled, has no npm
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a dependency, and gives type-safe keys — a wrong key is a typecheck failure, which is worth more than it
typecheck failure, which is worth more than it sounds when one agent is editing 400 files. sounds when one agent is editing hundreds of files.
**What it cannot do yet**, each of which is a task below: **What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
wired into the bridge, and empty.
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the Two things learned doing phases 1 and 2 that the plan did not anticipate:
runtime and unused.
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use - **`TranslationKeys` walked into a plural object**, so `t('key', { count })` — the entire point of D7 —
the _browser's_ locale, not the user's chosen one, so a Portuguese UI shows American dates. did not compile while `t('key.one')` did. The documented feature was uncompilable and the workaround was
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit not, which no test would ever have noticed because phase 1 contained no plurals. Fixed with type-level
from `pt`. assertions.
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in - **The typed wrappers rebuilt `t` on every render**, which is the trap `CLAUDE.md` already records against
`officerdev` and the 91 in `components` have no catalog of their own. `useClient()` and the Jellyfin player. It matters the moment `t` lands in a `useMemo` dependency list,
5. **Silent, ugly misses.** A missing key renders its own dot-path (`settings.profile.foo`) to the which phase 2 needed immediately. Memoised.
user. No warning, no test.
6. **Twenty languages offered, two that exist.** The picker in `Languages.tsx` lists ~20 codes **A shape this format cannot express:** a sentence with an inline link. Splitting around the anchor gives a
including Hebrew and Greek. Choosing any of the eighteen silently yields English. key whose Portuguese value is `"."`. The cheap fix is rephrasing so the link falls at a clause boundary;
there is no node-level interpolation helper and adding one is a design decision, not a patch. Worth
raising if a phase turns out to be full of them rather than absorbing it forty times.
### The size of the remaining work ### The size of the remaining work
@@ -262,6 +271,18 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content. - **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies. - **User content** — file names, paths, terminal output, code, commit messages, note bodies.
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
account language, not by ours. Translating them sends somebody hunting for "Ativar" in a console that
says "Enable".
So translate the connective prose around them and leave the quoted label in English. Do not key it with
identical values in both catalogs either — that is a key whose two sides can never legitimately differ,
which is a maintenance cost bought for nothing.
Decided 2026-08-23 on `GoogleOAuthConfig.tsx`, where 55 of the file's 67 strings are of this kind.
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_ - **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process (the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
names, database values. names, database values.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "officer", "name": "officer",
"version": "2026.08.23.03", "version": "2026.08.23.04",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
+16 -4
View File
@@ -38,6 +38,16 @@ const KIND_MEANING: Record<string, string> = {
export async function renderPlatformSkill(): Promise<string> { export async function renderPlatformSkill(): Promise<string> {
const { PERMISSIONS } = await import('../src/servers/permissions/registry'); const { PERMISSIONS } = await import('../src/servers/permissions/registry');
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts'); const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
const enCatalog = (await import('../src/apps/officer-web/locales/en-GB.json')).default as Record<string, unknown>;
/** Resolve a dot-path against the English catalog. This doc is for agents and stays in one language. */
const enString = (key: string): string => {
const value = key.split('.').reduce<unknown>((node, part) => {
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
return undefined;
}, enCatalog);
if (typeof value !== 'string') throw new Error(`gen-platform-skill: no English string for "${key}"`);
return value;
};
const byKind = new Map<string, typeof PERMISSIONS>(); const byKind = new Map<string, typeof PERMISSIONS>();
for (const p of PERMISSIONS) { for (const p of PERMISSIONS) {
@@ -112,14 +122,16 @@ export async function renderPlatformSkill(): Promise<string> {
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this'); out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
out('list rather than remembering one.'); out('list rather than remembering one.');
out(); out();
// The registry holds catalog keys since the shell was localized; this doc is agent-facing and stays
// English, so it resolves against en-GB rather than the reader's locale.
for (const section of SHORTCUT_SECTIONS) { for (const section of SHORTCUT_SECTIONS) {
out(`## ${section.title}`); out(`## ${enString(section.titleKey)}`);
if (section.note) { if (section.noteKey) {
out(); out();
out(section.note); out(enString(section.noteKey));
} }
out(); out();
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`); for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${enString(s.descriptionKey)}`);
out(); out();
} }
+28 -3
View File
@@ -30,8 +30,12 @@ const AREAS = [
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages * 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
* that must NEVER be translated; they are reported but excluded from the check, which is why the * that must NEVER be translated; they are reported but excluded from the check, which is why the
* neverTranslate split below has to exist for this entry to be possible at all. * neverTranslate split below has to exist for this entry to be possible at all.
*
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
* `stripUntranslated` skips for the same reason.
*/ */
const ZEROED: string[] = ['shared components']; const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/; const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
@@ -80,7 +84,16 @@ function isHuman(value: string | undefined): value is string {
if (!/[A-Za-z]{3}/.test(s)) return false; if (!/[A-Za-z]{3}/.test(s)) return false;
if (/^[a-z0-9\-_./:]+$/.test(s)) return false; if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false; if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.test(s)) return false; // Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
if (/^[A-Z0-9_.-]+$/.test(s)) return false;
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
// followed by a credential is never something a person reads.
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
// A single token carrying digits AND punctuation is a name, not a sentence — the OCR section's
// placeholder is the example model file `Qwen2.5-VL-7B-Instruct-q4_k_m.gguf`. Prose has spaces; the
// whitespace test is what keeps this from eating real one-word labels, which never look like this.
if (!/\s/.test(s) && /\d/.test(s) && /[-_.]/.test(s)) return false;
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's // Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
// `description: 'group-[.toast]:text-muted-foreground'`. // `description: 'group-[.toast]:text-muted-foreground'`.
// //
@@ -113,6 +126,18 @@ const stripComments = (source: string): string =>
const stripCodeElements = (source: string): string => const stripCodeElements = (source: string): string =>
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>'); source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
/**
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
* another product's UI, which docs/localization.md classifies as an identifier.
*
* Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
* doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
* carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
* it and say why.
*/
const stripUntranslated = (source: string): string =>
source.replace(/<Untranslated\b[^>]*>[\s\S]*?<\/Untranslated>/g, '<Untranslated/>');
function* walk(dir: string): Generator<string> { function* walk(dir: string): Generator<string> {
let entries: string[]; let entries: string[];
try { try {
@@ -144,7 +169,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
files++; files++;
let source: string; let source: string;
try { try {
source = stripCodeElements(stripComments(readFileSync(file, 'utf8'))); source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
} catch { } catch {
continue; continue;
} }
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { useTranslation } from '@/lib/i18n';
export function ForgotPassword() { export function ForgotPassword() {
const { t } = useTranslation();
const client = useClient('/api/auth'); const client = useClient('/api/auth');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
@@ -34,29 +36,29 @@ export function ForgotPassword() {
<Card className="md:py-12 md:px-24 flex flex-col gap-6"> <Card className="md:py-12 md:px-24 flex flex-col gap-6">
{done ? ( {done ? (
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2> <h2 className="text-2xl font-bold text-duck-dark mb-2">{t('auth.checkEmail')}</h2>
<div className="text-sm text-duck-dark/60"> <div className="text-sm text-duck-dark/60">
<p> <p>
A password reset link has been sent to <strong>{state.email}</strong>. A password reset link has been sent to <strong>{state.email}</strong>.
</p> </p>
<p>Click it to reset your password.</p> <p>{t('auth.checkEmailHint')}</p>
</div> </div>
</div> </div>
) : ( ) : (
<> <>
<div className="text-center"> <div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div> <div className="text-duck-dark text-2xl font-bold">{t('auth.forgotTitle')}</div>
<div className="text-duck-dark/60">Enter your email to receive a reset link</div> <div className="text-duck-dark/60">{t('auth.forgotSubtitle')}</div>
</div> </div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4"> <form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span> <span className="text-duck-dark/70">{t('auth.email')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email" type="email"
name="email" name="email"
placeholder="you@example.com" placeholder={t('auth.emailPlaceholder')}
autoComplete="email" autoComplete="email"
/> />
</Label> </Label>
@@ -74,7 +76,7 @@ export function ForgotPassword() {
<div className="text-center text-sm text-duck-dark/60"> <div className="text-center text-sm text-duck-dark/60">
<p> <p>
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80"> <Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
Back to login {t('auth.backToLogin')}
</Link> </Link>
</p> </p>
</div> </div>
@@ -4,8 +4,10 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useResetPassword } from './useResetPassword'; import { useResetPassword } from './useResetPassword';
import { useTranslation } from '@/lib/i18n';
export function ResetPassword() { export function ResetPassword() {
const { t } = useTranslation();
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword(); const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
const loading = tokenStatus === 'loading'; const loading = tokenStatus === 'loading';
const invalid = tokenStatus === 'invalid'; const invalid = tokenStatus === 'invalid';
@@ -15,45 +17,45 @@ export function ResetPassword() {
<> <>
{loading && ( {loading && (
<Card className="py-12 px-24 flex flex-col gap-6"> <Card className="py-12 px-24 flex flex-col gap-6">
<div className="text-center text-duck-dark/60">Verifying...</div> <div className="text-center text-duck-dark/60">{t('auth.verifying')}</div>
</Card> </Card>
)} )}
{invalid && ( {invalid && (
<Card className="py-12 px-24 flex flex-col gap-6"> <Card className="py-12 px-24 flex flex-col gap-6">
<div className="text-center"> <div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div> <div className="text-duck-dark text-2xl font-bold">{t('auth.invalidLink')}</div>
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div> <div className="text-duck-dark/60">{t('auth.invalidLinkHint')}</div>
</div> </div>
</Card> </Card>
)} )}
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}> <Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
<div className="text-center"> <div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Reset Password</div> <div className="text-duck-dark text-2xl font-bold">{t('auth.resetTitle')}</div>
<div className="text-duck-dark/60">Enter your new password</div> <div className="text-duck-dark/60">{t('auth.resetSubtitle')}</div>
</div> </div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4"> <form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<div className="grid md:flex gap-4"> <div className="grid md:flex gap-4">
<Label className="grid gap-2 flex-1"> <Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span> <span className="text-duck-dark/70">{t('auth.password')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
name="password" name="password"
placeholder="Min 12 characters" placeholder={t('auth.minChars')}
autoComplete="new-password" autoComplete="new-password"
/> />
</Label> </Label>
<Label className="grid gap-2 flex-1"> <Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span> <span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
name="confirmPassword" name="confirmPassword"
placeholder="Confirm your password" placeholder={t('auth.confirmPlaceholder')}
autoComplete="new-password" autoComplete="new-password"
/> />
</Label> </Label>
@@ -65,7 +67,7 @@ export function ResetPassword() {
disabled={!isValid || isSubmitting} disabled={!isValid || isSubmitting}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100" className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
> >
{isSubmitting ? 'Resetting...' : 'Reset Password'} {isSubmitting ? 'Resetting...' : t('auth.resetTitle')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -4,8 +4,10 @@ import { toast } from 'sonner';
import { useMounted } from 'hooks/useMounted'; import { useMounted } from 'hooks/useMounted';
import { useForm } from 'hooks/useForm'; import { useForm } from 'hooks/useForm';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
export const useResetPassword = () => { export const useResetPassword = () => {
const { t } = useTranslation();
const isMounted = useMounted(); const isMounted = useMounted();
const navigate = useNavigate(); const navigate = useNavigate();
const client = useClient('/api/auth'); const client = useClient('/api/auth');
@@ -44,7 +46,7 @@ export const useResetPassword = () => {
password: state.password, password: state.password,
verificationCode, verificationCode,
}); });
toast.success('Password reset successfully. Please sign in.'); toast.success(t('auth.resetOk'));
navigate('/'); navigate('/');
} catch (ex) { } catch (ex) {
const error = ex as { message?: string }; const error = ex as { message?: string };
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { useForm } from 'hooks/useForm'; import { useForm } from 'hooks/useForm';
import { useTranslation } from '@/lib/i18n';
export function Bootstrap() { export function Bootstrap() {
const { t } = useTranslation();
const authClient = useClient('/api/auth'); const authClient = useClient('/api/auth');
const navigate = useNavigate(); const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -30,7 +32,7 @@ export function Bootstrap() {
password: state.password, password: state.password,
confirmPassword: state.confirmPassword, confirmPassword: state.confirmPassword,
}); });
toast.success('Administrator account created. Please sign in.'); toast.success(t('auth.bootstrapOk'));
navigate('/'); navigate('/');
} catch (ex) { } catch (ex) {
const error = ex as { message?: string }; const error = ex as { message?: string };
@@ -43,35 +45,35 @@ export function Bootstrap() {
return ( return (
<Card className="md:min-w-[480px] flex flex-col gap-6"> <Card className="md:min-w-[480px] flex flex-col gap-6">
<div className="text-center"> <div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Welcome, Admin</div> <div className="text-duck-dark text-2xl font-bold">{t('auth.bootstrapTitle')}</div>
<div className="text-duck-dark/60">Create your administrator account</div> <div className="text-duck-dark/60">{t('auth.bootstrapSubtitle')}</div>
</div> </div>
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4"> <form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span> <span className="text-duck-dark/70">{t('auth.name')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text" type="text"
name="name" name="name"
placeholder="Your name" placeholder={t('auth.namePlaceholder')}
autoComplete="name" autoComplete="name"
/> />
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span> <span className="text-duck-dark/70">{t('auth.email')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email" type="email"
name="email" name="email"
placeholder="admin@example.com" placeholder={t('auth.adminEmailPlaceholder')}
autoComplete="email" autoComplete="email"
/> />
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span> <span className="text-duck-dark/70">{t('auth.username')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text" type="text"
@@ -83,23 +85,23 @@ export function Bootstrap() {
<div className="grid md:flex gap-4"> <div className="grid md:flex gap-4">
<Label className="grid gap-2 flex-1"> <Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span> <span className="text-duck-dark/70">{t('auth.password')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
name="password" name="password"
placeholder="Min 12 characters" placeholder={t('auth.minChars')}
autoComplete="new-password" autoComplete="new-password"
/> />
</Label> </Label>
<Label className="grid gap-2 flex-1"> <Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span> <span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
name="confirmPassword" name="confirmPassword"
placeholder="Confirm your password" placeholder={t('auth.confirmPlaceholder')}
autoComplete="new-password" autoComplete="new-password"
/> />
</Label> </Label>
@@ -8,12 +8,14 @@ import { toast } from 'sonner';
import { useForm } from 'hooks/useForm'; import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import { useTranslation } from '@/lib/i18n';
const initialState: LoginFormState = { const initialState: LoginFormState = {
// email: 'pastilhas@pastilhas.dev', // email: 'pastilhas@pastilhas.dev',
// password: '', // password: '',
}; };
export function Login() { export function Login() {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm); const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
const { signin } = useAuth(); const { signin } = useAuth();
@@ -40,20 +42,20 @@ export function Login() {
return ( return (
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6"> <Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
<div className="text-center"> <div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div> <div className="text-duck-dark text-2xl font-bold">{t('auth.signInTitle')}</div>
<div className="text-duck-dark/60">Sign in to your account</div> <div className="text-duck-dark/60">{t('auth.signInSubtitle')}</div>
</div> </div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4"> <form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Email or username</span> <span className="text-duck-dark/70">{t('auth.emailOrUsername')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
// `text`, not `email`: the browser refuses to submit a username under type="email", so the // `text`, not `email`: the browser refuses to submit a username under type="email", so the
// field would have validated the user out of an account they have. // field would have validated the user out of an account they have.
type="text" type="text"
name="email" name="email"
placeholder="you@example.com" placeholder={t('auth.emailPlaceholder')}
// `username` rather than `email`, which is what password managers expect for a field that // `username` rather than `email`, which is what password managers expect for a field that
// takes either — and it is the value they already store beside the password. // takes either — and it is the value they already store beside the password.
autoComplete="username" autoComplete="username"
@@ -61,12 +63,12 @@ export function Login() {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Password</span> <span className="text-duck-dark/70">{t('auth.password')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
name="password" name="password"
placeholder="Your password" placeholder={t('auth.passwordPlaceholder')}
autoComplete="current-password" autoComplete="current-password"
/> />
</Label> </Label>
@@ -90,7 +92,7 @@ export function Login() {
{/* </p> */} {/* </p> */}
<p> <p>
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80"> <Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
Forgot password? {t('auth.forgotPassword')}
</Link> </Link>
</p> </p>
</div> </div>
@@ -1,3 +1,5 @@
import { useTranslation } from '@/lib/i18n';
type DebugPanelProps = { type DebugPanelProps = {
fullControlMode: boolean; fullControlMode: boolean;
cameraInfo: { cameraInfo: {
@@ -13,9 +15,10 @@ type DebugPanelProps = {
}; };
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) { export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
const { t } = useTranslation();
return ( return (
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]"> <div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
<div className="font-bold text-blue-400">Controls:</div> <div className="font-bold text-blue-400">{t('auth.debug.controls')}</div>
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}> <div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'} Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
</div> </div>
@@ -29,12 +32,12 @@ export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPane
<div>* Ctrl+Alt+Enter: Toggle full control</div> <div>* Ctrl+Alt+Enter: Toggle full control</div>
<div>* Ctrl+Alt+D: Toggle debug panel</div> <div>* Ctrl+Alt+D: Toggle debug panel</div>
<div className="font-bold text-green-400 pt-2">Camera:</div> <div className="font-bold text-green-400 pt-2">{t('auth.debug.camera')}</div>
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div> <div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div> <div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div> <div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
<div className="font-bold text-yellow-400 pt-2">Model:</div> <div className="font-bold text-yellow-400 pt-2">{t('auth.debug.model')}</div>
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div> <div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div> <div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Scale: {modelInfo.scale.toFixed(2)}</div> <div>Scale: {modelInfo.scale.toFixed(2)}</div>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react'; import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
import { useTranslation } from '@/lib/i18n';
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean }; type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number }; type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
@@ -15,6 +16,7 @@ const MAX_LINES = 600;
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and // params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
// the screen re-derives the param from the registry row — one address shape for both kinds. // the screen re-derives the param from the registry row — one address shape for both kinds.
export const ActivityScreen = () => { export const ActivityScreen = () => {
const { t } = useTranslation();
const { token, get } = useClient(); const { token, get } = useClient();
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] }); const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
const [regLoaded, setRegLoaded] = useState(false); const [regLoaded, setRegLoaded] = useState(false);
@@ -87,30 +89,32 @@ export const ActivityScreen = () => {
<div className="flex h-full w-full"> <div className="flex h-full w-full">
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3"> <aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground"> <div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<ActivityIcon size={16} className="text-primary" /> Activity <ActivityIcon size={16} className="text-primary" /> {t('activity.title')}
</div> </div>
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Background tasks {t('activity.backgroundTasks')}
</div> </div>
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>} {reg.tasks.length === 0 && (
{reg.tasks.map((t) => ( <p className="px-2 py-1 text-xs text-muted-foreground">{t('activity.noneRunning')}</p>
)}
{reg.tasks.map((task) => (
<Link <Link
key={t.id} key={task.id}
to={`/activity/${encodeURIComponent(t.id)}`} to={`/activity/${encodeURIComponent(task.id)}`}
className={rowCls(selectedId === t.id)} className={rowCls(selectedId === task.id)}
title={t.cwd} title={task.cwd}
> >
<span <span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`} className={`inline-block h-2 w-2 shrink-0 rounded-full ${task.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
/> />
<span className="truncate font-mono text-xs">{t.id}</span> <span className="truncate font-mono text-xs">{task.id}</span>
</Link> </Link>
))} ))}
{reg.detached.length > 0 && ( {reg.detached.length > 0 && (
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Detached {t('activity.detached')}
</div> </div>
)} )}
{reg.detached.map((d) => ( {reg.detached.map((d) => (
@@ -169,7 +173,7 @@ export const ActivityScreen = () => {
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
it never started.{' '} it never started.{' '}
<Link to="/activity" className="underline"> <Link to="/activity" className="underline">
Back to the list {t('activity.backToList')}
</Link> </Link>
</> </>
) : ( ) : (
@@ -187,7 +191,7 @@ export const ActivityScreen = () => {
</> </>
) : ( ) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a task to follow its live output {t('activity.selectTask')}
</div> </div>
)} )}
</main> </main>
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { useChat, EmbeddableChat } from 'officerdev'; import { useChat, EmbeddableChat } from 'officerdev';
import { useTranslation } from '@/lib/i18n';
type CapabilitySummary = { type CapabilitySummary = {
dirName: string; dirName: string;
name: string; name: string;
@@ -277,12 +278,18 @@ export const CapabilityChat = ({
description, description,
onResponseEnd, onResponseEnd,
}: CapabilityChatProps) => { }: CapabilityChatProps) => {
const { t } = useTranslation();
const seedFile = `${kind.toUpperCase()}.md`; const seedFile = `${kind.toUpperCase()}.md`;
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`; const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix; const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix;
/*
The composer's PRE-FILLED text, which the reader sees and edits — so it is translated. The hidden
`promptFrontmatter` above is not: that one instructs the agent and carries file paths, and is
machine-facing rather than something anybody reads.
*/
const defaultInput = isNew const defaultInput = isNew
? (description ?? `Help me create the content for this new ${kind} file`) ? (description ?? t('capability.createPrompt', { kind: kind.toLowerCase() }))
: `Help me understand and improve this ${kind} file`; : t('capability.improvePrompt', { kind: kind.toLowerCase() });
const chat = useChat(undefined, undefined, { replaceUrl: false }); const chat = useChat(undefined, undefined, { replaceUrl: false });
@@ -301,6 +308,7 @@ export const CapabilityChat = ({
}; };
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => { export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@@ -310,7 +318,7 @@ export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors" className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
> >
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} /> <ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
<span className="text-xs font-medium">Frontmatter</span> <span className="text-xs font-medium">{t('capability.frontmatter')}</span>
</button> </button>
{open && ( {open && (
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre> <pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
@@ -330,6 +338,7 @@ export const CapabilityList = ({
showCreate, showCreate,
onShowCreateChange, onShowCreateChange,
}: CapabilityListProps) => { }: CapabilityListProps) => {
const { t, tUi } = useTranslation();
const client = useClient(); const client = useClient();
const qc = useQueryClient(); const qc = useQueryClient();
const [internalCreating, setInternalCreating] = useState(false); const [internalCreating, setInternalCreating] = useState(false);
@@ -356,7 +365,7 @@ export const CapabilityList = ({
setNewName(''); setNewName('');
onCreate(res.dirName); onCreate(res.dirName);
} catch { } catch {
toast.error(`Failed to create ${kind}`); toast.error(t('capability.createFailed', { kind: kind.toLowerCase() }));
} }
}; };
@@ -427,7 +436,7 @@ export const CapabilityList = ({
<input <input
value={internalSearch} value={internalSearch}
onChange={(ev) => setInternalSearch(ev.target.value)} onChange={(ev) => setInternalSearch(ev.target.value)}
placeholder={`Search ${kind.toLowerCase()}s...`} placeholder={t('capability.searchPlaceholder', { kind: `${kind.toLowerCase()}s` })}
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30" className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/> />
</div> </div>
@@ -452,7 +461,7 @@ export const CapabilityList = ({
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p> <p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
)} )}
{items.length > 0 && filtered.length === 0 && ( {items.length > 0 && filtered.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p> <p className="text-sm text-duck-dark/40 px-4 py-6 text-center">{t('capability.noMatches')}</p>
)} )}
</div> </div>
</> </>
@@ -469,6 +478,7 @@ export const CapabilityList = ({
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable. * them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
*/ */
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => { export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
const { t, tUi } = useTranslation();
const client = useClient(); const client = useClient();
const qc = useQueryClient(); const qc = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -497,7 +507,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
navigate(basePath, { replace: true }); navigate(basePath, { replace: true });
await qc.invalidateQueries({ queryKey: [queryKey] }); await qc.invalidateQueries({ queryKey: [queryKey] });
} catch { } catch {
toast.error(`Failed to delete ${kind}`); toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() }));
} }
}; };
@@ -526,7 +536,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<ArrowLeft className="h-4 w-4 text-duck-dark/60" /> <ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</Link> </Link>
<span className="text-sm font-medium text-duck-dark/70 flex-1"> <span className="text-sm font-medium text-duck-dark/70 flex-1">
{detail?.name ?? `Select a ${kind.toLowerCase()}`} {detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
</span> </span>
{detail && ( {detail && (
<> <>
@@ -554,10 +564,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
</ReactMarkdown> </ReactMarkdown>
</article> </article>
) : detail ? ( ) : detail ? (
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p> <p className="text-sm text-duck-dark/40 text-center mt-12">{t('capability.emptyFile')}</p>
) : ( ) : (
<p className="text-sm text-duck-dark/40 text-center mt-12"> <p className="text-sm text-duck-dark/40 text-center mt-12">
Select a {kind.toLowerCase()} to view its contents {t('capability.selectToView', { kind: kind.toLowerCase() })}
</p> </p>
)} )}
</div> </div>
@@ -606,10 +616,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
</DialogHeader> </DialogHeader>
<div className="flex justify-end gap-2 mt-2"> <div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer"> <Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
Cancel {tUi('button.cancel')}
</Button> </Button>
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer"> <Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
Delete {t('capability.delete')}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -9,6 +9,7 @@ import { errorText } from 'helpers/error-text';
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import { defaultLayout, hasAppType } from './defaultLayout'; import { defaultLayout, hasAppType } from './defaultLayout';
import { useTranslation } from '@/lib/i18n';
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in. // How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
const CHAT_TAIL = 20; const CHAT_TAIL = 20;
@@ -18,6 +19,7 @@ type SessionListPageProps = {
}; };
export const SessionListPage = ({ isNew }: SessionListPageProps) => { export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { t } = useTranslation();
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts. // The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
const groupCwd = cwdFromSplat(splat); const groupCwd = cwdFromSplat(splat);
@@ -122,7 +124,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and // Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
// no hint that the transcript could not be read, which is indistinguishable from a new session. // no hint that the transcript could not be read, which is indistinguishable from a new session.
// Most often the id is stale — the transcript was deleted or pruned out from under the link. // Most often the id is stale — the transcript was deleted or pruned out from under the link.
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`); toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') }));
setSelected({ id: sessionId }); setSelected({ id: sessionId });
} }
})(); })();
@@ -1,12 +1,16 @@
import { CodeEditorView } from 'officerdev'; import { CodeEditorView } from 'officerdev';
import { Widget } from 'widgets/Widget'; import { Widget } from 'widgets/Widget';
import { useTranslation } from '@/lib/i18n';
export const CodeEditor = () => ( export const CodeEditor = () => {
<div className="h-full w-full flex items-center justify-center"> const { t } = useTranslation();
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable> return (
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel <div className="h-full w-full flex items-center justify-center">
<Widget title={t('nav.codeEditor')} className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
wrapper deliberately does not pass this: a dashboard can hold two editors. */} wrapper deliberately does not pass this: a dashboard can hold two editors. */}
<CodeEditorView className="h-full w-full" urlState /> <CodeEditorView className="h-full w-full" urlState />
</Widget> </Widget>
</div> </div>
); );
};
@@ -3,6 +3,9 @@ import { useParams, Link } from 'react-router';
import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react'; import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from '@/lib/i18n';
type TFn = ReturnType<typeof useTranslation>['t'];
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled // Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
// from the job's persisted progress (the executor emits counter snapshots, not per-item events). // from the job's persisted progress (the executor emits counter snapshots, not per-item events).
@@ -29,26 +32,40 @@ type DownloadJob = {
const POLL_MS = 1500; const POLL_MS = 1500;
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted'; const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
const statusBadge = (status: string) => { // Takes `t` because it is a module-level helper, not a component — the alternative is a hook
// call outside a component body.
const statusBadge = (status: string, t: TFn) => {
switch (status) { switch (status) {
case 'running': case 'running':
return { return {
icon: <Loader2 className="h-4 w-4 animate-spin" />, icon: <Loader2 className="h-4 w-4 animate-spin" />,
label: 'Running', label: t('jobs.status.running'),
cls: 'text-amber-600 dark:text-amber-500', cls: 'text-amber-600 dark:text-amber-500',
}; };
case 'pending': case 'pending':
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' }; return {
icon: <Clock className="h-4 w-4" />,
label: t('jobs.status.queued'),
cls: 'text-blue-600 dark:text-blue-400',
};
case 'completed': case 'completed':
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' }; return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
case 'failed': case 'failed':
return { icon: <XCircle className="h-4 w-4" />, label: 'Failed', cls: 'text-red-600 dark:text-red-400' }; return {
icon: <XCircle className="h-4 w-4" />,
label: t('jobs.status.failed'),
cls: 'text-red-600 dark:text-red-400',
};
case 'stopped': case 'stopped':
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' }; return {
icon: <Ban className="h-4 w-4" />,
label: t('jobs.status.stopped'),
cls: 'text-duck-dark/50 dark:text-foreground/50',
};
default: default:
return { return {
icon: <XCircle className="h-4 w-4" />, icon: <XCircle className="h-4 w-4" />,
label: 'Interrupted', label: t('jobs.status.interrupted'),
cls: 'text-orange-600 dark:text-orange-400', cls: 'text-orange-600 dark:text-orange-400',
}; };
} }
@@ -100,6 +117,7 @@ export const PhaseBar = ({
}; };
export const DownloadJobDetail = () => { export const DownloadJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const client = useClient(); const client = useClient();
const [job, setJob] = useState<DownloadJob | null>(null); const [job, setJob] = useState<DownloadJob | null>(null);
@@ -134,9 +152,9 @@ export const DownloadJobDetail = () => {
if (!id) return; if (!id) return;
try { try {
await client.post(`/jobs/${id}/stop`, {}); await client.post(`/jobs/${id}/stop`, {});
toast.success('Stop requested'); toast.success(t('jobs.stopRequested'));
} catch { } catch {
toast.error('Failed to stop'); toast.error(t('jobs.stopFailed'));
} }
}; };
@@ -149,14 +167,14 @@ export const DownloadJobDetail = () => {
if (!job) if (!job)
return ( return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60"> <div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
<span>Job not found.</span> <span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal hover:underline"> <Link to="/jobs" className="text-duck-teal hover:underline">
Back to jobs {t('jobs.backToJobs')}
</Link> </Link>
</div> </div>
); );
const badge = statusBadge(job.status); const badge = statusBadge(job.status, t);
const running = !isTerminal(job.status); const running = !isTerminal(job.status);
const p = job.progress; const p = job.progress;
const isAudio = job.inputs?.format !== 'video'; const isAudio = job.inputs?.format !== 'video';
@@ -197,7 +215,7 @@ export const DownloadJobDetail = () => {
onClick={stop} onClick={stop}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
> >
<Square className="h-3 w-3" /> Stop <Square className="h-3 w-3" /> {t('jobs.stop')}
</button> </button>
)} )}
</div> </div>
@@ -206,14 +224,14 @@ export const DownloadJobDetail = () => {
<div className="mx-auto flex max-w-md flex-col gap-5"> <div className="mx-auto flex max-w-md flex-col gap-5">
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div> <div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
<PhaseBar <PhaseBar
label="Titles" label={t('jobs.titles')}
c={p?.meta ?? { done: 0, failed: 0, total: 0 }} c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
active={p?.phase === 'metadata'} active={p?.phase === 'metadata'}
savedLabel="found" savedLabel="found"
failedLabel="skipped" failedLabel="skipped"
/> />
<PhaseBar <PhaseBar
label="Download" label={t('jobs.download')}
c={p?.dl ?? { done: 0, failed: 0, total: 0 }} c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
active={p?.phase === 'download'} active={p?.phase === 'download'}
savedLabel="saved" savedLabel="saved"
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { WorkspaceLayout } from 'officerdev'; import { WorkspaceLayout } from 'officerdev';
import type { LayoutNode, PanelComponents } from 'officerdev'; import type { LayoutNode, PanelComponents } from 'officerdev';
import { randomId } from 'helpers/random-id'; import { randomId } from 'helpers/random-id';
import { useTranslation } from '@/lib/i18n';
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number }; type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
@@ -162,6 +163,7 @@ type ToolCallEntryProps = {
}; };
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => { const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
return ( return (
@@ -183,14 +185,14 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
{expanded && ( {expanded && (
<div className="p-2.5 space-y-2 border-t border-duck-dark/10"> <div className="p-2.5 space-y-2 border-t border-duck-dark/10">
<div> <div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Input</div> <div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.input')}</div>
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto"> <pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
{JSON.stringify(entry.toolInput, null, 2)} {JSON.stringify(entry.toolInput, null, 2)}
</pre> </pre>
</div> </div>
{entry.output !== undefined && ( {entry.output !== undefined && (
<div> <div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div> <div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.output')}</div>
<pre <pre
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`} className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
> >
@@ -236,6 +238,7 @@ const DEFAULT_LAYOUT: LayoutNode = {
}; };
const StepsPanel = () => { const StepsPanel = () => {
const { t } = useTranslation();
const ctx = useJobPanel(); const ctx = useJobPanel();
const { const {
displaySteps, displaySteps,
@@ -255,7 +258,7 @@ const StepsPanel = () => {
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<div className="px-3 py-2 border-b border-duck-dark/10"> <div className="px-3 py-2 border-b border-duck-dark/10">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Steps</h2> <h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.steps')}</h2>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{displaySteps.map((step, i) => { {displaySteps.map((step, i) => {
@@ -336,6 +339,7 @@ const StepsPanel = () => {
}; };
const OutputPanel = () => { const OutputPanel = () => {
const { t } = useTranslation();
const ctx = useJobPanel(); const ctx = useJobPanel();
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx; const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
@@ -345,7 +349,7 @@ const OutputPanel = () => {
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2"> <div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2> <h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.output')}</h2>
{selectedKey && ( {selectedKey && (
<span className="text-xs text-duck-dark/40 truncate"> <span className="text-xs text-duck-dark/40 truncate">
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`} {selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
@@ -355,12 +359,12 @@ const OutputPanel = () => {
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs"> <div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
{!selectedKey && ( {!selectedKey && (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans"> <div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
Select a step or sub-task to view output {t('jobs.selectStep')}
</div> </div>
)} )}
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && ( {selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans"> <div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
No output yet {t('jobs.noOutput')}
</div> </div>
)} )}
{selectedOutputs.map((entry) => { {selectedOutputs.map((entry) => {
@@ -385,6 +389,7 @@ const OutputPanel = () => {
}; };
export const PipelineJobDetail = () => { export const PipelineJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const client = useClient(); const client = useClient();
@@ -792,15 +797,15 @@ export const PipelineJobDetail = () => {
); );
if (isLoading) { if (isLoading) {
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>; return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">{t('jobs.loading')}</div>;
} }
if (!job) { if (!job) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm"> <div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
<span>Job not found</span> <span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline"> <Link to="/jobs" className="text-duck-teal text-xs hover:underline">
Back to jobs {t('jobs.backToJobs')}
</Link> </Link>
</div> </div>
); );
@@ -821,7 +826,11 @@ export const PipelineJobDetail = () => {
<Card className="px-4 py-3 shrink-0"> <Card className="px-4 py-3 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* The other two job detail panes already do this; this was the last imperative back in Jobs. */} {/* The other two job detail panes already do this; this was the last imperative back in Jobs. */}
<Link to="/jobs" aria-label="Back to jobs" className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer"> <Link
to="/jobs"
aria-label={t('jobs.backToJobs')}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer"
>
<ArrowLeft className="h-4 w-4 text-duck-dark/60" /> <ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</Link> </Link>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -837,7 +846,7 @@ export const PipelineJobDetail = () => {
{isRunning && ( {isRunning && (
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5"> <Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
<Square className="h-3 w-3" /> <Square className="h-3 w-3" />
Stop {t('jobs.stop')}
</Button> </Button>
)} )}
</div> </div>
@@ -852,13 +861,13 @@ export const PipelineJobDetail = () => {
{displayCost && ( {displayCost && (
<> <>
<div className="flex items-center gap-1.5 text-duck-dark/60"> <div className="flex items-center gap-1.5 text-duck-dark/60">
<span className="text-duck-dark/40">Tokens:</span> <span className="text-duck-dark/40">{t('jobs.tokens')}</span>
<span className="font-mono tabular-nums"> <span className="font-mono tabular-nums">
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out {formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 text-duck-dark/60"> <div className="flex items-center gap-1.5 text-duck-dark/60">
<span className="text-duck-dark/40">Cost:</span> <span className="text-duck-dark/40">{t('jobs.cost')}</span>
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span> <span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
</div> </div>
</> </>
@@ -22,6 +22,7 @@ import { Card } from '@/components/Card';
import { ScriptJobDetail } from './ScriptJobDetail'; import { ScriptJobDetail } from './ScriptJobDetail';
import { DownloadJobDetail } from './DownloadJobDetail'; import { DownloadJobDetail } from './DownloadJobDetail';
import { PipelineJobDetail } from './JobDetail'; import { PipelineJobDetail } from './JobDetail';
import { useTranslation } from '@/lib/i18n';
type JobSummary = { type JobSummary = {
id: string; id: string;
@@ -99,6 +100,7 @@ const useJobsData = () => {
type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void }; type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void };
const JobRow = ({ job, onAction }: JobRowProps) => { const JobRow = ({ job, onAction }: JobRowProps) => {
const { t } = useTranslation();
const { id: activeId } = useParams<{ id: string }>(); const { id: activeId } = useParams<{ id: string }>();
const isRunning = job.status === 'running'; const isRunning = job.status === 'running';
const target = basename(job.target); const target = basename(job.target);
@@ -126,7 +128,7 @@ const JobRow = ({ job, onAction }: JobRowProps) => {
</Link> </Link>
<button <button
onClick={(ev) => onAction(ev, job)} onClick={(ev) => onAction(ev, job)}
title={isRunning ? 'Stop' : 'Delete'} title={isRunning ? t('jobs.stop') : 'Delete'}
className="shrink-0 mr-2 p-1.5 rounded-md text-duck-dark/40 hover:text-red-500 hover:bg-red-500/10 md:opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer" className="shrink-0 mr-2 p-1.5 rounded-md text-duck-dark/40 hover:text-red-500 hover:bg-red-500/10 md:opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
> >
{isRunning ? <Square className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />} {isRunning ? <Square className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />}
@@ -144,6 +146,7 @@ const PanelHeader = ({ children }: { children: ReactNode }) => (
// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord // real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord
// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it. // today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it.
const TestNotificationButton = () => { const TestNotificationButton = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -157,12 +160,12 @@ const TestNotificationButton = () => {
); );
const results = res?.results ?? []; const results = res?.results ?? [];
if (results.length === 0) { if (results.length === 0) {
toast.warning('No channels configured'); toast.warning(t('jobs.noChannels'));
} else { } else {
toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', ')); toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', '));
} }
} catch { } catch {
toast.error('Failed to send notification'); toast.error(t('jobs.notifyFailed'));
} finally { } finally {
setSending(false); setSending(false);
} }
@@ -172,7 +175,7 @@ const TestNotificationButton = () => {
<button <button
onClick={send} onClick={send}
disabled={sending} disabled={sending}
title="Send a test notification to every configured channel" title={t('jobs.testNotification')}
className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40" className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40"
> >
<Bell className="h-3.5 w-3.5" /> <Bell className="h-3.5 w-3.5" />
@@ -182,6 +185,7 @@ const TestNotificationButton = () => {
}; };
const ActiveJobsPanel = () => { const ActiveJobsPanel = () => {
const { t } = useTranslation();
const { jobs, isLoading, act } = useJobsData(); const { jobs, isLoading, act } = useJobsData();
const active = [ const active = [
...jobs.filter((j) => j.status === 'running'), ...jobs.filter((j) => j.status === 'running'),
@@ -191,15 +195,17 @@ const ActiveJobsPanel = () => {
<div className="h-full p-2"> <div className="h-full p-2">
<Card className="h-full flex flex-col overflow-hidden"> <Card className="h-full flex flex-col overflow-hidden">
<PanelHeader> <PanelHeader>
<h2 className="text-sm font-semibold text-duck-dark">Running &amp; Queued</h2> <h2 className="text-sm font-semibold text-duck-dark">{t('jobs.runningQueued')}</h2>
{active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>} {active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>}
<TestNotificationButton /> <TestNotificationButton />
</PanelHeader> </PanelHeader>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div> <div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
) : active.length === 0 ? ( ) : active.length === 0 ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Nothing running</div> <div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
{t('jobs.nothingRunning')}
</div>
) : ( ) : (
active.map((job) => <JobRow key={job.id} job={job} onAction={act} />) active.map((job) => <JobRow key={job.id} job={job} onAction={act} />)
)} )}
@@ -211,6 +217,7 @@ const ActiveJobsPanel = () => {
// Bottom-left panel: finished / failed / stopped, newest first, searchable. // Bottom-left panel: finished / failed / stopped, newest first, searchable.
const HistoryJobsPanel = () => { const HistoryJobsPanel = () => {
const { t } = useTranslation();
const { jobs, isLoading, act, clearHistory } = useJobsData(); const { jobs, isLoading, act, clearHistory } = useJobsData();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const history = jobs const history = jobs
@@ -224,12 +231,12 @@ const HistoryJobsPanel = () => {
<div className="h-full p-2"> <div className="h-full p-2">
<Card className="h-full flex flex-col overflow-hidden"> <Card className="h-full flex flex-col overflow-hidden">
<PanelHeader> <PanelHeader>
<h2 className="text-sm font-semibold text-duck-dark shrink-0">History</h2> <h2 className="text-sm font-semibold text-duck-dark shrink-0">{t('jobs.history')}</h2>
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
<input <input
type="text" type="text"
placeholder="Search…" placeholder={t('jobs.search')}
value={search} value={search}
onChange={(ev) => setSearch(ev.target.value)} onChange={(ev) => setSearch(ev.target.value)}
className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40" className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
@@ -238,16 +245,16 @@ const HistoryJobsPanel = () => {
{history.length > 0 && ( {history.length > 0 && (
<button <button
onClick={clearHistory} onClick={clearHistory}
title="Delete all finished jobs" title={t('jobs.deleteFinished')}
className="shrink-0 text-xs font-medium text-duck-dark/50 hover:text-red-500 cursor-pointer" className="shrink-0 text-xs font-medium text-duck-dark/50 hover:text-red-500 cursor-pointer"
> >
Clear all {t('jobs.clearAll')}
</button> </button>
)} )}
</PanelHeader> </PanelHeader>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div> <div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
) : history.length === 0 ? ( ) : history.length === 0 ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs"> <div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
{search ? 'No matches' : 'No finished jobs'} {search ? 'No matches' : 'No finished jobs'}
@@ -263,6 +270,7 @@ const HistoryJobsPanel = () => {
// ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ── // ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ──
const JobDetailPanel = () => { const JobDetailPanel = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const client = useClient(); const client = useClient();
const [mode, setMode] = useState<string | null>(null); const [mode, setMode] = useState<string | null>(null);
@@ -284,7 +292,7 @@ const JobDetailPanel = () => {
<div className="h-full p-2"> <div className="h-full p-2">
<Card className="h-full flex flex-col items-center justify-center gap-2 text-duck-dark/30"> <Card className="h-full flex flex-col items-center justify-center gap-2 text-duck-dark/30">
<Inbox className="h-8 w-8" /> <Inbox className="h-8 w-8" />
<span className="text-sm">Select a job</span> <span className="text-sm">{t('jobs.selectJob')}</span>
</Card> </Card>
</div> </div>
); );
@@ -301,7 +309,7 @@ const JobDetailPanel = () => {
if (mode === 'notfound') { if (mode === 'notfound') {
return ( return (
<div className="h-full p-2"> <div className="h-full p-2">
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">Job not found.</Card> <Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">{t('jobs.notFound')}</Card>
</div> </div>
); );
} }
@@ -4,6 +4,9 @@ import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'l
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { PhaseBar, type DownloadProgress } from './DownloadJobDetail'; import { PhaseBar, type DownloadProgress } from './DownloadJobDetail';
import { useTranslation } from '@/lib/i18n';
type TFn = ReturnType<typeof useTranslation>['t'];
type ScriptJob = { type ScriptJob = {
id: string; id: string;
@@ -27,36 +30,46 @@ const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === '
const isDownloadProgress = (p: unknown): p is DownloadProgress => const isDownloadProgress = (p: unknown): p is DownloadProgress =>
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p; !!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
const statusBadge = (status: string, exitCode: number | null) => { // Takes `t` because it is a module-level helper, not a component.
const statusBadge = (status: string, exitCode: number | null, t: TFn) => {
switch (status) { switch (status) {
case 'running': case 'running':
return { return {
icon: <Loader2 className="h-4 w-4 animate-spin" />, icon: <Loader2 className="h-4 w-4 animate-spin" />,
label: 'Running', label: t('jobs.status.running'),
cls: 'text-amber-600 dark:text-amber-500', cls: 'text-amber-600 dark:text-amber-500',
}; };
case 'pending': case 'pending':
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' }; return {
icon: <Clock className="h-4 w-4" />,
label: t('jobs.status.queued'),
cls: 'text-blue-600 dark:text-blue-400',
};
case 'completed': case 'completed':
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' }; return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
case 'failed': case 'failed':
return { return {
icon: <XCircle className="h-4 w-4" />, icon: <XCircle className="h-4 w-4" />,
label: `Failed${exitCode != null ? ` (exit ${exitCode})` : ''}`, label: exitCode != null ? t('jobs.failedWithCode', { code: exitCode }) : t('jobs.status.failed'),
cls: 'text-red-600 dark:text-red-400', cls: 'text-red-600 dark:text-red-400',
}; };
case 'stopped': case 'stopped':
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' }; return {
icon: <Ban className="h-4 w-4" />,
label: t('jobs.status.stopped'),
cls: 'text-duck-dark/50 dark:text-foreground/50',
};
default: default:
return { return {
icon: <XCircle className="h-4 w-4" />, icon: <XCircle className="h-4 w-4" />,
label: 'Interrupted', label: t('jobs.status.interrupted'),
cls: 'text-orange-600 dark:text-orange-400', cls: 'text-orange-600 dark:text-orange-400',
}; };
} }
}; };
export const ScriptJobDetail = () => { export const ScriptJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const client = useClient(); const client = useClient();
@@ -137,9 +150,9 @@ export const ScriptJobDetail = () => {
if (!id) return; if (!id) return;
try { try {
await client.post(`/jobs/${id}/stop`, {}); await client.post(`/jobs/${id}/stop`, {});
toast.success('Stop requested'); toast.success(t('jobs.stopRequested'));
} catch { } catch {
toast.error('Failed to stop'); toast.error(t('jobs.stopFailed'));
} }
}; };
@@ -153,15 +166,15 @@ export const ScriptJobDetail = () => {
if (!job) { if (!job) {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60"> <div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
<span>Job not found.</span> <span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal hover:underline"> <Link to="/jobs" className="text-duck-teal hover:underline">
Back to jobs {t('jobs.backToJobs')}
</Link> </Link>
</div> </div>
); );
} }
const badge = statusBadge(job.status, job.exitCode); const badge = statusBadge(job.status, job.exitCode, t);
const running = !isTerminal(job.status); const running = !isTerminal(job.status);
return ( return (
@@ -185,7 +198,7 @@ export const ScriptJobDetail = () => {
onClick={stop} onClick={stop}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
> >
<Square className="h-3 w-3" /> Stop <Square className="h-3 w-3" /> {t('jobs.stop')}
</button> </button>
)} )}
</div> </div>
@@ -193,14 +206,14 @@ export const ScriptJobDetail = () => {
{isDownloadProgress(job.progress) && ( {isDownloadProgress(job.progress) && (
<div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4"> <div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4">
<PhaseBar <PhaseBar
label="Titles" label={t('jobs.titles')}
c={job.progress.meta} c={job.progress.meta}
active={job.progress.phase === 'metadata'} active={job.progress.phase === 'metadata'}
savedLabel="found" savedLabel="found"
failedLabel="skipped" failedLabel="skipped"
/> />
<PhaseBar <PhaseBar
label="Download" label={t('jobs.download')}
c={job.progress.dl} c={job.progress.dl}
active={job.progress.phase === 'download'} active={job.progress.phase === 'download'}
savedLabel="saved" savedLabel="saved"
@@ -66,12 +66,16 @@ export const ShortcutsModal = () => {
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */} {/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2"> <div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
{SHORTCUT_SECTIONS.map((section) => ( {SHORTCUT_SECTIONS.map((section) => (
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4"> <section key={section.titleKey} className="rounded-xl border border-border bg-muted/20 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>} {t(section.titleKey)}
</h3>
{section.noteKey && (
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{t(section.noteKey)}</p>
)}
<div className="mt-2"> <div className="mt-2">
{section.shortcuts.map((shortcut) => ( {section.shortcuts.map((shortcut) => (
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} /> <Row key={shortcut.keys} keys={shortcut.keys} description={t(shortcut.descriptionKey)} />
))} ))}
</div> </div>
</section> </section>
@@ -4,8 +4,10 @@ import { KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
export const PasskeyGate = () => { export const PasskeyGate = () => {
const { t } = useTranslation();
const { user, createPasskeyCredentials } = useAuth(); const { user, createPasskeyCredentials } = useAuth();
const [isRegistering, setIsRegistering] = useState(false); const [isRegistering, setIsRegistering] = useState(false);
@@ -31,7 +33,7 @@ export const PasskeyGate = () => {
<KeyRound className="h-8 w-8 text-duck-teal" /> <KeyRound className="h-8 w-8 text-duck-teal" />
</div> </div>
<h2 className="text-2xl font-bold text-duck-dark">Set up your passkey</h2> <h2 className="text-2xl font-bold text-duck-dark">{t('passkeyGate.setUp')}</h2>
<p className="text-duck-dark/70"> <p className="text-duck-dark/70">
Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type ApifyConfigData = { type ApifyConfigData = {
apiToken: string; apiToken: string;
@@ -15,6 +16,7 @@ type ApifyStatus = {
}; };
export const ApifyConfig = () => { export const ApifyConfig = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -38,10 +40,10 @@ export const ApifyConfig = () => {
setIsSaving(true); setIsSaving(true);
try { try {
await client.put('/integrations/apify/config', { apiToken: apiToken.trim() }); await client.put('/integrations/apify/config', { apiToken: apiToken.trim() });
toast.success('Apify API token saved'); toast.success(t('settings.apify.saved'));
setStatus({ configured: !!apiToken.trim() }); setStatus({ configured: !!apiToken.trim() });
} catch { } catch {
toast.error('Failed to save Apify API token'); toast.error(t('settings.apify.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -64,7 +66,7 @@ export const ApifyConfig = () => {
<form onSubmit={handleSave} className="grid gap-5"> <form onSubmit={handleSave} className="grid gap-5">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Token</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.apify.token')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
@@ -7,6 +7,8 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard'; import { copyToClipboard } from 'helpers/clipboard';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird). // Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
// //
@@ -26,19 +28,21 @@ type DavPassword = {
type Minted = { password: string; username: string; label: string }; type Minted = { password: string; username: string; label: string };
const formatDate = (value: string | null) => // Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(`${what} copied`);
} catch {
toast.error('Could not copy — select and copy manually');
}
};
export const DavAppPasswords = () => { export const DavAppPasswords = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.dav.copied', { what }));
} catch {
toast.error(t('settings.dav.copyFailed'));
}
};
const client = useClient(); const client = useClient();
const [passwords, setPasswords] = useState<DavPassword[]>([]); const [passwords, setPasswords] = useState<DavPassword[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -71,7 +75,7 @@ export const DavAppPasswords = () => {
setLabel(''); setLabel('');
load(); load();
} catch { } catch {
toast.error('Could not create the app password'); toast.error(t('settings.dav.createFailed'));
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -80,10 +84,10 @@ export const DavAppPasswords = () => {
const revoke = async (id: number) => { const revoke = async (id: number) => {
try { try {
await client.post(`/dav/passwords/${id}/revoke`, {}); await client.post(`/dav/passwords/${id}/revoke`, {});
toast.success('Revoked — that device will stop syncing'); toast.success(t('settings.dav.revoked'));
load(); load();
} catch { } catch {
toast.error('Could not revoke'); toast.error(t('settings.dav.revokeFailed'));
} }
}; };
@@ -92,7 +96,7 @@ export const DavAppPasswords = () => {
await client.delete(`/dav/passwords/${id}`); await client.delete(`/dav/passwords/${id}`);
load(); load();
} catch { } catch {
toast.error('Could not delete'); toast.error(t('settings.dav.deleteFailed'));
} }
}; };
@@ -126,7 +130,12 @@ export const DavAppPasswords = () => {
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs"> <code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{value} {value}
</code> </code>
<Button variant="ghost" size="sm" onClick={() => copy(value, field)} title={`Copy ${field}`}> <Button
variant="ghost"
size="sm"
onClick={() => copy(value, field)}
title={t('settings.dav.copyField', { field })}
>
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -146,29 +155,31 @@ export const DavAppPasswords = () => {
<form onSubmit={create} className="flex items-end gap-2"> <form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2"> <Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New device</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20" className="h-11 bg-background/60 border-duck-dark/20"
value={label} value={label}
onChange={(ev) => setLabel(ev.target.value)} onChange={(ev) => setLabel(ev.target.value)}
placeholder="Pixel 9, iPad, Thunderbird…" placeholder={t('settings.dav.placeholder')}
/> />
</Label> </Label>
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11"> <Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" /> <Plus className="mr-1.5 h-4 w-4" />
Create {t('settings.dav.create')}
</Button> </Button>
</form> </form>
{passwords.length === 0 ? ( {passwords.length === 0 ? (
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40"> <div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
No devices yet. {t('settings.dav.none')}
</div> </div>
) : ( ) : (
<div className="grid gap-2"> <div className="grid gap-2">
{passwords.map((entry) => { {passwords.map((entry) => {
const revoked = !!entry.revokedAt; const revoked = !!entry.revokedAt;
const lastUsed = formatDate(entry.lastUsedAt); const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return ( return (
<div <div
key={entry.id} key={entry.id}
@@ -192,7 +203,7 @@ export const DavAppPasswords = () => {
</div> </div>
</div> </div>
{!revoked && ( {!revoked && (
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title="Revoke"> <Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
<Ban className="h-3.5 w-3.5" /> <Ban className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
@@ -200,7 +211,7 @@ export const DavAppPasswords = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => remove(entry.id)} onClick={() => remove(entry.id)}
title="Delete" title={t('settings.dav.delete')}
className="hover:text-red-500" className="hover:text-red-500"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type GoogleStatus = { type GoogleStatus = {
connected: boolean; connected: boolean;
@@ -11,6 +12,7 @@ type GoogleStatus = {
}; };
export const GoogleAccount = () => { export const GoogleAccount = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<GoogleStatus>({ const [status, setStatus] = useState<GoogleStatus>({
@@ -30,9 +32,9 @@ export const GoogleAccount = () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const result = params.get('google'); const result = params.get('google');
if (result === 'success') { if (result === 'success') {
toast.success('Google account connected'); toast.success(t('settings.googleAccount.connectedToast'));
} else if (result === 'error') { } else if (result === 'error') {
toast.error('Failed to connect Google account'); toast.error(t('settings.googleAccount.connectFailed'));
} }
if (result) { if (result) {
window.history.replaceState({}, '', window.location.pathname); window.history.replaceState({}, '', window.location.pathname);
@@ -51,9 +53,9 @@ export const GoogleAccount = () => {
try { try {
await client.delete('/integrations/google/connection'); await client.delete('/integrations/google/connection');
setStatus({ ...status, connected: false, email: null }); setStatus({ ...status, connected: false, email: null });
toast.success('Google account disconnected'); toast.success(t('settings.googleAccount.disconnectedToast'));
} catch { } catch {
toast.error('Failed to disconnect Google account'); toast.error(t('settings.googleAccount.disconnectFailed'));
} }
}; };
@@ -63,17 +65,17 @@ export const GoogleAccount = () => {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div> <div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Google Account</p> <p className="text-sm font-medium text-duck-dark dark:text-foreground">{t('settings.googleAccount.title')}</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1"> <p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">{t('settings.googleAccount.hint')}</p>
Connect your Google account for Calendar, Contacts, and other Google services.
</p>
</div> </div>
{status.connected ? ( {status.connected ? (
<> <>
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"> <div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-success shrink-0" /> <div className="h-2.5 w-2.5 rounded-full bg-success shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p> <p className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.googleAccount.connected')}
</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p> <p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
</div> </div>
{status.picture && ( {status.picture && (
@@ -81,7 +83,7 @@ export const GoogleAccount = () => {
)} )}
</div> </div>
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer"> <Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
Disconnect {t('settings.googleAccount.disconnect')}
</Button> </Button>
</> </>
) : ( ) : (
@@ -90,7 +92,7 @@ export const GoogleAccount = () => {
onClick={handleConnect} onClick={handleConnect}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer" className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
> >
Connect Google Account {t('settings.googleAccount.connect')}
</Button> </Button>
)} )}
</div> </div>
@@ -7,197 +7,175 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
import { Untranslated } from '@/lib/Untranslated';
type GoogleOAuthSettings = { type GoogleOAuthSettings = {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
}; };
// The scope strings are Google's own identifiers; only the explanation beside them is ours.
const SCOPES = [ const SCOPES = [
{ scope: 'gmail.readonly', description: 'Read emails' }, { scope: 'gmail.readonly', descriptionKey: 'settings.google.scopeGmail' },
{ scope: 'calendar.readonly', description: 'Read calendar events' }, { scope: 'calendar.readonly', descriptionKey: 'settings.google.scopeCalendar' },
]; ] as const;
const SetupGuide = ({ redirectUri }: { redirectUri: string }) => { const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation();
/*
Every quoted Google-console label below is wrapped in <Untranslated>. Per docs/localization.md they
are identifiers, not prose: they exist to match what the reader sees on Google's screen, and Google
localises by the reader's Google account language rather than ours.
The sentences are keyed WHOLE, each ending at the label or link it introduces. The first attempt
split them around inline labels, which needs "Go to the" and "page." as separate keys — and the
Portuguese value of the second one is "." That is the fragmentation smell, so the English was
rephrased to put the label at a clause boundary, which reads naturally in both languages.
*/
const GoogleLink = ({ href, label }: { href: string; label: string }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<Untranslated reason="foreign-ui-label">{label}</Untranslated>
</a>
);
return ( return (
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full"> <CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} /> <ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
Step-by-step setup guide {t('settings.google.guideToggle')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5"> <ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step1.title')}</strong>
<p className="mt-1"> <p className="mt-1">
Go to the{' '} {t('settings.google.step1.body')}{' '}
<a <GoogleLink
href="https://console.cloud.google.com/projectcreate" href="https://console.cloud.google.com/projectcreate"
target="_blank" label={t('settings.google.step1.link')}
rel="noopener noreferrer" />{' '}
className="text-duck-teal underline" → <Untranslated reason="foreign-ui-label">Create</Untranslated>
>
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
</p> </p>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step2.title')}</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} {t('settings.google.step2.body')}{' '}
<a <GoogleLink
href="https://console.cloud.google.com/apis/library" href="https://console.cloud.google.com/apis/library"
target="_blank" label={t('settings.google.step2.link')}
rel="noopener noreferrer" />{' '}
className="text-duck-teal underline" → <Untranslated reason="foreign-ui-label">Enable</Untranslated>
>
API Library
</a>
. Search for and enable each of these:
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li> <li>
<strong>Gmail API</strong> <Untranslated reason="foreign-ui-label">Gmail API</Untranslated>
</li> </li>
<li> <li>
<strong>Google Calendar API</strong> <Untranslated reason="foreign-ui-label">Google Calendar API</Untranslated>
</li> </li>
</ul> </ul>
<p className="mt-1">
Click each one, then click <strong>Enable</strong>.
</p>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step3.title')}</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} <GoogleLink
<a
href="https://console.cloud.google.com/auth/branding" href="https://console.cloud.google.com/auth/branding"
target="_blank" label={t('settings.google.step3.link')}
rel="noopener noreferrer" />
className="text-duck-teal underline"
>
OAuth Branding
</a>
.
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li> <li>
Set <strong>App name</strong> to your organization name or &quot;Officer&quot; {t('settings.google.step3.appName')} <Untranslated reason="foreign-ui-label">App name</Untranslated>
</li> </li>
<li> <li>
Set <strong>User support email</strong> to your admin email {t('settings.google.step3.supportEmail')}{' '}
<Untranslated reason="foreign-ui-label">User support email</Untranslated>
</li> </li>
<li> <li>
Add your admin email under <strong>Developer contact information</strong> {t('settings.google.step3.devContact')}{' '}
<Untranslated reason="foreign-ui-label">Developer contact information</Untranslated>
</li> </li>
<li> <li>
Click <strong>Save</strong> {t('settings.google.step3.save')} <Untranslated reason="foreign-ui-label">Save</Untranslated>
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step4.title')}</strong>
<p className="mt-1"> <p className="mt-1">
Go to{' '} <GoogleLink
<a
href="https://console.cloud.google.com/auth/audience" href="https://console.cloud.google.com/auth/audience"
target="_blank" label={t('settings.google.step4.link')}
rel="noopener noreferrer" />
className="text-duck-teal underline"
>
OAuth Audience
</a>
.
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li> <li>
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed {t('settings.google.step4.internal')} <Untranslated reason="foreign-ui-label">Internal</Untranslated>
</li> </li>
<li> <li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '} {t('settings.google.step4.external')} <Untranslated reason="foreign-ui-label">External</Untranslated>
(required while the app is unverified; limit of 100 test users)
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step5.title')}</strong>
<p className="mt-1"> <p className="mt-1">
In the left sidebar, click{' '} {t('settings.google.step5.body')}{' '}
<a <GoogleLink href="https://console.cloud.google.com/auth/scopes" label={t('settings.google.step5.link')} />{' '}
href="https://console.cloud.google.com/auth/scopes" → <Untranslated reason="foreign-ui-label">Add or remove scopes</Untranslated>
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => ( {SCOPES.map((entry) => (
<li key={s.scope}> <li key={entry.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '} <code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{s.description} {entry.scope}
</code>{' '}
— {t(entry.descriptionKey)}
</li> </li>
))} ))}
</ul> </ul>
<p className="mt-1"> <p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">{t('settings.google.step5.note')}</p>
Click <strong>Update</strong>, then <strong>Save</strong>.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step6.title')}</strong>
<p className="mt-1"> <p className="mt-1">
In the left sidebar, click{' '} {t('settings.google.step6.body')}{' '}
<a <GoogleLink
href="https://console.cloud.google.com/auth/clients" href="https://console.cloud.google.com/auth/clients"
target="_blank" label={t('settings.google.step6.link')}
rel="noopener noreferrer" />{' '}
className="text-duck-teal underline" → <Untranslated reason="foreign-ui-label">Create OAuth client</Untranslated>
>
Clients
</a>
, then click <strong>Create OAuth client</strong>.
</p> </p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5"> <ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li> <li>
Application type: <strong>Web application</strong> {t('settings.google.step6.appType')}{' '}
<Untranslated reason="foreign-ui-label">Web application</Untranslated>
</li> </li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li> <li>{t('settings.google.step6.nameAnything')}</li>
<li> <li>
Authorized redirect URIs: add{' '} {t('settings.google.step6.redirectUris')}{' '}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all"> <code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all">
{redirectUri} {redirectUri}
</code> </code>
</li> </li>
<li> <li>
Click <strong>Create</strong> {t('settings.google.step6.thenClick')} <Untranslated reason="foreign-ui-label">Create</Untranslated>
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong> <strong className="text-duck-dark dark:text-foreground">{t('settings.google.step7.title')}</strong>
<p className="mt-1"> <p className="mt-1">{t('settings.google.step7.body')}</p>
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
them into the fields below.
</p>
</li> </li>
</ol> </ol>
</CollapsibleContent> </CollapsibleContent>
@@ -208,11 +186,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
type VerifyStatus = { valid: boolean; error: string | null } | null; type VerifyStatus = { valid: boolean; error: string | null } | null;
const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => { const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
const { t } = useTranslation();
if (isVerifying) { if (isVerifying) {
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"> <div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="h-2.5 w-2.5 rounded-full bg-duck-dark/20 dark:bg-foreground/20 animate-pulse shrink-0" /> <div className="h-2.5 w-2.5 rounded-full bg-duck-dark/20 dark:bg-foreground/20 animate-pulse shrink-0" />
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">Verifying credentials...</span> <span className="text-sm text-duck-dark/50 dark:text-foreground/50">{t('settings.google.verifying')}</span>
</div> </div>
); );
} }
@@ -223,13 +202,14 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"> <div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-success' : 'bg-red-500'}`} /> <div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-success' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}> <span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')} {status.valid ? t('settings.google.valid') : (status.error ?? t('settings.google.invalid'))}
</span> </span>
</div> </div>
); );
}; };
export const GoogleOAuthConfig = () => { export const GoogleOAuthConfig = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -243,7 +223,7 @@ export const GoogleOAuthConfig = () => {
client client
.get<{ valid: boolean; error: string | null }>('/integrations/google/verify') .get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
.then(setVerifyStatus) .then(setVerifyStatus)
.catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' })) .catch(() => setVerifyStatus({ valid: false, error: t('settings.google.verifyFailed') }))
.finally(() => setIsVerifying(false)); .finally(() => setIsVerifying(false));
}; };
@@ -273,10 +253,10 @@ export const GoogleOAuthConfig = () => {
setIsSaving(true); setIsSaving(true);
try { try {
await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() }); await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
toast.success('Google OAuth configuration saved'); toast.success(t('settings.google.saved'));
verify(); verify();
} catch { } catch {
toast.error('Failed to save Google OAuth configuration'); toast.error(t('settings.google.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -294,7 +274,7 @@ export const GoogleOAuthConfig = () => {
<form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5"> <form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientId')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text" type="text"
@@ -305,7 +285,7 @@ export const GoogleOAuthConfig = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client Secret</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientSecret')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
@@ -316,10 +296,8 @@ export const GoogleOAuthConfig = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Redirect URI</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.redirectUri')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.google.redirectUriHint')}</p>
Add this URI to your Google OAuth client&apos;s authorized redirect URIs
</p>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60"
type="text" type="text"
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels'; import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
function buildGroups(models: ModelOption[]) { function buildGroups(models: ModelOption[]) {
const groups: Record<string, { id: string; name: string }[]> = {}; const groups: Record<string, { id: string; name: string }[]> = {};
@@ -29,6 +30,7 @@ function buildGroups(models: ModelOption[]) {
const NONE = '__none__'; const NONE = '__none__';
export const AIModels = () => { export const AIModels = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const piModels = useUserVisibleModels(); const piModels = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -54,9 +56,9 @@ export const AIModels = () => {
chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel }, chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel },
tasks: { ...settings.tasks, defaultModel: taskModel }, tasks: { ...settings.tasks, defaultModel: taskModel },
}); });
toast.success('AI model defaults saved'); toast.success(t('settings.profile.models.aiDefaultsSaved'));
} catch { } catch {
toast.error('Failed to save settings'); toast.error(t('settings.profile.models.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -86,25 +88,25 @@ export const AIModels = () => {
return ( return (
<div className="grid gap-5"> <div className="grid gap-5">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultChat')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat from the home screen {t('settings.profile.models.defaultChatHint')}
</p> </p>
{renderModelSelect(chatModel, setChatModel, 'System default')} {renderModelSelect(chatModel, setChatModel, 'System default')}
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultProject')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat inside a project dashboard {t('settings.profile.models.defaultProjectHint')}
</p> </p>
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')} {renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultTask')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when running tasks from the file browser {t('settings.profile.models.defaultTaskHint')}
</p> </p>
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')} {renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label> </Label>
@@ -2,6 +2,8 @@ import type { FormEvent } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react'; import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -36,19 +38,22 @@ type Minted = { key: string; name: string };
const API_KEYS_QUERY_KEY = ['API_KEYS']; const API_KEYS_QUERY_KEY = ['API_KEYS'];
const formatDate = (value: string | null) => // `toLocaleDateString(undefined, …)` followed the BROWSER's locale, so a Portuguese UI showed the
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null; // browser's dates. `useFormat` binds it to the locale the rest of the app is in (D7).
const copy = async (text: string) => {
try {
await copyToClipboard(text);
toast.success('Key copied');
} catch {
toast.error('Could not copy — select and copy manually');
}
};
export const ApiKeys = () => { export const ApiKeys = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.profile.apiKeys.copied'));
} catch {
toast.error(t('settings.profile.apiKeys.copyFailed'));
}
};
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -74,7 +79,7 @@ export const ApiKeys = () => {
setName(''); setName('');
await refresh(); await refresh();
} catch (ex) { } catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not create the key'); toast.error(ex instanceof Error ? ex.message : t('settings.profile.apiKeys.createFailed'));
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -85,17 +90,17 @@ export const ApiKeys = () => {
await client.delete(`/api-keys/${id}/record`); await client.delete(`/api-keys/${id}/record`);
await refresh(); await refresh();
} catch { } catch {
toast.error('Could not remove it'); toast.error(t('settings.profile.apiKeys.forgetFailed'));
} }
}; };
const revoke = async (id: number) => { const revoke = async (id: number) => {
try { try {
await client.delete(`/api-keys/${id}`); await client.delete(`/api-keys/${id}`);
toast.success('Revoked — anything using that key is signed out'); toast.success(t('settings.profile.apiKeys.revokeDone'));
await refresh(); await refresh();
} catch { } catch {
toast.error('Could not revoke'); toast.error(t('settings.profile.apiKeys.revokeFailed'));
} }
}; };
@@ -116,7 +121,7 @@ export const ApiKeys = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground"> <div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys… <Loader2 className="h-4 w-4 animate-spin" /> {t('settings.profile.apiKeys.loading')}
</div> </div>
); );
} }
@@ -124,15 +129,13 @@ export const ApiKeys = () => {
return ( return (
<div className="grid gap-5"> <div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70"> <p className="text-sm text-duck-dark/70 dark:text-foreground/70">
An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not {t('settings.profile.apiKeys.intro')} <code className="font-mono text-xs">Authorization: Bearer …</code>.
tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '}
<code className="font-mono text-xs">Authorization: Bearer …</code>.
</p> </p>
{minted && ( {minted && (
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4"> <div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
<div className="text-sm font-medium text-duck-dark dark:text-foreground"> <div className="text-sm font-medium text-duck-dark dark:text-foreground">
Key for “{minted.name}” — shown once {t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}
</div> </div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60"> <p className="text-xs text-duck-dark/60 dark:text-foreground/60">
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it, This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
@@ -143,30 +146,35 @@ export const ApiKeys = () => {
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs"> <code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{minted.key} {minted.key}
</code> </code>
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key"> <Button
variant="ghost"
size="sm"
onClick={() => copy(minted.key)}
title={t('settings.profile.apiKeys.copyKey')}
>
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}> <Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
I've saved it {t('settings.profile.apiKeys.savedIt')}
</Button> </Button>
</div> </div>
)} )}
<form onSubmit={create} className="flex items-end gap-2"> <form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2"> <Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.apiKeys.newKey')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20" className="h-11 bg-background/60 border-duck-dark/20"
value={name} value={name}
onChange={(ev) => setName(ev.target.value)} onChange={(ev) => setName(ev.target.value)}
placeholder="iPhone, Music app, laptop CLI…" placeholder={t('settings.profile.apiKeys.namePlaceholder')}
/> />
</Label> </Label>
<Button type="submit" disabled={!name.trim() || isCreating} className="h-11"> <Button type="submit" disabled={!name.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" /> <Plus className="me-1.5 h-4 w-4" />
Create {t('settings.profile.apiKeys.create')}
</Button> </Button>
</form> </form>
@@ -174,14 +182,16 @@ export const ApiKeys = () => {
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40"> <div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence {/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence
that would send somebody looking for a bug in the list. */} that would send somebody looking for a bug in the list. */}
{allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'} {allKeys.length === 0 ? t('settings.profile.apiKeys.noneYet') : t('settings.profile.apiKeys.noneInUse')}
</div> </div>
) : ( ) : (
<div className="grid gap-2"> <div className="grid gap-2">
{keys.map((entry) => { {keys.map((entry) => {
const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now(); const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now();
const dead = !!entry.revokedAt || expired; const dead = !!entry.revokedAt || expired;
const lastUsed = formatDate(entry.lastUsedAt); const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return ( return (
<div <div
key={entry.id} key={entry.id}
@@ -195,14 +205,20 @@ export const ApiKeys = () => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm text-duck-dark dark:text-foreground"> <div className="truncate text-sm text-duck-dark dark:text-foreground">
{entry.name} {entry.name}
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>} {entry.revokedAt && (
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>} <span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.revoked')}</span>
)}
{!entry.revokedAt && expired && (
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.expired')}</span>
)}
</div> </div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40"> <div className="text-xs text-duck-dark/40 dark:text-foreground/40">
<code className="font-mono">{entry.prefix}…</code> <code className="font-mono">{entry.prefix}…</code>
{' · '} {' · '}
{/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */} {/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */}
{lastUsed ? `last used ${lastUsed}` : 'never used'} {lastUsed
? t('settings.profile.apiKeys.lastUsed', { date: lastUsed })
: t('settings.profile.apiKeys.neverUsed')}
</div> </div>
</div> </div>
{dead ? ( {dead ? (
@@ -212,7 +228,7 @@ export const ApiKeys = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => void forget(entry.id)} onClick={() => void forget(entry.id)}
title="Remove this record" title={t('settings.profile.apiKeys.forget')}
className="hover:text-red-500" className="hover:text-red-500"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -222,7 +238,7 @@ export const ApiKeys = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => void revoke(entry.id)} onClick={() => void revoke(entry.id)}
title="Revoke" title={t('settings.profile.apiKeys.revoke')}
className="hover:text-red-500" className="hover:text-red-500"
> >
<Ban className="h-3.5 w-3.5" /> <Ban className="h-3.5 w-3.5" />
@@ -241,8 +257,8 @@ export const ApiKeys = () => {
className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70" className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70"
> >
{showRevoked {showRevoked
? 'Hide revoked and expired' ? t('settings.profile.apiKeys.hideDead')
: `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`} : t('settings.profile.apiKeys.showDead', { count: deadKeys.length })}
</button> </button>
)} )}
</div> </div>
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev'; import { useDock } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions'; import { usePermissions } from 'hooks/usePermissions';
import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock'; import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
import { useTranslation } from '@/lib/i18n';
type DockPillProps = { type DockPillProps = {
label: string; label: string;
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
}; };
export const DockSettings = () => { export const DockSettings = () => {
const { t } = useTranslation();
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone // Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
// pin a tile that cannot appear, which reads as the setting being broken. // pin a tile that cannot appear, which reads as the setting being broken.
const { plugins } = usePermissions(); const { plugins } = usePermissions();
@@ -193,10 +195,10 @@ export const DockSettings = () => {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<DropZone label="Visible" onDrop={onDropVisible}> <DropZone label={t('settings.profile.dock.visible')} onDrop={onDropVisible}>
{items.length === 0 && ( {items.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1"> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
Drag items here to show in dock {t('settings.profile.dock.dragToShow')}
</span> </span>
)} )}
{items.map((item) => ( {items.map((item) => (
@@ -216,9 +218,11 @@ export const DockSettings = () => {
))} ))}
</DropZone> </DropZone>
<DropZone label="Hidden" onDrop={onDropHidden}> <DropZone label={t('settings.profile.dock.hidden')} onDrop={onDropHidden}>
{hiddenItems.length === 0 && ( {hiddenItems.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.profile.dock.allVisible')}
</span>
)} )}
{hiddenItems.map((item) => ( {hiddenItems.map((item) => (
<DockPill <DockPill
@@ -235,7 +239,7 @@ export const DockSettings = () => {
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer"> <Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
<RotateCcw className="h-3.5 w-3.5 mr-1.5" /> <RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Reset to defaults {t('settings.profile.dock.reset')}
</Button> </Button>
</div> </div>
); );
@@ -1,46 +1,67 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n'; import { useTranslation } from '@/lib/i18n';
import { SHIPPED_LOCALES } from '@/lib/locales'; import { SHIPPED_LOCALES } from '@/lib/locales';
const LANGUAGES = [ /**
{ code: 'en', label: 'English' }, * Language names come from `Intl.DisplayNames`, not from the catalog.
{ code: 'pt', label: 'Portuguese' }, *
{ code: 'es', label: 'Spanish' }, * The runtime already knows every language's name in every locale — "German" in English, "alemão" in
{ code: 'fr', label: 'French' }, * Portuguese — so 28 names would have been 56 hand-written catalog entries that `Intl` gets right for
{ code: 'de', label: 'German' }, * free, including for locales we have not shipped yet. Same reasoning as D7 for dates and numbers.
{ code: 'it', label: 'Italian' }, *
{ code: 'nl', label: 'Dutch' }, * The tags themselves stay a hardcoded list: this is "which languages may I claim to speak", a product
{ code: 'ru', label: 'Russian' }, * choice, not something to enumerate from the runtime.
{ code: 'zh', label: 'Chinese' }, */
{ code: 'ja', label: 'Japanese' }, const SPOKEN_LANGUAGE_TAGS = [
{ code: 'ko', label: 'Korean' }, 'en',
{ code: 'ar', label: 'Arabic' }, 'pt',
{ code: 'hi', label: 'Hindi' }, 'es',
{ code: 'tr', label: 'Turkish' }, 'fr',
{ code: 'pl', label: 'Polish' }, 'de',
{ code: 'sv', label: 'Swedish' }, 'it',
{ code: 'da', label: 'Danish' }, 'nl',
{ code: 'no', label: 'Norwegian' }, 'ru',
{ code: 'fi', label: 'Finnish' }, 'zh',
{ code: 'uk', label: 'Ukrainian' }, 'ja',
{ code: 'cs', label: 'Czech' }, 'ko',
{ code: 'ro', label: 'Romanian' }, 'ar',
{ code: 'el', label: 'Greek' }, 'hi',
{ code: 'he', label: 'Hebrew' }, 'tr',
{ code: 'th', label: 'Thai' }, 'pl',
{ code: 'vi', label: 'Vietnamese' }, 'sv',
{ code: 'id', label: 'Indonesian' }, 'da',
{ code: 'ms', label: 'Malay' }, 'no',
]; 'fi',
'uk',
'cs',
'ro',
'el',
'he',
'th',
'vi',
'id',
'ms',
] as const;
const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label ?? code; /** `Intl` yields "alemão" in pt; a picker row reads better capitalized. */
const useLanguageName = () => {
const { locale } = useTranslation();
return useMemo(() => {
const names = new Intl.DisplayNames([locale], { type: 'language', fallback: 'code' });
return (code: string) => {
const name = names.of(code) ?? code;
return name.charAt(0).toUpperCase() + name.slice(1);
};
}, [locale]);
};
export const Languages = () => { export const Languages = () => {
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const languageName = useLanguageName();
const { spoken, default: defaultLang, translateTo } = settings.languages; const { spoken, default: defaultLang, translateTo } = settings.languages;
const [addingLang, setAddingLang] = useState(''); const [addingLang, setAddingLang] = useState('');
@@ -63,7 +84,7 @@ export const Languages = () => {
save(updates); save(updates);
}; };
const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code)); const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code));
return ( return (
<div className="grid gap-5"> <div className="grid gap-5">
@@ -76,7 +97,7 @@ export const Languages = () => {
key={code} key={code}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium"
> >
{getLabel(code)} {languageName(code)}
{spoken.length > 1 && ( {spoken.length > 1 && (
<button <button
onClick={() => removeSpoken(code)} onClick={() => removeSpoken(code)}
@@ -96,9 +117,9 @@ export const Languages = () => {
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer" className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
> >
<option value="">{t('settings.profile.languages.addLanguage')}</option> <option value="">{t('settings.profile.languages.addLanguage')}</option>
{availableToAdd.map((l) => ( {availableToAdd.map((code) => (
<option key={l.code} value={l.code}> <option key={code} value={code}>
{l.label} {languageName(code)}
</option> </option>
))} ))}
</select> </select>
@@ -131,9 +152,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })} onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer" className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
> >
{LANGUAGES.map((l) => ( {SPOKEN_LANGUAGE_TAGS.map((code) => (
<option key={l.code} value={l.code}> <option key={code} value={code}>
{l.label} {languageName(code)}
</option> </option>
))} ))}
</select> </select>
@@ -13,8 +13,10 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, type ModelOption } from 'state/useModels'; import { useUserVisibleModels, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
export const TaskDefaults = () => { export const TaskDefaults = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const models = useUserVisibleModels(); const models = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -44,9 +46,9 @@ export const TaskDefaults = () => {
setIsSaving(true); setIsSaving(true);
try { try {
await saveSettings({ ...settings, tasks: { defaultProvider: 'chat', defaultModel: model } }); await saveSettings({ ...settings, tasks: { defaultProvider: 'chat', defaultModel: model } });
toast.success('Task defaults saved'); toast.success(t('settings.profile.models.taskDefaultsSaved'));
} catch { } catch {
toast.error('Failed to save settings'); toast.error(t('settings.profile.models.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -55,10 +57,10 @@ export const TaskDefaults = () => {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Default Model</span> <span className="text-duck-dark/70">{t('settings.profile.models.defaultModel')}</span>
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}> <Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark"> <SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Same as chat default" /> <SelectValue placeholder={t('settings.profile.models.sameAsChat')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]"> <SelectContent className="z-[600] max-h-[300px]">
{modelGroups.map(({ provider, models }) => ( {modelGroups.map(({ provider, models }) => (
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useForm } from 'hooks/useForm'; import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type ProfileFormState = { type ProfileFormState = {
name?: string; name?: string;
@@ -24,6 +25,7 @@ const readFileAsBase64 = (file: File): Promise<string> => {
}; };
export const UserData = () => { export const UserData = () => {
const { t } = useTranslation();
const { user, updateUser } = useAuth(); const { user, updateUser } = useAuth();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null); const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
@@ -35,12 +37,12 @@ export const UserData = () => {
if (!file) return; if (!file) return;
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('Please select an image file'); toast.error(t('settings.profile.userData.selectImage'));
return; return;
} }
if (file.size > MAX_AVATAR_SIZE) { if (file.size > MAX_AVATAR_SIZE) {
toast.error('Image must be smaller than 384KB'); toast.error(t('settings.profile.userData.imageTooLarge'));
return; return;
} }
@@ -58,11 +60,11 @@ export const UserData = () => {
name: profileForm.state.name ?? '', name: profileForm.state.name ?? '',
avatar: avatarPreview ?? user?.avatar ?? '', avatar: avatarPreview ?? user?.avatar ?? '',
}); });
toast.success('Profile updated'); toast.success(t('settings.profile.userData.updated'));
setAvatarPreview(null); setAvatarPreview(null);
} catch (ex) { } catch (ex) {
const error = ex as { message?: string }; const error = ex as { message?: string };
toast.error(error.message || 'Failed to update profile'); toast.error(error.message || t('settings.profile.userData.updateFailed'));
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
@@ -93,7 +95,7 @@ export const UserData = () => {
<form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4"> <form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span> <span className="text-duck-dark/70">{t('settings.profile.userData.email')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email" type="email"
@@ -103,7 +105,7 @@ export const UserData = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span> <span className="text-duck-dark/70">{t('settings.profile.userData.username')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text" type="text"
@@ -113,12 +115,12 @@ export const UserData = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span> <span className="text-duck-dark/70">{t('settings.profile.userData.name')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text" type="text"
name="name" name="name"
placeholder="Your name" placeholder={t('settings.profile.userData.namePlaceholder')}
autoComplete="name" autoComplete="name"
/> />
</Label> </Label>
@@ -128,7 +130,7 @@ export const UserData = () => {
disabled={isUpdating} disabled={isUpdating}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100" className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
> >
{isUpdating ? 'Saving...' : 'Save'} {isUpdating ? t('common.saving') : t('common.save')}
</Button> </Button>
</form> </form>
</div> </div>
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
type VoiceGroup = { label: string; voices: string[] }; type VoiceGroup = { label: string; voices: string[] };
@@ -26,6 +27,7 @@ type TtsConfig = {
const SERVER_DEFAULT = '__server_default__'; const SERVER_DEFAULT = '__server_default__';
export const VoicePreference = () => { export const VoicePreference = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const [voices, setVoices] = useState<string[]>([]); const [voices, setVoices] = useState<string[]>([]);
@@ -128,13 +130,13 @@ export const VoicePreference = () => {
<div className="grid gap-4"> <div className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.voice.label')}</span>
<button <button
type="button" type="button"
onClick={handleRefresh} onClick={handleRefresh}
disabled={loading} disabled={loading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40" className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices" title={t('settings.profile.voice.refresh')}
> >
<RefreshCw <RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
@@ -144,7 +146,7 @@ export const VoicePreference = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={selectedValue} onValueChange={handleChange}> <Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"> <SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Server default" /> <SelectValue placeholder={t('settings.profile.voice.serverDefault')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]"> <SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}> <SelectItem value={SERVER_DEFAULT}>
@@ -175,7 +177,7 @@ export const VoicePreference = () => {
onClick={handleListen} onClick={handleListen}
disabled={listening === 'loading' || !serverConfig} disabled={listening === 'loading' || !serverConfig}
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default" className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
title={listening === 'playing' ? 'Stop' : 'Listen'} title={listening === 'playing' ? t('settings.profile.voice.stop') : t('settings.profile.voice.listen')}
> >
{listening === 'loading' ? ( {listening === 'loading' ? (
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" /> <Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
@@ -186,9 +188,7 @@ export const VoicePreference = () => {
)} )}
</button> </button>
</div> </div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40"> <span className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.profile.voice.hint')}</span>
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
</span>
</Label> </Label>
</div> </div>
); );
@@ -13,6 +13,7 @@ import {
CommandList, CommandList,
} from '@/components/ui/command'; } from '@/components/ui/command';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null }; type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type StoredApiKeys = { keys: { provider: string; value: string }[] }; type StoredApiKeys = { keys: { provider: string; value: string }[] };
@@ -38,6 +39,7 @@ type ChatProvider = { key: string; providerId: string };
type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' }; type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' };
export const AIHarnessesSection = () => { export const AIHarnessesSection = () => {
const { t, tUi } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [installing, setInstalling] = useState(false); const [installing, setInstalling] = useState(false);
@@ -168,7 +170,7 @@ export const AIHarnessesSection = () => {
// No auth needed — save directly // No auth needed — save directly
await saveLocalProvider(result, auth); await saveLocalProvider(result, auth);
} catch { } catch {
toast.error('Failed to connect'); toast.error(t('settings.server.harnesses.connectFailed'));
setProbeState({ step: 'url' }); setProbeState({ step: 'url' });
} }
}; };
@@ -192,13 +194,13 @@ export const AIHarnessesSection = () => {
return; return;
} }
if (result.needsAuth) { if (result.needsAuth) {
toast.error('Authentication failed'); toast.error(t('settings.server.harnesses.authFailed'));
setProbeState({ step: 'auth', probe }); setProbeState({ step: 'auth', probe });
return; return;
} }
await saveLocalProvider(result, auth); await saveLocalProvider(result, auth);
} catch { } catch {
toast.error('Failed to connect'); toast.error(t('settings.server.harnesses.connectFailed'));
setProbeState({ step: 'auth', probe }); setProbeState({ step: 'auth', probe });
} }
}; };
@@ -218,10 +220,10 @@ export const AIHarnessesSection = () => {
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_PROVIDERS'] }); queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_PROVIDERS'] });
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_HEALTH'] }); queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_HEALTH'] });
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] }); queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success(`Connected to ${probe.name}`); toast.success(t('settings.server.harnesses.connectedTo', { name: probe.name ?? '' }));
resetLocalForm(); resetLocalForm();
} catch { } catch {
toast.error('Failed to save provider'); toast.error(t('settings.server.harnesses.saveFailed'));
setProbeState({ step: 'url' }); setProbeState({ step: 'url' });
} }
}; };
@@ -254,9 +256,9 @@ export const AIHarnessesSection = () => {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium text-duck-dark">Pi Mono</span> <span className="text-sm font-medium text-duck-dark">{t('settings.server.harnesses.piMono')}</span>
{piMonoLoading ? ( {piMonoLoading ? (
<span className="text-xs text-duck-dark/50">Checking...</span> <span className="text-xs text-duck-dark/50">{t('settings.server.harnesses.checking')}</span>
) : piMonoVersion?.version ? ( ) : piMonoVersion?.version ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-duck-dark/50">{piMonoVersion.version}</span> <span className="text-xs text-duck-dark/50">{piMonoVersion.version}</span>
@@ -265,7 +267,7 @@ export const AIHarnessesSection = () => {
onClick={installAgent} onClick={installAgent}
disabled={installing} disabled={installing}
className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30" className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Update Pi Mono" title={t('settings.server.harnesses.updatePiMono')}
> >
<RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} />
</button> </button>
@@ -285,13 +287,21 @@ export const AIHarnessesSection = () => {
<> <>
{/* Local Providers */} {/* Local Providers */}
<div className="mt-6 text-xs text-duck-dark/50"> <div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span> <span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.harnesses.localProviders')}
</span>
<div className="mt-3 flex flex-col gap-2"> <div className="mt-3 flex flex-col gap-2">
{localProviders.map((lp: LocalProviderEntry) => ( {localProviders.map((lp: LocalProviderEntry) => (
<div key={lp.id} className="flex items-center gap-2"> <div key={lp.id} className="flex items-center gap-2">
<span <span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-success' : 'bg-red-500') : 'bg-duck-dark/20'}`} className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-success' : 'bg-red-500') : 'bg-duck-dark/20'}`}
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'} title={
lp.id in localHealth
? localHealth[lp.id]
? 'Online'
: 'Offline'
: t('settings.server.harnesses.checking')
}
/> />
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span> <span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span> <span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
@@ -299,7 +309,7 @@ export const AIHarnessesSection = () => {
type="button" type="button"
onClick={() => removeLocalProvider(lp.id)} onClick={() => removeLocalProvider(lp.id)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors" className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Remove provider" title={t('settings.server.harnesses.removeProvider')}
> >
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" /> <Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button> </button>
@@ -312,7 +322,7 @@ export const AIHarnessesSection = () => {
<Input <Input
type="text" type="text"
className="h-8 text-xs" className="h-8 text-xs"
placeholder="Name (e.g. My Ollama)" placeholder={t('settings.server.harnesses.namePlaceholder')}
value={localName} value={localName}
onChange={(ev) => setLocalName(ev.target.value)} onChange={(ev) => setLocalName(ev.target.value)}
onKeyDown={(ev) => { onKeyDown={(ev) => {
@@ -340,7 +350,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40" className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!localUrl.trim()} disabled={!localUrl.trim()}
> >
Connect {t('settings.server.harnesses.connect')}
</Button> </Button>
)} )}
{(probeState.step === 'probing' || probeState.step === 'saving') && ( {(probeState.step === 'probing' || probeState.step === 'saving') && (
@@ -351,7 +361,7 @@ export const AIHarnessesSection = () => {
onClick={resetLocalForm} onClick={resetLocalForm}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs" className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
> >
Cancel {tUi('button.cancel')}
</button> </button>
</div> </div>
</form> </form>
@@ -370,7 +380,7 @@ export const AIHarnessesSection = () => {
<Input <Input
type="text" type="text"
className="h-8 text-xs" className="h-8 text-xs"
placeholder="Username" placeholder={t('settings.server.harnesses.username')}
value={authUsername} value={authUsername}
onChange={(ev) => setAuthUsername(ev.target.value)} onChange={(ev) => setAuthUsername(ev.target.value)}
autoFocus autoFocus
@@ -378,7 +388,7 @@ export const AIHarnessesSection = () => {
<Input <Input
type="password" type="password"
className="h-8 text-xs" className="h-8 text-xs"
placeholder="Password" placeholder={t('settings.server.harnesses.password')}
value={authPassword} value={authPassword}
onChange={(ev) => setAuthPassword(ev.target.value)} onChange={(ev) => setAuthPassword(ev.target.value)}
/> />
@@ -388,7 +398,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40" className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authUsername || !authPassword} disabled={!authUsername || !authPassword}
> >
Authenticate {t('settings.server.harnesses.authenticate')}
</Button> </Button>
</> </>
) : ( ) : (
@@ -396,7 +406,7 @@ export const AIHarnessesSection = () => {
<Input <Input
type="password" type="password"
className="h-8 text-xs" className="h-8 text-xs"
placeholder="API Key" placeholder={t('settings.server.harnesses.apiKey')}
value={authApiKey} value={authApiKey}
onChange={(ev) => setAuthApiKey(ev.target.value)} onChange={(ev) => setAuthApiKey(ev.target.value)}
autoFocus autoFocus
@@ -407,7 +417,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40" className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authApiKey} disabled={!authApiKey}
> >
Authenticate {t('settings.server.harnesses.authenticate')}
</Button> </Button>
</> </>
)} )}
@@ -423,7 +433,7 @@ export const AIHarnessesSection = () => {
onClick={() => setAddingLocal(true)} onClick={() => setAddingLocal(true)}
> >
<Plus className="h-3.5 w-3.5 mr-1.5" /> <Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Local Provider {t('settings.server.harnesses.connectLocal')}
</Button> </Button>
)} )}
</div> </div>
@@ -431,7 +441,9 @@ export const AIHarnessesSection = () => {
{/* Remote Providers */} {/* Remote Providers */}
<div className="mt-6 text-xs text-duck-dark/50"> <div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span> <span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.harnesses.remoteProviders')}
</span>
<div className="mt-3 flex flex-col gap-2"> <div className="mt-3 flex flex-col gap-2">
{connectedProviders.map((provider) => ( {connectedProviders.map((provider) => (
<div key={provider.providerId} className="flex flex-col gap-2"> <div key={provider.providerId} className="flex flex-col gap-2">
@@ -457,7 +469,7 @@ export const AIHarnessesSection = () => {
type="button" type="button"
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)} onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors" className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Edit key" title={t('settings.server.harnesses.editKey')}
> >
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" /> <Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
</button> </button>
@@ -465,7 +477,7 @@ export const AIHarnessesSection = () => {
type="button" type="button"
onClick={() => disconnectProvider(provider)} onClick={() => disconnectProvider(provider)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors" className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Disconnect provider" title={t('settings.server.harnesses.disconnect')}
> >
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" /> <Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button> </button>
@@ -478,7 +490,7 @@ export const AIHarnessesSection = () => {
<Input <Input
type="password" type="password"
className="h-7 text-xs flex-1" className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'} placeholder={getStoredMasked(provider.providerId) || t('settings.server.harnesses.enterKey')}
value={keyInputs[provider.providerId] ?? ''} value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) => onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value })) setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
@@ -492,7 +504,7 @@ export const AIHarnessesSection = () => {
type="submit" type="submit"
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId} disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default" className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key" title={t('settings.server.harnesses.saveKey')}
> >
<Save className="h-3.5 w-3.5 text-duck-teal" /> <Save className="h-3.5 w-3.5 text-duck-teal" />
</button> </button>
@@ -501,7 +513,7 @@ export const AIHarnessesSection = () => {
onClick={() => setEditingProvider(null)} onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs" className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
> >
Cancel {tUi('button.cancel')}
</button> </button>
</form> </form>
)} )}
@@ -524,7 +536,7 @@ export const AIHarnessesSection = () => {
<Input <Input
type="password" type="password"
className="h-7 text-xs flex-1" className="h-7 text-xs flex-1"
placeholder="Enter API key" placeholder={t('settings.server.harnesses.enterKey')}
value={keyInputs[provider.providerId] ?? ''} value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) => onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value })) setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
@@ -538,7 +550,7 @@ export const AIHarnessesSection = () => {
type="submit" type="submit"
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId} disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default" className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key" title={t('settings.server.harnesses.saveKey')}
> >
<Save className="h-3.5 w-3.5 text-duck-teal" /> <Save className="h-3.5 w-3.5 text-duck-teal" />
</button> </button>
@@ -547,7 +559,7 @@ export const AIHarnessesSection = () => {
onClick={() => setEditingProvider(null)} onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs" className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
> >
Cancel {tUi('button.cancel')}
</button> </button>
</form> </form>
); );
@@ -561,14 +573,14 @@ export const AIHarnessesSection = () => {
onClick={() => setCommandOpen(true)} onClick={() => setCommandOpen(true)}
> >
<Plus className="h-3.5 w-3.5 mr-1.5" /> <Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Provider {t('settings.server.harnesses.connectProvider')}
</Button> </Button>
)} )}
</div> </div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}> <CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Search providers..." /> <CommandInput placeholder={t('settings.server.harnesses.search')} />
<CommandList> <CommandList>
<CommandEmpty>No providers found.</CommandEmpty> <CommandEmpty>{t('settings.server.harnesses.none')}</CommandEmpty>
<CommandGroup heading="Available Providers"> <CommandGroup heading="Available Providers">
{unconnectedProviders.map((provider) => ( {unconnectedProviders.map((provider) => (
<CommandItem <CommandItem
@@ -3,6 +3,7 @@ import { Download, Hammer, Loader2, RotateCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
/** /**
* The two ways to load a change without leaving the browser. * The two ways to load a change without leaving the browser.
@@ -30,6 +31,7 @@ export function errorMessage(err: unknown, fallback: string): string {
} }
export const MaintenanceSection = () => { export const MaintenanceSection = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const [pulling, setPulling] = useState(false); const [pulling, setPulling] = useState(false);
const [pulled, setPulled] = useState<{ from: string; to: string; commits: string[]; needs: string[] } | null>(null); const [pulled, setPulled] = useState<{ from: string; to: string; commits: string[]; needs: string[] } | null>(null);
@@ -58,11 +60,11 @@ export const MaintenanceSection = () => {
}>('/server-settings/pull', {}); }>('/server-settings/pull', {});
if (!res.updated) { if (!res.updated) {
setPulled(null); setPulled(null);
toast.success('Already up to date'); toast.success(t('settings.server.maintenance.upToDate'));
return; return;
} }
setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs }); setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs });
toast.success(`Pulled ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`); toast.success(t('settings.server.maintenance.pulled', { count: res.commits.length }));
} catch (err) { } catch (err) {
toast.error(errorMessage(err, 'Pull failed')); toast.error(errorMessage(err, 'Pull failed'));
} finally { } finally {
@@ -81,7 +83,7 @@ export const MaintenanceSection = () => {
setLastBuild(`${(res.ms / 1000).toFixed(1)}s · ${res.outputs} files`); setLastBuild(`${(res.ms / 1000).toFixed(1)}s · ${res.outputs} files`);
// Nothing reloads itself: the bundle on disk changed, this page is still the old one. Saying so is // Nothing reloads itself: the bundle on disk changed, this page is still the old one. Saying so is
// the difference between "it worked" and "why does my change not show". // the difference between "it worked" and "why does my change not show".
toast.success('Frontend rebuilt — refresh this page to see it'); toast.success(t('settings.server.maintenance.rebuilt'));
} catch (err) { } catch (err) {
// The client throws on any status >= 400 with the raw response body as its message, so a build // The client throws on any status >= 400 with the raw response body as its message, so a build
// failure arrives here as the JSON the route sent. Unwrapped, or the toast shows a reader // failure arrives here as the JSON the route sent. Unwrapped, or the toast shows a reader
@@ -96,7 +98,7 @@ export const MaintenanceSection = () => {
setRestarting(true); setRestarting(true);
try { try {
await client.post('/server-settings/restart', {}); await client.post('/server-settings/restart', {});
toast.success('Restarting — the page will reconnect on its own'); toast.success(t('settings.server.maintenance.restarting'));
} catch (err) { } catch (err) {
/** /**
* Two failures arrive here and they are opposites. * Two failures arrive here and they are opposites.
@@ -116,7 +118,7 @@ export const MaintenanceSection = () => {
setRestarting(false); setRestarting(false);
return; return;
} }
toast.success('Restarting — the page will reconnect on its own'); toast.success(t('settings.server.maintenance.restarting'));
} }
// Left disabled on the success path, deliberately: the button must not become pressable again while // Left disabled on the success path, deliberately: the button must not become pressable again while
// the server is coming back. // the server is coming back.
@@ -126,14 +128,12 @@ export const MaintenanceSection = () => {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-foreground">Update the platform</div> <div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.updateTitle')}</div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Pulls this server&rsquo;s repository. Refuses if anything is uncommitted or the checkout has diverged — it Pulls this server&rsquo;s repository. Refuses if anything is uncommitted or the checkout has diverged — it
will never discard local work. will never discard local work.
</p> </p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">{t('settings.server.maintenance.updateHint')}</p>
Changes nothing that is running. Whatever the update needs is listed here, and stays your call.
</p>
{pulled && ( {pulled && (
<div className="mt-2 rounded-md border border-border bg-muted/40 p-2"> <div className="mt-2 rounded-md border border-border bg-muted/40 p-2">
<p className="font-mono text-[11px] text-muted-foreground"> <p className="font-mono text-[11px] text-muted-foreground">
@@ -163,7 +163,7 @@ export const MaintenanceSection = () => {
<div className="flex items-start justify-between gap-6 border-t border-border pt-6"> <div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div> <div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.rebuild')}</div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session — Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session —
refresh the page when it finishes. refresh the page when it finishes.
@@ -178,7 +178,7 @@ export const MaintenanceSection = () => {
<div className="flex items-start justify-between gap-6 border-t border-border pt-6"> <div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-foreground">Restart the server</div> <div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.restart')}</div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
The only way to load changed server code — Bun caches modules for the life of the process, so rebuilding The only way to load changed server code — Bun caches modules for the life of the process, so rebuilding
cannot see an edit to the backend. cannot see an edit to the backend.
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type OcrConfig = { type OcrConfig = {
url: string; url: string;
@@ -15,6 +16,7 @@ type OcrConfig = {
}; };
export const OCRSection = () => { export const OCRSection = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -59,9 +61,9 @@ export const OCRSection = () => {
try { try {
await client.put('/server-settings/ocr', { url, model }); await client.put('/server-settings/ocr', { url, model });
queryClient.invalidateQueries({ queryKey: ['OCR_CONFIG'] }); queryClient.invalidateQueries({ queryKey: ['OCR_CONFIG'] });
toast.success('OCR settings saved'); toast.success(t('settings.server.ocr.saved'));
} catch { } catch {
toast.error('Failed to save OCR settings'); toast.error(t('settings.server.ocr.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -75,7 +77,7 @@ export const OCRSection = () => {
if (res.error) { if (res.error) {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success('Vision model server is reachable'); toast.success(t('settings.server.ocr.reachable'));
} }
} catch (err: unknown) { } catch (err: unknown) {
const raw = (err as { message?: string })?.message; const raw = (err as { message?: string })?.message;
@@ -91,12 +93,12 @@ export const OCRSection = () => {
} }
}; };
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>; if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return ( return (
<form onSubmit={handleSave} className="grid gap-4"> <form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Vision Model Server URL</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.ocr.serverUrl')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="http://localhost:64203" placeholder="http://localhost:64203"
@@ -107,13 +109,13 @@ export const OCRSection = () => {
<Label className="grid gap-2"> <Label className="grid gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
<button <button
type="button" type="button"
onClick={() => fetchModels(url)} onClick={() => fetchModels(url)}
disabled={modelsLoading} disabled={modelsLoading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40" className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh models" title={t('settings.server.ocr.refreshModels')}
> >
<RefreshCw <RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${modelsLoading ? 'animate-spin' : ''}`} className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${modelsLoading ? 'animate-spin' : ''}`}
@@ -123,7 +125,7 @@ export const OCRSection = () => {
{models.length > 0 ? ( {models.length > 0 ? (
<Select value={model} onValueChange={setModel}> <Select value={model} onValueChange={setModel}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"> <SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Select a model" /> <SelectValue placeholder={t('settings.server.ocr.selectModel')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]"> <SelectContent className="z-[600] max-h-[300px]">
{models.map((m) => ( {models.map((m) => (
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type Provider = 'resend' | 'smtp' | 'mailhog'; type Provider = 'resend' | 'smtp' | 'mailhog';
@@ -31,6 +32,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<SmtpConfig>> = {
}; };
export const SMTPSection = () => { export const SMTPSection = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useAuth(); const { user } = useAuth();
@@ -92,9 +94,9 @@ export const SMTPSection = () => {
try { try {
await client.put('/server-settings/smtp', buildConfig()); await client.put('/server-settings/smtp', buildConfig());
queryClient.invalidateQueries({ queryKey: ['SMTP_CONFIG'] }); queryClient.invalidateQueries({ queryKey: ['SMTP_CONFIG'] });
toast.success('SMTP settings saved'); toast.success(t('settings.server.smtp.saved'));
} catch { } catch {
toast.error('Failed to save SMTP settings'); toast.error(t('settings.server.smtp.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -105,7 +107,7 @@ export const SMTPSection = () => {
setIsTestingConnection(true); setIsTestingConnection(true);
try { try {
await client.post('/server-settings/smtp/test-connection'); await client.post('/server-settings/smtp/test-connection');
toast.success('Connection successful'); toast.success(t('settings.server.smtp.connected'));
} catch (err: unknown) { } catch (err: unknown) {
const raw = (err as { message?: string })?.message; const raw = (err as { message?: string })?.message;
let msg = 'Connection failed'; let msg = 'Connection failed';
@@ -132,7 +134,7 @@ export const SMTPSection = () => {
if (result.error) { if (result.error) {
toast.error(result.error); toast.error(result.error);
} else { } else {
toast.success(`Test email sent to ${testEmail}`); toast.success(t('settings.server.smtp.testSent', { email: testEmail }));
} }
} catch (err: unknown) { } catch (err: unknown) {
const raw = (err as { message?: string })?.message; const raw = (err as { message?: string })?.message;
@@ -148,7 +150,7 @@ export const SMTPSection = () => {
} }
}; };
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>; if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
const showSmtpFields = provider === 'smtp' || provider === 'mailhog'; const showSmtpFields = provider === 'smtp' || provider === 'mailhog';
@@ -156,22 +158,22 @@ export const SMTPSection = () => {
<div className="grid gap-4"> <div className="grid gap-4">
<form onSubmit={handleSave} className="grid gap-4"> <form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}> <Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"> <SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600]"> <SelectContent className="z-[600]">
<SelectItem value="mailhog">MailHog (local testing)</SelectItem> <SelectItem value="mailhog">{t('settings.server.smtp.mailhog')}</SelectItem>
<SelectItem value="resend">Resend</SelectItem> <SelectItem value="resend">{t('settings.server.smtp.resend')}</SelectItem>
<SelectItem value="smtp">Custom SMTP</SelectItem> <SelectItem value="smtp">{t('settings.server.smtp.custom')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</Label> </Label>
{provider === 'resend' && ( {provider === 'resend' && (
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Key</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.apiKey')}</span>
<Input <Input
type="password" type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -186,7 +188,7 @@ export const SMTPSection = () => {
<> <>
<div className="grid grid-cols-[1fr_auto] gap-2"> <div className="grid grid-cols-[1fr_auto] gap-2">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Host</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.host')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="smtp.example.com" placeholder="smtp.example.com"
@@ -195,7 +197,7 @@ export const SMTPSection = () => {
/> />
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Port</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.port')}</span>
<Input <Input
type="number" type="number"
className="h-11 w-24 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground" className="h-11 w-24 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
@@ -208,16 +210,20 @@ export const SMTPSection = () => {
<> <>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Username</span> <span className="text-duck-dark/70 dark:text-foreground/70">
{t('settings.server.smtp.username')}
</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="user@example.com" placeholder={t('settings.server.smtp.userExample')}
value={username} value={username}
onChange={(ev) => setUsername(ev.target.value)} onChange={(ev) => setUsername(ev.target.value)}
/> />
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Password</span> <span className="text-duck-dark/70 dark:text-foreground/70">
{t('settings.server.smtp.password')}
</span>
<Input <Input
type="password" type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
@@ -228,7 +234,7 @@ export const SMTPSection = () => {
</div> </div>
<Label className="flex items-center gap-3"> <Label className="flex items-center gap-3">
<Switch checked={secure} onCheckedChange={setSecure} /> <Switch checked={secure} onCheckedChange={setSecure} />
<span className="text-duck-dark/70 dark:text-foreground/70">TLS / SSL</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.tls')}</span>
</Label> </Label>
</> </>
)} )}
@@ -237,7 +243,7 @@ export const SMTPSection = () => {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">From Name</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromName')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
value={fromName} value={fromName}
@@ -245,7 +251,7 @@ export const SMTPSection = () => {
/> />
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">From Email</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromEmail')}</span>
<Input <Input
type="email" type="email"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -276,12 +282,14 @@ export const SMTPSection = () => {
</form> </form>
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-4 mt-1"> <div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-4 mt-1">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Send Test Email</span> <span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.smtp.sendTest')}
</span>
<form onSubmit={handleTest} className="flex items-center gap-2 mt-2"> <form onSubmit={handleTest} className="flex items-center gap-2 mt-2">
<Input <Input
type="email" type="email"
className="h-9 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 flex-1" className="h-9 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 flex-1"
placeholder="recipient@example.com" placeholder={t('settings.server.smtp.recipientExample')}
value={testEmail} value={testEmail}
onChange={(ev) => setTestEmail(ev.target.value)} onChange={(ev) => setTestEmail(ev.target.value)}
/> />
@@ -6,12 +6,14 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type SttConfig = { type SttConfig = {
url: string; url: string;
}; };
export const STTSection = () => { export const STTSection = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -36,9 +38,9 @@ export const STTSection = () => {
try { try {
await client.put('/server-settings/stt', { url }); await client.put('/server-settings/stt', { url });
queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] }); queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] });
toast.success('STT settings saved'); toast.success(t('settings.server.stt.saved'));
} catch { } catch {
toast.error('Failed to save STT settings'); toast.error(t('settings.server.stt.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -52,7 +54,7 @@ export const STTSection = () => {
if (res.error) { if (res.error) {
toast.error(res.error); toast.error(res.error);
} else { } else {
toast.success('Whisper server is reachable'); toast.success(t('settings.server.stt.reachable'));
} }
} catch (err: unknown) { } catch (err: unknown) {
const raw = (err as { message?: string })?.message; const raw = (err as { message?: string })?.message;
@@ -68,12 +70,12 @@ export const STTSection = () => {
} }
}; };
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>; if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return ( return (
<form onSubmit={handleSave} className="grid gap-4"> <form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Whisper Server URL</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.stt.serverUrl')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="http://localhost:64201" placeholder="http://localhost:64201"
@@ -16,6 +16,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type Provider = 'openai' | 'elevenlabs'; type Provider = 'openai' | 'elevenlabs';
@@ -33,6 +34,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<TtsConfig>> = {
}; };
export const TTSSection = () => { export const TTSSection = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -110,9 +112,9 @@ export const TTSSection = () => {
try { try {
await client.put('/server-settings/tts', buildConfig()); await client.put('/server-settings/tts', buildConfig());
queryClient.invalidateQueries({ queryKey: ['TTS_CONFIG'] }); queryClient.invalidateQueries({ queryKey: ['TTS_CONFIG'] });
toast.success('TTS settings saved'); toast.success(t('settings.server.tts.saved'));
} catch { } catch {
toast.error('Failed to save TTS settings'); toast.error(t('settings.server.tts.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -143,27 +145,27 @@ export const TTSSection = () => {
const audio = new Audio(audioUrl); const audio = new Audio(audioUrl);
audioRef.current = audio; audioRef.current = audio;
audio.play(); audio.play();
toast.success('Playing test audio'); toast.success(t('settings.server.tts.playing'));
} catch { } catch {
toast.error('Test failed — could not reach TTS server'); toast.error(t('settings.server.tts.testFailed'));
} finally { } finally {
setIsTesting(false); setIsTesting(false);
} }
}; };
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>; if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return ( return (
<form onSubmit={handleSave} className="grid gap-4"> <form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}> <Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"> <SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600]"> <SelectContent className="z-[600]">
<SelectItem value="openai">OpenAI-compatible (Kokoro, OpenAI, etc.)</SelectItem> <SelectItem value="openai">{t('settings.server.tts.openaiCompatible')}</SelectItem>
<SelectItem value="elevenlabs">ElevenLabs</SelectItem> <SelectItem value="elevenlabs">{t('settings.server.tts.elevenLabs')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</Label> </Label>
@@ -195,7 +197,7 @@ export const TTSSection = () => {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder={provider === 'openai' ? 'kokoro' : 'eleven_multilingual_v2'} placeholder={provider === 'openai' ? 'kokoro' : 'eleven_multilingual_v2'}
@@ -205,13 +207,13 @@ export const TTSSection = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.voice')}</span>
<button <button
type="button" type="button"
onClick={() => fetchVoices(provider, url, apiKey, model)} onClick={() => fetchVoices(provider, url, apiKey, model)}
disabled={voicesLoading} disabled={voicesLoading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40" className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices" title={t('settings.server.tts.refreshVoices')}
> >
<RefreshCw <RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`} className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
@@ -221,7 +223,7 @@ export const TTSSection = () => {
{voices.length > 0 ? ( {voices.length > 0 ? (
<Select value={voice} onValueChange={setVoice}> <Select value={voice} onValueChange={setVoice}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"> <SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Select a voice" /> <SelectValue placeholder={t('settings.server.tts.selectVoice')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]"> <SelectContent className="z-[600] max-h-[300px]">
{voiceGroups.length > 0 {voiceGroups.length > 0
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { copyToClipboard } from 'helpers/clipboard'; import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/lib/i18n';
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres. // The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
// //
@@ -65,6 +66,7 @@ type CreatedAccount = {
}; };
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => { export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
const { t, tUi } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -118,16 +120,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4"> <div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
<div> <div>
<h3 className="text-sm font-medium">{created.email} created</h3> <h3 className="text-sm font-medium">{created.email} created</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settings.users.copyBeforeClosing')}</p>
Copy what you need before closing — none of it can be shown again.
</p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Password</Label> <Label>{t('settings.users.password')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input readOnly value={created.password} className="font-mono" /> <Input readOnly value={created.password} className="font-mono" />
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}> <Button
type="button"
variant="outline"
size="icon"
onClick={() => copy(created.password, t('settings.users.password'))}
>
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -139,14 +144,14 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
{created.osUser && ( {created.osUser && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Linux account</Label> <Label>{t('settings.users.linuxAccount')}</Label>
<Input readOnly value={created.osUser} className="font-mono" /> <Input readOnly value={created.osUser} className="font-mono" />
</div> </div>
)} )}
{created.osSshPublicKey && ( {created.osSshPublicKey && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Their SSH public key</Label> <Label>{t('settings.users.sshKey')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" /> <Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
<Button <Button
@@ -162,20 +167,20 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
permission error that says nothing about a missing key. */} permission error that says nothing about a missing key. */}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Generated on the machine; the private half never leaves it.{' '} Generated on the machine; the private half never leaves it.{' '}
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row. <strong>{t('settings.users.addToGitea')}</strong> so they can push. Retrievable later from their row.
</p> </p>
</div> </div>
)} )}
{created.osUserError && ( {created.osUserError && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs"> <div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<div className="font-medium">The account works, but its Linux side did not finish</div> <div className="font-medium">{t('settings.users.linuxIncomplete')}</div>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p> <p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
</div> </div>
)} )}
<Button type="button" onClick={close}> <Button type="button" onClick={close}>
Done {t('settings.users.done')}
</Button> </Button>
</div> </div>
); );
@@ -185,7 +190,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
return ( return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}> <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
Add account {t('settings.users.addAccount')}
</Button> </Button>
); );
} }
@@ -194,19 +199,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
<form onSubmit={submit} className="space-y-4 rounded-lg border p-4"> <form onSubmit={submit} className="space-y-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<h3 className="text-sm font-medium">New account</h3> <h3 className="text-sm font-medium">{t('settings.users.newAccount')}</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Created active — they can sign in straight away. Tell them the password; it is not recoverable afterwards. Created active — they can sign in straight away. Tell them the password; it is not recoverable afterwards.
</p> </p>
</div> </div>
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label="Cancel"> <Button type="button" variant="ghost" size="icon" onClick={close} aria-label={tUi('button.cancel')}>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="new-user-email">Email</Label> <Label htmlFor="new-user-email">{t('settings.users.email')}</Label>
<Input <Input
id="new-user-email" id="new-user-email"
type="email" type="email"
@@ -218,7 +223,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="new-user-name">Name</Label> <Label htmlFor="new-user-name">{t('settings.users.name')}</Label>
<Input <Input
id="new-user-name" id="new-user-name"
autoComplete="off" autoComplete="off"
@@ -229,7 +234,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="new-user-username">Username</Label> <Label htmlFor="new-user-username">{t('settings.users.username')}</Label>
<Input <Input
id="new-user-username" id="new-user-username"
autoComplete="off" autoComplete="off"
@@ -237,11 +242,11 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
onChange={(ev) => set('username')(ev.target.value)} onChange={(ev) => set('username')(ev.target.value)}
required required
/> />
<p className="text-xs text-muted-foreground">Letters, numbers, dots, hyphens and underscores.</p> <p className="text-xs text-muted-foreground">{t('settings.users.usernameHint')}</p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="new-user-role">Role</Label> <Label htmlFor="new-user-role">{t('settings.users.role')}</Label>
<Select value={form.role} onValueChange={set('role')}> <Select value={form.role} onValueChange={set('role')}>
<SelectTrigger id="new-user-role"> <SelectTrigger id="new-user-role">
<SelectValue /> <SelectValue />
@@ -261,7 +266,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div> </div>
<div className="space-y-1.5 sm:col-span-2"> <div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-password">Password</Label> <Label htmlFor="new-user-password">{t('settings.users.password')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="new-user-password" id="new-user-password"
@@ -276,7 +281,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
/> />
<Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}> <Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}>
<Dices className="h-4 w-4" /> <Dices className="h-4 w-4" />
<span className="sr-only">Generate a password</span> <span className="sr-only">{t('settings.users.generatePassword')}</span>
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -285,29 +290,27 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
disabled={!form.password} disabled={!form.password}
onClick={() => { onClick={() => {
void copyToClipboard(form.password); void copyToClipboard(form.password);
toast.success('Password copied'); toast.success(t('settings.users.passwordCopied'));
}} }}
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
<span className="sr-only">Copy the password</span> <span className="sr-only">{t('settings.users.copyPassword')}</span>
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('settings.users.passwordHint')}</p>
At least 12 characters, with upper and lower case, a number and a symbol.
</p>
</div> </div>
{/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does {/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does
not replace it, because a key on a laptop is no use to an agent running on the server. */} not replace it, because a key on a laptop is no use to an agent running on the server. */}
<div className="space-y-1.5 sm:col-span-2"> <div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-ssh"> <Label htmlFor="new-user-ssh">
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span> {t('settings.users.sshKey')} <span className="ml-1 text-xs opacity-60">(optional)</span>
</Label> </Label>
<Textarea <Textarea
id="new-user-ssh" id="new-user-ssh"
rows={3} rows={3}
spellCheck={false} spellCheck={false}
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop" placeholder={t('settings.users.sshPlaceholder')}
className="font-mono text-xs" className="font-mono text-xs"
value={form.sshPublicKey} value={form.sshPublicKey}
onChange={(ev) => set('sshPublicKey')(ev.target.value)} onChange={(ev) => set('sshPublicKey')(ev.target.value)}
@@ -325,7 +328,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
Create account Create account
</Button> </Button>
<Button type="button" variant="ghost" onClick={close} disabled={saving}> <Button type="button" variant="ghost" onClick={close} disabled={saving}>
Cancel {tUi('button.cancel')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
import { PERMISSIONS_QUERY_KEY } from 'hooks/usePermissions'; import { PERMISSIONS_QUERY_KEY } from 'hooks/usePermissions';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/lib/i18n';
// What each ROLE may reach. Not each user — see the table comment in schema/permissions.ts for why. // What each ROLE may reach. Not each user — see the table comment in schema/permissions.ts for why.
// //
@@ -37,6 +38,7 @@ type Level = 'none' | 'read' | 'write';
const PERMISSIONS_KEY = ['ROLE_PERMISSIONS']; const PERMISSIONS_KEY = ['ROLE_PERMISSIONS'];
export const PermissionsSection = () => { export const PermissionsSection = () => {
const { t, tUi } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [role, setRole] = useState<string | null>(null); const [role, setRole] = useState<string | null>(null);
@@ -78,7 +80,7 @@ export const PermissionsSection = () => {
// The owner may be editing their own view's inputs — and anyone already signed in needs the dock to // The owner may be editing their own view's inputs — and anyone already signed in needs the dock to
// catch up without a reload. // catch up without a reload.
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_QUERY_KEY }); await queryClient.invalidateQueries({ queryKey: PERMISSIONS_QUERY_KEY });
toast.success(`Saved what ${activeRole}s can reach`); toast.success(t('settings.users.permissionsSaved', { role: activeRole }));
} catch (ex) { } catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not save'); toast.error(ex instanceof Error ? ex.message : 'Could not save');
} finally { } finally {
@@ -89,12 +91,12 @@ export const PermissionsSection = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground"> <div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading permissions… <Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingPermissions')}
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return <div className="p-6 text-sm text-destructive">Could not load permissions.</div>; return <div className="p-6 text-sm text-destructive">{t('settings.users.permissionsFailed')}</div>;
} }
return ( return (
@@ -103,7 +105,11 @@ export const PermissionsSection = () => {
hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real
buttons, because switching role mutates a draft rather than navigating. */} buttons, because switching role mutates a draft rather than navigating. */}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role"> <div
className="flex items-center gap-1 rounded-lg border p-1"
role="tablist"
aria-label={t('settings.users.role')}
>
{data.roles.map((r) => ( {data.roles.map((r) => (
<button <button
key={r} key={r}
@@ -128,9 +134,9 @@ export const PermissionsSection = () => {
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Everything is denied unless granted here. <strong>Read</strong> allows viewing, plus changes to things that are {t('settings.users.deniedUnlessGranted')} <strong>{t('settings.users.read')}</strong> allows viewing, plus
only ever the person&rsquo;s own — their favourites, their playlists, their devices. changes to things that are only ever the person&rsquo;s own — their favourites, their playlists, their devices.
<strong> Full</strong> allows everything within the app. <strong> {t('settings.users.full')}</strong> allows everything within the app.
</p> </p>
<div className="divide-y rounded-lg border"> <div className="divide-y rounded-lg border">
@@ -151,9 +157,9 @@ export const PermissionsSection = () => {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No access</SelectItem> <SelectItem value="none">{t('settings.users.noAccess')}</SelectItem>
<SelectItem value="read">Read</SelectItem> <SelectItem value="read">{t('settings.users.read')}</SelectItem>
<SelectItem value="write">Full</SelectItem> <SelectItem value="write">{t('settings.users.full')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -17,6 +17,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { CreateUserForm } from './CreateUserForm'; import { CreateUserForm } from './CreateUserForm';
import { copyToClipboard } from 'helpers/clipboard'; import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/lib/i18n';
type ManagedUser = { type ManagedUser = {
id: number; id: number;
@@ -43,6 +44,7 @@ type UsersResponse = {
const USERS_KEY = ['MANAGED_USERS']; const USERS_KEY = ['MANAGED_USERS'];
export const UsersSection = () => { export const UsersSection = () => {
const { t, tUi } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [pendingId, setPendingId] = useState<number | null>(null); const [pendingId, setPendingId] = useState<number | null>(null);
@@ -77,11 +79,7 @@ export const UsersSection = () => {
* reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it". * reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it".
*/ */
const provisionLinux = async (user: ManagedUser) => { const provisionLinux = async (user: ManagedUser) => {
const key = window.prompt( const key = window.prompt(t('settings.users.provisionPrompt', { email: user.email }), '');
`Linux account for ${user.email}.\n\n` +
`Paste an SSH public key to allow them to SSH in, or leave empty to keep the current one.`,
'',
);
// Cancel is null; empty string is a deliberate "no change". // Cancel is null; empty string is a deliberate "no change".
if (key === null) return; if (key === null) return;
@@ -147,13 +145,13 @@ export const UsersSection = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading accounts… <Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingAccounts')}
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return <div className="text-sm text-destructive">Could not load the accounts.</div>; return <div className="text-sm text-destructive">{t('settings.users.accountsFailed')}</div>;
} }
return ( return (
@@ -173,7 +171,12 @@ export const UsersSection = () => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{user.name || user.username || user.email}</span> <span className="truncate text-sm font-medium">{user.name || user.username || user.email}</span>
{user.isOwner && <Crown className="h-3.5 w-3.5 shrink-0 text-duck-teal" aria-label="Server owner" />} {user.isOwner && (
<Crown
className="h-3.5 w-3.5 shrink-0 text-duck-teal"
aria-label={t('settings.users.serverOwner')}
/>
)}
</div> </div>
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{user.email} {user.email}
@@ -212,11 +215,13 @@ export const UsersSection = () => {
size="icon" size="icon"
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`} className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
disabled={busy} disabled={busy}
aria-label={user.osUser ? `Repair ${user.email}'s Linux account` : `Create a Linux account`} aria-label={
user.osUser ? t('settings.users.repairFor', { email: user.email }) : t('settings.users.createLinux')
}
title={ title={
user.osUser user.osUser
? `Linux account: ${user.osUser} — click to repair or replace their SSH key` ? t('settings.users.linuxAccountFor', { osUser: user.osUser })
: 'No Linux account — click to create one' : t('settings.users.noLinux')
} }
onClick={() => void provisionLinux(user)} onClick={() => void provisionLinux(user)}
> >
@@ -231,11 +236,11 @@ export const UsersSection = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="shrink-0 text-muted-foreground" className="shrink-0 text-muted-foreground"
aria-label={`Copy ${user.email}'s SSH public key`} aria-label={t('settings.users.copySshFor', { email: user.email })}
title="Copy their SSH public key (add it to their Gitea account)" title={t('settings.users.copySshTitle')}
onClick={() => { onClick={() => {
void copyToClipboard(user.osSshPublicKey!); void copyToClipboard(user.osSshPublicKey!);
toast.success('Public key copied'); toast.success(t('settings.users.publicKeyCopied'));
}} }}
> >
<KeyRound className="h-4 w-4" /> <KeyRound className="h-4 w-4" />
@@ -250,8 +255,8 @@ export const UsersSection = () => {
size="icon" size="icon"
className="shrink-0 text-muted-foreground" className="shrink-0 text-muted-foreground"
disabled={busy} disabled={busy}
aria-label={`Reset ${user.email}'s password`} aria-label={t('settings.users.resetPasswordFor', { email: user.email })}
title="Generate a new password — shown once, and signs them out everywhere" title={t('settings.users.resetPasswordTitle')}
onClick={() => setConfirmReset(user)} onClick={() => setConfirmReset(user)}
> >
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />} {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
@@ -264,7 +269,7 @@ export const UsersSection = () => {
className="shrink-0 text-muted-foreground hover:text-destructive" className="shrink-0 text-muted-foreground hover:text-destructive"
disabled={user.isOwner || busy} disabled={user.isOwner || busy}
onClick={() => setConfirmDelete(user)} onClick={() => setConfirmDelete(user)}
aria-label={`Remove ${user.email}`} aria-label={t('settings.users.removeFor', { email: user.email })}
> >
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button> </Button>
@@ -282,19 +287,18 @@ export const UsersSection = () => {
<AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}> <AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Remove {confirmDelete?.email}?</AlertDialogTitle> <AlertDialogTitle>
<AlertDialogDescription> {t('settings.users.removeTitle', { email: confirmDelete?.email ?? '' })}
This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, and </AlertDialogTitle>
anything an installed plugin keeps for them. It cannot be undone. <AlertDialogDescription>{t('settings.users.removeBody')}</AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => confirmDelete && void remove(confirmDelete)} onClick={() => confirmDelete && void remove(confirmDelete)}
> >
Remove account {t('settings.users.removeAccount')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -305,23 +309,16 @@ export const UsersSection = () => {
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}> <AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Reset the password for {confirmReset?.email}?</AlertDialogTitle> <AlertDialogTitle>{t('settings.users.resetTitle', { email: confirmReset?.email ?? '' })}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up {t('settings.users.resetBody')}
afterwards. Their existing password stops working immediately, and they are signed out everywhere. {confirmReset?.osUser ? ` ${t('settings.users.resetLinuxNote', { osUser: confirmReset.osUser })}` : null}
{confirmReset?.osUser ? (
<>
{' '}
Their Linux account ({confirmReset.osUser}) is not affected: it has no password, and SSH keys are
unchanged.
</>
) : null}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}> <AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
Generate a new password {t('settings.users.generateNewPassword')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -345,11 +342,11 @@ export const UsersSection = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="shrink-0" className="shrink-0"
aria-label="Copy the new password" aria-label={t('settings.users.copyNewPassword')}
onClick={() => { onClick={() => {
if (!newPassword) return; if (!newPassword) return;
void copyToClipboard(newPassword.password).then((ok) => void copyToClipboard(newPassword.password).then((ok) =>
ok ? toast.success('Password copied') : toast.error('Could not copy — select it and copy by hand'), ok ? toast.success(t('settings.users.passwordCopied')) : toast.error(t('settings.users.copyFailed')),
); );
}} }}
> >
@@ -358,7 +355,7 @@ export const UsersSection = () => {
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogAction onClick={() => setNewPassword(null)}>Done</AlertDialogAction> <AlertDialogAction onClick={() => setNewPassword(null)}>{t('settings.users.done')}</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
type UntranslatedProps = {
children: ReactNode;
/** Why this text is not localized. Required so the decision is legible at the call site. */
reason: 'foreign-ui-label';
className?: string;
};
/**
* Text that is deliberately NOT localized, marked as such where it is written.
*
* The only reason so far is `foreign-ui-label`: a label quoted from another product's interface, which
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
* what the reader sees on Google's screen, and Google localises by their Google account language rather
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
*
* It exists as a component rather than a comment for two reasons. It states the decision at every call
* site, where the next person editing the sentence will see it. And it gives the scanner something exact
* to skip: without that, ~40 labels that MUST stay English count as debt forever, and the area can never
* be held at zero — the same trap as counting `new Error()` messages.
*
* `reason` is a closed union on purpose. A second legitimate reason should be argued for and added here,
* not smuggled in as free text.
*/
export const Untranslated = ({ children, className }: UntranslatedProps) => (
<strong className={className}>{children}</strong>
);
+17 -4
View File
@@ -6,9 +6,22 @@
*/ */
export type ShippedLocale = { code: string; label: string }; export type ShippedLocale = { code: string; label: string };
export const SHIPPED_LOCALES: ShippedLocale[] = [ /**
{ code: 'en-GB', label: 'English (UK)' }, * The label is an AUTONYM — each language named in its own language, so "Português" reads as Portuguese
{ code: 'pt-PT', label: 'Português (Portugal)' }, * whichever locale the UI is currently in. That is the convention for a language picker and it is the
]; * useful behaviour: somebody who cannot read the current language still has to find theirs.
*
* Computed rather than written down, for the same reason the spoken-language list is (see Languages.tsx):
* `Intl` already knows every language's name in every locale, including its own.
*/
const autonym = (code: string): string => {
const name = new Intl.DisplayNames([code], { type: 'language', fallback: 'code' }).of(code) ?? code;
return name.charAt(0).toUpperCase() + name.slice(1);
};
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT'].map((code) => ({
code,
label: autonym(code),
}));
export const FALLBACK_LOCALE = 'en-GB'; export const FALLBACK_LOCALE = 'en-GB';
+427 -24
View File
@@ -6,26 +6,68 @@
}, },
"settings": { "settings": {
"profile": { "profile": {
"searchPlaceholder": "Search settings...",
"sections": {
"profileTitle": "Profile",
"profileDescription": "Update your name and avatar.",
"changePasswordTitle": "Change Password",
"changePasswordDescription": "Update your account password.",
"tasksTitle": "Tasks",
"tasksDescription": "Default model for file browser tasks.",
"languagesTitle": "Languages",
"languagesDescription": "Set your spoken languages and translation preferences."
},
"userData": { "userData": {
"emailLabel": "Email", "email": "Email",
"nameLabel": "Name", "username": "Username",
"name": "Name",
"namePlaceholder": "Your name", "namePlaceholder": "Your name",
"profileUpdated": "Profile updated", "updated": "Profile updated",
"profileUpdateFailed": "Failed to update profile", "updateFailed": "Failed to update profile",
"selectImageFile": "Please select an image file", "selectImage": "Please select an image file",
"imageTooLarge": "Image must be smaller than 384KB" "imageTooLarge": "Image must be smaller than 384KB"
}, },
"apiKeys": {
"intro": "An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as",
"loading": "Loading keys…",
"mintedTitle": "Key for “{{name}}” — shown once",
"savedIt": "I’ve saved it",
"copyKey": "Copy key",
"copied": "Key copied",
"copyFailed": "Could not copy — select and copy manually",
"newKey": "New key",
"namePlaceholder": "iPhone, Music app, laptop CLI…",
"create": "Create",
"createFailed": "Could not create the key",
"noneYet": "No keys yet.",
"noneInUse": "No keys in use.",
"revoked": "revoked",
"expired": "expired",
"lastUsed": "last used {{date}}",
"neverUsed": "never used",
"forget": "Remove this record",
"forgetFailed": "Could not remove it",
"revoke": "Revoke",
"revokeDone": "Revoked — anything using that key is signed out",
"revokeFailed": "Could not revoke",
"hideDead": "Hide revoked and expired",
"showDead": {
"one": "Show {{count}} revoked or expired key",
"other": "Show {{count}} revoked or expired keys"
}
},
"voice": {
"label": "Voice",
"refresh": "Refresh voices",
"serverDefault": "Server default",
"hint": "Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.",
"stop": "Stop",
"listen": "Listen"
},
"dock": {
"visible": "Visible",
"hidden": "Hidden",
"dragToShow": "Drag items here to show in dock",
"allVisible": "All items visible",
"reset": "Reset to defaults"
},
"languages": {
"spokenLabel": "Languages you speak",
"addLanguage": "Add a language...",
"defaultLabel": "Default language",
"defaultHint": "Used for UI localization.",
"translateToLabel": "Translate to",
"translateToHint": "Target language when translating content you don't speak."
},
"changePassword": { "changePassword": {
"currentPasswordLabel": "Current Password", "currentPasswordLabel": "Current Password",
"currentPasswordPlaceholder": "Current password", "currentPasswordPlaceholder": "Current password",
@@ -38,13 +80,18 @@
"passwordChanged": "Password changed", "passwordChanged": "Password changed",
"passwordChangeFailed": "Failed to change password" "passwordChangeFailed": "Failed to change password"
}, },
"languages": { "models": {
"spokenLabel": "Languages you speak", "defaultModel": "Default Model",
"addLanguage": "Add a language...", "sameAsChat": "Same as chat default",
"defaultLabel": "Default language", "taskDefaultsSaved": "Task defaults saved",
"defaultHint": "Used for UI localization.", "saveFailed": "Failed to save settings",
"translateToLabel": "Translate to", "defaultChat": "Default Chat Model",
"translateToHint": "Target language when translating content you don't speak." "defaultChatHint": "Used when starting a new chat from the home screen",
"defaultProject": "Default Project Model",
"defaultProjectHint": "Used when starting a new chat inside a project dashboard",
"defaultTask": "Default Task Model",
"defaultTaskHint": "Used when running tasks from the file browser",
"aiDefaultsSaved": "AI model defaults saved"
} }
}, },
"sections": { "sections": {
@@ -173,6 +220,235 @@
"integrations": { "integrations": {
"enterprise": "Enterprise", "enterprise": "Enterprise",
"personal": "Personal" "personal": "Personal"
},
"server": {
"loading": "Loading…",
"provider": "Provider",
"model": "Model",
"voice": "Voice",
"ocr": {
"serverUrl": "Vision Model Server URL",
"refreshModels": "Refresh models",
"selectModel": "Select a model",
"saved": "OCR settings saved",
"saveFailed": "Failed to save OCR settings",
"reachable": "Vision model server is reachable"
},
"stt": {
"serverUrl": "Whisper Server URL",
"saved": "STT settings saved",
"saveFailed": "Failed to save STT settings",
"reachable": "Whisper server is reachable"
},
"tts": {
"openaiCompatible": "OpenAI-compatible (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
"refreshVoices": "Refresh voices",
"selectVoice": "Select a voice",
"saved": "TTS settings saved",
"saveFailed": "Failed to save TTS settings",
"playing": "Playing test audio",
"testFailed": "Test failed — could not reach TTS server"
},
"smtp": {
"mailhog": "MailHog (local testing)",
"resend": "Resend",
"custom": "Custom SMTP",
"apiKey": "API Key",
"host": "Host",
"port": "Port",
"username": "Username",
"password": "Password",
"tls": "TLS / SSL",
"fromName": "From Name",
"fromEmail": "From Email",
"sendTest": "Send Test Email",
"userExample": "user@example.com",
"recipientExample": "recipient@example.com",
"saved": "SMTP settings saved",
"saveFailed": "Failed to save SMTP settings",
"connected": "Connection successful",
"testSent": "Test email sent to {{email}}"
},
"maintenance": {
"updateTitle": "Update the platform",
"updateHint": "Changes nothing that is running. Whatever the update needs is listed here, and stays your call.",
"rebuild": "Rebuild the frontend",
"restart": "Restart the server",
"upToDate": "Already up to date",
"rebuilt": "Frontend rebuilt — refresh this page to see it",
"restarting": "Restarting — the page will reconnect on its own",
"pulled": {
"one": "Pulled {{count}} commit",
"other": "Pulled {{count}} commits"
}
},
"harnesses": {
"piMono": "Pi Mono",
"checking": "Checking…",
"localProviders": "Local Providers",
"remoteProviders": "Remote Providers",
"connect": "Connect",
"authenticate": "Authenticate",
"connectLocal": "Connect Local Provider",
"connectProvider": "Connect Provider",
"none": "No providers found.",
"updatePiMono": "Update Pi Mono",
"removeProvider": "Remove provider",
"namePlaceholder": "Name (e.g. My Ollama)",
"username": "Username",
"password": "Password",
"apiKey": "API Key",
"editKey": "Edit key",
"disconnect": "Disconnect provider",
"saveKey": "Save key",
"enterKey": "Enter API key",
"search": "Search providers…",
"connectFailed": "Failed to connect",
"authFailed": "Authentication failed",
"saveFailed": "Failed to save provider",
"connectedTo": "Connected to {{name}}"
}
},
"users": {
"loadingPermissions": "Loading permissions…",
"permissionsFailed": "Could not load permissions.",
"deniedUnlessGranted": "Everything is denied unless granted here.",
"read": "Read",
"full": "Full",
"noAccess": "No access",
"role": "Role",
"permissionsSaved": "Saved what {{role}}s can reach",
"copyBeforeClosing": "Copy what you need before closing — none of it can be shown again.",
"password": "Password",
"linuxAccount": "Linux account",
"sshKey": "Their SSH public key",
"addToGitea": "Add this to their Gitea account",
"linuxIncomplete": "The account works, but its Linux side did not finish",
"done": "Done",
"addAccount": "Add account",
"newAccount": "New account",
"email": "Email",
"name": "Name",
"username": "Username",
"usernameHint": "Letters, numbers, dots, hyphens and underscores.",
"generatePassword": "Generate a password",
"copyPassword": "Copy the password",
"passwordHint": "At least 12 characters, with upper and lower case, a number and a symbol.",
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@laptop",
"passwordCopied": "Password copied",
"loadingAccounts": "Loading accounts…",
"accountsFailed": "Could not load the accounts.",
"removeAccount": "Remove account",
"generateNewPassword": "Generate a new password",
"serverOwner": "Server owner",
"copySshTitle": "Copy their SSH public key (add it to their Gitea account)",
"resetPasswordTitle": "Generate a new password — shown once, and signs them out everywhere",
"copyNewPassword": "Copy the new password",
"publicKeyCopied": "Public key copied",
"copyFailed": "Could not copy — select it and copy by hand",
"repairFor": "Repair {{email}}’s Linux account",
"linuxAccountFor": "Linux account: {{osUser}} — click to repair or replace their SSH key",
"copySshFor": "Copy {{email}}’s SSH public key",
"resetPasswordFor": "Reset {{email}}’s password",
"removeFor": "Remove {{email}}",
"provisionPrompt": "Linux account for {{email}}.\n\nPaste an SSH public key to allow them to SSH in, or leave empty to keep the current one.",
"createLinux": "Create a Linux account",
"noLinux": "No Linux account — click to create one",
"removeTitle": "Remove {{email}}?",
"removeBody": "This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, and anything an installed plugin keeps for them. It cannot be undone.",
"resetTitle": "Reset the password for {{email}}?",
"resetBody": "A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up afterwards. Their existing password stops working immediately, and they are signed out everywhere.",
"resetLinuxNote": "Their Linux account ({{osUser}}) is not affected: it has no password, and SSH keys are unchanged."
},
"dav": {
"newDevice": "New device",
"create": "Create",
"none": "No devices yet.",
"placeholder": "Pixel 9, iPad, Thunderbird…",
"revoke": "Revoke",
"delete": "Delete",
"copyFailed": "Could not copy — select and copy manually",
"createFailed": "Could not create the app password",
"revoked": "Revoked — that device will stop syncing",
"revokeFailed": "Could not revoke",
"deleteFailed": "Could not delete",
"copyField": "Copy {{field}}",
"copied": "{{what}} copied"
},
"google": {
"guideToggle": "Step-by-step setup guide",
"step1": {
"title": "Create a Google Cloud project",
"body": "Open the project creation page, give it a name (for example “Officer”), and click:",
"link": "New Project"
},
"step2": {
"title": "Enable the APIs",
"body": "Open the API library and search for each of these, then click:",
"link": "API Library"
},
"step3": {
"title": "Configure the OAuth consent screen",
"link": "OAuth Branding",
"appName": "Set your organization name, or “Officer”, as:",
"supportEmail": "Set your admin email as:",
"devContact": "Add your admin email under:",
"save": "Then click:"
},
"step4": {
"title": "Set the audience",
"link": "OAuth Audience",
"internal": "If your team uses Google Workspace, choose — no verification needed:",
"external": "Otherwise choose, and add your team’s emails under Test users (required while the app is unverified; limit of 100 test users):"
},
"step5": {
"title": "Add scopes",
"link": "Data Access",
"body": "Open data access and search for and add the scopes below, then click:",
"note": "Google classifies the calendar scope as sensitive and the Gmail scope as restricted. That is fine for Internal apps (Google Workspace) and for External apps in testing mode; publishing to production with restricted scopes requires Google verification."
},
"step6": {
"title": "Create OAuth credentials",
"link": "Clients",
"body": "Open the clients page and click:",
"appType": "Application type:",
"nameAnything": "Name: anything (for example “Officer”)",
"redirectUris": "Authorized redirect URIs — add:",
"thenClick": "Then click:"
},
"step7": {
"title": "Copy the credentials",
"body": "A dialog shows your client ID and client secret. Copy both and paste them into the fields below."
},
"scopeGmail": "Read emails",
"scopeCalendar": "Read calendar events",
"verifying": "Verifying credentials…",
"valid": "Credentials valid",
"invalid": "Invalid credentials",
"verifyFailed": "Verification request failed",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"redirectUri": "Redirect URI",
"redirectUriHint": "Add this URI to your Google OAuth client’s authorized redirect URIs",
"saved": "Google OAuth configuration saved",
"saveFailed": "Failed to save Google OAuth configuration"
},
"googleAccount": {
"title": "Google Account",
"hint": "Connect your Google account for Calendar, Contacts, and other Google services.",
"connected": "Connected",
"disconnect": "Disconnect",
"connect": "Connect Google Account",
"connectedToast": "Google account connected",
"connectFailed": "Failed to connect Google account",
"disconnectedToast": "Google account disconnected",
"disconnectFailed": "Failed to disconnect Google account"
},
"apify": {
"token": "API Token",
"saved": "Apify API token saved",
"saveFailed": "Failed to save Apify API token"
} }
}, },
"header": { "header": {
@@ -208,7 +484,8 @@
"calendar": "Calendar", "calendar": "Calendar",
"contacts": "Contacts", "contacts": "Contacts",
"appStore": "App store", "appStore": "App store",
"marketplace": "Marketplace" "marketplace": "Marketplace",
"codeEditor": "Code Editor"
}, },
"theme": { "theme": {
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}", "pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}",
@@ -297,5 +574,131 @@
"submitted": "Bug report submitted", "submitted": "Bug report submitted",
"failed": "Failed to submit bug report", "failed": "Failed to submit bug report",
"submit": "Submit" "submit": "Submit"
},
"auth": {
"signInTitle": "Welcome Back",
"signInSubtitle": "Sign in to your account",
"emailOrUsername": "Email or username",
"password": "Password",
"forgotPassword": "Forgot password?",
"emailPlaceholder": "you@example.com",
"passwordPlaceholder": "Your password",
"forgotTitle": "Forgot Password",
"forgotSubtitle": "Enter your email to receive a reset link",
"email": "Email",
"backToLogin": "Back to login",
"checkEmail": "Check your email",
"checkEmailHint": "Click it to reset your password.",
"resetOk": "Password reset successfully. Please sign in.",
"verifying": "Verifying…",
"invalidLink": "Invalid Link",
"invalidLinkHint": "This reset link is invalid or has expired.",
"resetTitle": "Reset Password",
"resetSubtitle": "Enter your new password",
"confirmPassword": "Confirm Password",
"minChars": "Min 12 characters",
"confirmPlaceholder": "Confirm your password",
"bootstrapTitle": "Welcome, Admin",
"bootstrapSubtitle": "Create your administrator account",
"name": "Name",
"username": "Username",
"namePlaceholder": "Your name",
"adminEmailPlaceholder": "admin@example.com",
"bootstrapOk": "Administrator account created. Please sign in.",
"debug": {
"controls": "Controls:",
"camera": "Camera:",
"model": "Model:"
}
},
"shortcuts": {
"navigation": "Navigation",
"spotlight": "Spotlight — go to any screen, plugin or setting by name",
"thisList": "This list",
"themes": "Themes",
"themesNote": "The browser applies as you move, so you watch the app change rather than reading names.",
"openBrowser": "Open the theme browser",
"prevNextTheme": "Previous / next theme, without opening anything",
"starCurrent": "Star or unstar the current theme, from anywhere",
"inBrowser": "In the theme browser",
"moveList": "Move through the list",
"applyKeepBrowsing": "Apply the focused theme and keep browsing",
"restoreStart": "Put back the theme you started on",
"starFocused": "Star or unstar the focused theme",
"keepLooking": "Keep what you are looking at",
"cancelRestore": "Cancel and restore",
"inSpotlight": "In Spotlight",
"moveResults": "Move through the results",
"goThere": "Go there",
"close": "Close",
"chat": "Chat",
"chatNote": "Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.",
"prevNextPrompt": "Previous / next prompt you sent, shell-style",
"send": "Send · on an empty composer with something queued, send that now",
"newline": "Newline",
"stopTurn": "Stop the turn that is generating"
},
"jobs": {
"runningQueued": "Running & Queued",
"loading": "Loading…",
"nothingRunning": "Nothing running",
"history": "History",
"clearAll": "Clear all",
"selectJob": "Select a job",
"notFound": "Job not found.",
"backToJobs": "Back to jobs",
"stop": "Stop",
"testNotification": "Send a test notification to every configured channel",
"search": "Search…",
"deleteFinished": "Delete all finished jobs",
"noChannels": "No channels configured",
"notifyFailed": "Failed to send notification",
"stopRequested": "Stop requested",
"stopFailed": "Failed to stop",
"titles": "Titles",
"download": "Download",
"input": "Input",
"output": "Output",
"steps": "Steps",
"selectStep": "Select a step or sub-task to view output",
"noOutput": "No output yet",
"tokens": "Tokens:",
"cost": "Cost:",
"status": {
"running": "Running",
"queued": "Queued",
"completed": "Completed",
"failed": "Failed",
"stopped": "Stopped",
"interrupted": "Interrupted"
},
"failedWithCode": "Failed (exit {{code}})"
},
"activity": {
"title": "Activity",
"backgroundTasks": "Background tasks",
"detached": "Detached",
"backToList": "Back to the list",
"selectTask": "Select a task to follow its live output",
"noneRunning": "none running"
},
"passkeyGate": {
"setUp": "Set up your passkey"
},
"chatHistory": {
"loadFailed": "Could not load this conversation: {{reason}}"
},
"capability": {
"frontmatter": "Frontmatter",
"noMatches": "No matches",
"emptyFile": "Empty file",
"createFailed": "Could not create the {{kind}}",
"deleteFailed": "Could not delete the {{kind}}",
"searchPlaceholder": "Search {{kind}}…",
"selectOne": "Select a {{kind}}",
"selectToView": "Select a {{kind}} to view its contents",
"delete": "Delete",
"createPrompt": "Help me create the content for this new {{kind}} file",
"improvePrompt": "Help me understand and improve this {{kind}} file"
} }
} }
+429 -26
View File
@@ -6,25 +6,67 @@
}, },
"settings": { "settings": {
"profile": { "profile": {
"searchPlaceholder": "Pesquisar definições...",
"sections": {
"profileTitle": "Perfil",
"profileDescription": "Atualiza o teu nome e avatar.",
"changePasswordTitle": "Alterar Palavra-passe",
"changePasswordDescription": "Atualiza a palavra-passe da tua conta.",
"tasksTitle": "Tarefas",
"tasksDescription": "Modelo predefinido para tarefas do explorador de ficheiros.",
"languagesTitle": "Idiomas",
"languagesDescription": "Define os teus idiomas e preferências de tradução."
},
"userData": { "userData": {
"emailLabel": "Email", "email": "Email",
"nameLabel": "Nome", "username": "Nome de utilizador",
"namePlaceholder": "O teu nome", "name": "Nome",
"profileUpdated": "Perfil atualizado", "namePlaceholder": "O seu nome",
"profileUpdateFailed": "Falha ao atualizar perfil", "updated": "Perfil atualizado",
"selectImageFile": "Por favor seleciona um ficheiro de imagem", "updateFailed": "Falha ao atualizar o perfil",
"imageTooLarge": "A imagem deve ter menos de 384KB" "selectImage": "Selecione um ficheiro de imagem",
"imageTooLarge": "A imagem tem de ser menor do que 384KB"
},
"apiKeys": {
"intro": "Uma chave de API inicia sessão como você, sem palavra-passe e sem expirar. Uma chave funciona em todo o lado — uma chave não está associada a uma aplicação. Dar a cada dispositivo a sua própria chave serve apenas para poder cortar uma sem mexer nas restantes. Envie-a como",
"loading": "A carregar chaves…",
"mintedTitle": "Chave para “{{name}}” — mostrada uma única vez",
"savedIt": "Já a guardei",
"copyKey": "Copiar chave",
"copied": "Chave copiada",
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
"newKey": "Nova chave",
"namePlaceholder": "iPhone, aplicação Música, CLI do portátil…",
"create": "Criar",
"createFailed": "Não foi possível criar a chave",
"noneYet": "Ainda não há chaves.",
"noneInUse": "Nenhuma chave em uso.",
"revoked": "revogada",
"expired": "expirada",
"lastUsed": "usada pela última vez em {{date}}",
"neverUsed": "nunca usada",
"forget": "Remover este registo",
"forgetFailed": "Não foi possível remover",
"revoke": "Revogar",
"revokeDone": "Revogada — tudo o que usava essa chave foi desligado",
"revokeFailed": "Não foi possível revogar",
"hideDead": "Ocultar revogadas e expiradas",
"showDead": {
"one": "Mostrar {{count}} chave revogada ou expirada",
"other": "Mostrar {{count}} chaves revogadas ou expiradas"
}
},
"voice": {
"label": "Voz",
"refresh": "Atualizar vozes",
"serverDefault": "Predefinição do servidor",
"hint": "Escolha uma voz para a conversão de texto em fala. Deixe na predefinição do servidor para usar a voz configurada pelo administrador.",
"stop": "Parar",
"listen": "Ouvir"
},
"dock": {
"visible": "Visíveis",
"hidden": "Ocultos",
"dragToShow": "Arraste itens para aqui para os mostrar no dock",
"allVisible": "Todos os itens visíveis",
"reset": "Restaurar predefinições"
},
"languages": {
"spokenLabel": "Idiomas que falas",
"addLanguage": "Adicionar idioma...",
"defaultLabel": "Idioma predefinido",
"defaultHint": "Usado para localização da interface.",
"translateToLabel": "Traduzir para",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
}, },
"changePassword": { "changePassword": {
"currentPasswordLabel": "Palavra-passe Atual", "currentPasswordLabel": "Palavra-passe Atual",
@@ -38,13 +80,18 @@
"passwordChanged": "Palavra-passe alterada", "passwordChanged": "Palavra-passe alterada",
"passwordChangeFailed": "Falha ao alterar palavra-passe" "passwordChangeFailed": "Falha ao alterar palavra-passe"
}, },
"languages": { "models": {
"spokenLabel": "Idiomas que falas", "defaultModel": "Modelo predefinido",
"addLanguage": "Adicionar idioma...", "sameAsChat": "Igual à predefinição do chat",
"defaultLabel": "Idioma predefinido", "taskDefaultsSaved": "Predefinições de tarefas guardadas",
"defaultHint": "Usado para localização da interface.", "saveFailed": "Falha ao guardar as definições",
"translateToLabel": "Traduzir para", "defaultChat": "Modelo predefinido do chat",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas." "defaultChatHint": "Usado ao iniciar um novo chat a partir do ecrã inicial",
"defaultProject": "Modelo predefinido do projeto",
"defaultProjectHint": "Usado ao iniciar um novo chat dentro de um painel de projeto",
"defaultTask": "Modelo predefinido das tarefas",
"defaultTaskHint": "Usado ao executar tarefas a partir do explorador de ficheiros",
"aiDefaultsSaved": "Predefinições de modelos de IA guardadas"
} }
}, },
"sections": { "sections": {
@@ -173,6 +220,235 @@
"integrations": { "integrations": {
"enterprise": "Empresa", "enterprise": "Empresa",
"personal": "Pessoal" "personal": "Pessoal"
},
"server": {
"loading": "A carregar…",
"provider": "Fornecedor",
"model": "Modelo",
"voice": "Voz",
"ocr": {
"serverUrl": "URL do servidor do modelo de visão",
"refreshModels": "Atualizar modelos",
"selectModel": "Selecione um modelo",
"saved": "Definições de OCR guardadas",
"saveFailed": "Falha ao guardar as definições de OCR",
"reachable": "O servidor do modelo de visão está acessível"
},
"stt": {
"serverUrl": "URL do servidor Whisper",
"saved": "Definições de STT guardadas",
"saveFailed": "Falha ao guardar as definições de STT",
"reachable": "O servidor Whisper está acessível"
},
"tts": {
"openaiCompatible": "Compatível com OpenAI (Kokoro, OpenAI, etc.)",
"elevenLabs": "ElevenLabs",
"refreshVoices": "Atualizar vozes",
"selectVoice": "Selecione uma voz",
"saved": "Definições de TTS guardadas",
"saveFailed": "Falha ao guardar as definições de TTS",
"playing": "A reproduzir áudio de teste",
"testFailed": "Teste falhou — não foi possível contactar o servidor de TTS"
},
"smtp": {
"mailhog": "MailHog (testes locais)",
"resend": "Resend",
"custom": "SMTP personalizado",
"apiKey": "Chave de API",
"host": "Servidor",
"port": "Porta",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"tls": "TLS / SSL",
"fromName": "Nome do remetente",
"fromEmail": "Email do remetente",
"sendTest": "Enviar email de teste",
"userExample": "utilizador@exemplo.com",
"recipientExample": "destinatario@exemplo.com",
"saved": "Definições de SMTP guardadas",
"saveFailed": "Falha ao guardar as definições de SMTP",
"connected": "Ligação estabelecida",
"testSent": "Email de teste enviado para {{email}}"
},
"maintenance": {
"updateTitle": "Atualizar a plataforma",
"updateHint": "Não altera nada que esteja em execução. O que a atualização precisa está listado aqui, e a decisão é sua.",
"rebuild": "Reconstruir o frontend",
"restart": "Reiniciar o servidor",
"upToDate": "Já está atualizado",
"rebuilt": "Frontend reconstruído — atualize esta página para o ver",
"restarting": "A reiniciar — a página volta a ligar-se sozinha",
"pulled": {
"one": "{{count}} commit obtido",
"other": "{{count}} commits obtidos"
}
},
"harnesses": {
"piMono": "Pi Mono",
"checking": "A verificar…",
"localProviders": "Fornecedores locais",
"remoteProviders": "Fornecedores remotos",
"connect": "Ligar",
"authenticate": "Autenticar",
"connectLocal": "Ligar fornecedor local",
"connectProvider": "Ligar fornecedor",
"none": "Nenhum fornecedor encontrado.",
"updatePiMono": "Atualizar Pi Mono",
"removeProvider": "Remover fornecedor",
"namePlaceholder": "Nome (ex. O meu Ollama)",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"apiKey": "Chave de API",
"editKey": "Editar chave",
"disconnect": "Desligar fornecedor",
"saveKey": "Guardar chave",
"enterKey": "Introduza a chave de API",
"search": "Pesquisar fornecedores…",
"connectFailed": "Falha ao ligar",
"authFailed": "Falha na autenticação",
"saveFailed": "Falha ao guardar o fornecedor",
"connectedTo": "Ligado a {{name}}"
}
},
"users": {
"loadingPermissions": "A carregar permissões…",
"permissionsFailed": "Não foi possível carregar as permissões.",
"deniedUnlessGranted": "Tudo é negado a menos que seja concedido aqui.",
"read": "Leitura",
"full": "Total",
"noAccess": "Sem acesso",
"role": "Função",
"permissionsSaved": "Guardado o que os {{role}} podem alcançar",
"copyBeforeClosing": "Copie o que precisa antes de fechar — nada disto pode ser mostrado outra vez.",
"password": "Palavra-passe",
"linuxAccount": "Conta Linux",
"sshKey": "A sua chave pública SSH",
"addToGitea": "Adicione isto à conta Gitea dele",
"linuxIncomplete": "A conta funciona, mas a parte Linux não ficou concluída",
"done": "Concluído",
"addAccount": "Adicionar conta",
"newAccount": "Nova conta",
"email": "Email",
"name": "Nome",
"username": "Nome de utilizador",
"usernameHint": "Letras, números, pontos, hífenes e underscores.",
"generatePassword": "Gerar uma palavra-passe",
"copyPassword": "Copiar a palavra-passe",
"passwordHint": "Pelo menos 12 caracteres, com maiúsculas e minúsculas, um número e um símbolo.",
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@portatil",
"passwordCopied": "Palavra-passe copiada",
"loadingAccounts": "A carregar contas…",
"accountsFailed": "Não foi possível carregar as contas.",
"removeAccount": "Remover conta",
"generateNewPassword": "Gerar uma nova palavra-passe",
"serverOwner": "Proprietário do servidor",
"copySshTitle": "Copiar a sua chave pública SSH (adicione-a à conta Gitea dele)",
"resetPasswordTitle": "Gerar uma nova palavra-passe — mostrada uma única vez, e termina a sessão em todo o lado",
"copyNewPassword": "Copiar a nova palavra-passe",
"publicKeyCopied": "Chave pública copiada",
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
"repairFor": "Reparar a conta Linux de {{email}}",
"linuxAccountFor": "Conta Linux: {{osUser}} — clique para reparar ou substituir a chave SSH",
"copySshFor": "Copiar a chave pública SSH de {{email}}",
"resetPasswordFor": "Redefinir a palavra-passe de {{email}}",
"removeFor": "Remover {{email}}",
"provisionPrompt": "Conta Linux para {{email}}.\n\nCole uma chave pública SSH para lhe permitir aceder por SSH, ou deixe vazio para manter a atual.",
"createLinux": "Criar uma conta Linux",
"noLinux": "Sem conta Linux — clique para criar uma",
"removeTitle": "Remover {{email}}?",
"removeBody": "Isto elimina a conta e tudo o que lhe pertence — passkeys, painéis, layouts guardados e tudo o que qualquer plugin instalado guarde para ela. Não pode ser desfeito.",
"resetTitle": "Redefinir a palavra-passe de {{email}}?",
"resetBody": "É gerada uma nova palavra-passe e mostrada uma única vez — não é guardada em nenhum lugar e não pode ser consultada depois. A palavra-passe atual deixa de funcionar imediatamente, e a sessão é terminada em todo o lado.",
"resetLinuxNote": "A conta Linux ({{osUser}}) não é afetada: não tem palavra-passe, e as chaves SSH ficam inalteradas."
},
"dav": {
"newDevice": "Novo dispositivo",
"create": "Criar",
"none": "Ainda não há dispositivos.",
"placeholder": "Pixel 9, iPad, Thunderbird…",
"revoke": "Revogar",
"delete": "Eliminar",
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
"createFailed": "Não foi possível criar a palavra-passe de aplicação",
"revoked": "Revogada — esse dispositivo deixa de sincronizar",
"revokeFailed": "Não foi possível revogar",
"deleteFailed": "Não foi possível eliminar",
"copyField": "Copiar {{field}}",
"copied": "{{what}} copiado"
},
"google": {
"guideToggle": "Guia de configuração passo a passo",
"step1": {
"title": "Criar um projeto Google Cloud",
"body": "Abra a página de criação de projeto, dê-lhe um nome (por exemplo “Officer”) e clique em:",
"link": "New Project"
},
"step2": {
"title": "Ativar as APIs",
"body": "Abra a biblioteca de APIs, procure cada uma destas e clique em:",
"link": "API Library"
},
"step3": {
"title": "Configurar o ecrã de consentimento OAuth",
"link": "OAuth Branding",
"appName": "Defina o nome da sua organização, ou “Officer”, como:",
"supportEmail": "Defina o email do administrador como:",
"devContact": "Adicione o email do administrador em:",
"save": "Depois clique em:"
},
"step4": {
"title": "Definir o público",
"link": "OAuth Audience",
"internal": "Se a sua equipa usa Google Workspace, escolha — não é necessária verificação:",
"external": "Caso contrário escolha, e adicione os emails da sua equipa em Test users (obrigatório enquanto a aplicação não estiver verificada; limite de 100 utilizadores de teste):"
},
"step5": {
"title": "Adicionar âmbitos",
"link": "Data Access",
"body": "Abra o acesso a dados, procure e adicione os âmbitos abaixo e depois clique em:",
"note": "A Google classifica o âmbito do calendário como sensível e o do Gmail como restrito. Isso não é problema para aplicações Internal (Google Workspace) nem para aplicações External em modo de teste; publicar em produção com âmbitos restritos exige verificação pela Google."
},
"step6": {
"title": "Criar credenciais OAuth",
"link": "Clients",
"body": "Abra a página de clientes e clique em:",
"appType": "Tipo de aplicação:",
"nameAnything": "Nome: qualquer um (por exemplo “Officer”)",
"redirectUris": "URIs de redirecionamento autorizados — adicione:",
"thenClick": "Depois clique em:"
},
"step7": {
"title": "Copiar as credenciais",
"body": "Uma caixa de diálogo mostra o seu client ID e client secret. Copie ambos e cole-os nos campos abaixo."
},
"scopeGmail": "Ler emails",
"scopeCalendar": "Ler eventos do calendário",
"verifying": "A verificar credenciais…",
"valid": "Credenciais válidas",
"invalid": "Credenciais inválidas",
"verifyFailed": "O pedido de verificação falhou",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"redirectUri": "URI de redirecionamento",
"redirectUriHint": "Adicione este URI aos URIs de redirecionamento autorizados do seu cliente OAuth da Google",
"saved": "Configuração OAuth da Google guardada",
"saveFailed": "Falha ao guardar a configuração OAuth da Google"
},
"googleAccount": {
"title": "Conta Google",
"hint": "Ligue a sua conta Google para o Calendário, Contactos e outros serviços Google.",
"connected": "Ligada",
"disconnect": "Desligar",
"connect": "Ligar conta Google",
"connectedToast": "Conta Google ligada",
"connectFailed": "Falha ao ligar a conta Google",
"disconnectedToast": "Conta Google desligada",
"disconnectFailed": "Falha ao desligar a conta Google"
},
"apify": {
"token": "Token de API",
"saved": "Token de API do Apify guardado",
"saveFailed": "Falha ao guardar o token de API do Apify"
} }
}, },
"header": { "header": {
@@ -208,7 +484,8 @@
"calendar": "Calendário", "calendar": "Calendário",
"contacts": "Contactos", "contacts": "Contactos",
"appStore": "Loja de aplicações", "appStore": "Loja de aplicações",
"marketplace": "Marketplace" "marketplace": "Marketplace",
"codeEditor": "Editor de código"
}, },
"theme": { "theme": {
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}", "pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
@@ -297,5 +574,131 @@
"submitted": "Comunicação de erro enviada", "submitted": "Comunicação de erro enviada",
"failed": "Falha ao enviar a comunicação de erro", "failed": "Falha ao enviar a comunicação de erro",
"submit": "Enviar" "submit": "Enviar"
},
"auth": {
"signInTitle": "Bem-vindo de volta",
"signInSubtitle": "Inicie sessão na sua conta",
"emailOrUsername": "Email ou nome de utilizador",
"password": "Palavra-passe",
"forgotPassword": "Esqueceu-se da palavra-passe?",
"emailPlaceholder": "utilizador@exemplo.com",
"passwordPlaceholder": "A sua palavra-passe",
"forgotTitle": "Recuperar palavra-passe",
"forgotSubtitle": "Introduza o seu email para receber um link de recuperação",
"email": "Email",
"backToLogin": "Voltar ao início de sessão",
"checkEmail": "Verifique o seu email",
"checkEmailHint": "Clique nele para redefinir a palavra-passe.",
"resetOk": "Palavra-passe redefinida com sucesso. Inicie sessão.",
"verifying": "A verificar…",
"invalidLink": "Link inválido",
"invalidLinkHint": "Este link de recuperação é inválido ou expirou.",
"resetTitle": "Redefinir palavra-passe",
"resetSubtitle": "Introduza a sua nova palavra-passe",
"confirmPassword": "Confirmar palavra-passe",
"minChars": "Mínimo 12 caracteres",
"confirmPlaceholder": "Confirme a sua palavra-passe",
"bootstrapTitle": "Bem-vindo, Administrador",
"bootstrapSubtitle": "Crie a sua conta de administrador",
"name": "Nome",
"username": "Nome de utilizador",
"namePlaceholder": "O seu nome",
"adminEmailPlaceholder": "admin@exemplo.com",
"bootstrapOk": "Conta de administrador criada. Inicie sessão.",
"debug": {
"controls": "Controlos:",
"camera": "Câmara:",
"model": "Modelo:"
}
},
"shortcuts": {
"navigation": "Navegação",
"spotlight": "Spotlight — ir para qualquer ecrã, plugin ou definição pelo nome",
"thisList": "Esta lista",
"themes": "Temas",
"themesNote": "O explorador aplica à medida que se move, para que veja a aplicação mudar em vez de ler nomes.",
"openBrowser": "Abrir o explorador de temas",
"prevNextTheme": "Tema anterior / seguinte, sem abrir nada",
"starCurrent": "Marcar ou desmarcar o tema atual, a partir de qualquer lugar",
"inBrowser": "No explorador de temas",
"moveList": "Percorrer a lista",
"applyKeepBrowsing": "Aplicar o tema focado e continuar a explorar",
"restoreStart": "Voltar ao tema em que começou",
"starFocused": "Marcar ou desmarcar o tema focado",
"keepLooking": "Manter o que está a ver",
"cancelRestore": "Cancelar e restaurar",
"inSpotlight": "No Spotlight",
"moveResults": "Percorrer os resultados",
"goThere": "Ir para lá",
"close": "Fechar",
"chat": "Chat",
"chatNote": "A recuperação só usa a tecla de seta num compositor VAZIO, e qualquer outra tecla termina a recuperação — pelo que um rascunho nunca é substituído.",
"prevNextPrompt": "Prompt anterior / seguinte que enviou, à maneira da shell",
"send": "Enviar · num compositor vazio com algo em fila, enviar isso agora",
"newline": "Nova linha",
"stopTurn": "Parar o turno que está a gerar"
},
"jobs": {
"runningQueued": "Em execução e em fila",
"loading": "A carregar…",
"nothingRunning": "Nada em execução",
"history": "Histórico",
"clearAll": "Limpar tudo",
"selectJob": "Selecione um trabalho",
"notFound": "Trabalho não encontrado.",
"backToJobs": "Voltar aos trabalhos",
"stop": "Parar",
"testNotification": "Enviar uma notificação de teste para todos os canais configurados",
"search": "Pesquisar…",
"deleteFinished": "Eliminar todos os trabalhos terminados",
"noChannels": "Nenhum canal configurado",
"notifyFailed": "Falha ao enviar a notificação",
"stopRequested": "Paragem pedida",
"stopFailed": "Falha ao parar",
"titles": "Títulos",
"download": "Descarregar",
"input": "Entrada",
"output": "Saída",
"steps": "Passos",
"selectStep": "Selecione um passo ou subtarefa para ver a saída",
"noOutput": "Ainda não há saída",
"tokens": "Tokens:",
"cost": "Custo:",
"status": {
"running": "Em execução",
"queued": "Em fila",
"completed": "Concluído",
"failed": "Falhou",
"stopped": "Parado",
"interrupted": "Interrompido"
},
"failedWithCode": "Falhou (saída {{code}})"
},
"activity": {
"title": "Atividade",
"backgroundTasks": "Tarefas em segundo plano",
"detached": "Destacada",
"backToList": "Voltar à lista",
"selectTask": "Selecione uma tarefa para seguir a sua saída ao vivo",
"noneRunning": "nenhuma em execução"
},
"passkeyGate": {
"setUp": "Configure a sua passkey"
},
"chatHistory": {
"loadFailed": "Não foi possível carregar esta conversa: {{reason}}"
},
"capability": {
"frontmatter": "Frontmatter",
"noMatches": "Sem correspondências",
"emptyFile": "Ficheiro vazio",
"createFailed": "Não foi possível criar o {{kind}}",
"deleteFailed": "Não foi possível eliminar o {{kind}}",
"searchPlaceholder": "Pesquisar {{kind}}…",
"selectOne": "Selecione um {{kind}}",
"selectToView": "Selecione um {{kind}} para ver o seu conteúdo",
"delete": "Eliminar",
"createPrompt": "Ajuda-me a criar o conteúdo deste novo ficheiro de {{kind}}",
"improvePrompt": "Ajuda-me a compreender e melhorar este ficheiro de {{kind}}"
} }
} }
+35 -28
View File
@@ -1,3 +1,5 @@
import type { AppTranslationKey } from '@/lib/i18n';
/* /*
Every keyboard shortcut the shell owns, as data. Every keyboard shortcut the shell owns, as data.
@@ -22,92 +24,97 @@
export type Shortcut = { export type Shortcut = {
/** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */ /** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
keys: string; keys: string;
description: string; /** A catalog key. This is data with no component around it, so ShortcutsModal resolves it. */
descriptionKey: AppTranslationKey;
/** The file whose handler actually implements it, until this registry is authoritative. */ /** The file whose handler actually implements it, until this registry is authoritative. */
implementedIn: string; implementedIn: string;
}; };
export type ShortcutSection = { export type ShortcutSection = {
title: string; titleKey: AppTranslationKey;
/** Shown under the title when the section needs a caveat that a per-row description cannot carry. */ /** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
note?: string; noteKey?: AppTranslationKey;
shortcuts: Shortcut[]; shortcuts: Shortcut[];
}; };
export const SHORTCUT_SECTIONS: ShortcutSection[] = [ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{ {
title: 'Navigation', titleKey: 'shortcuts.navigation',
shortcuts: [ shortcuts: [
{ {
keys: 'Shift+Space', keys: 'Shift+Space',
description: 'Spotlight — go to any screen, plugin or setting by name', descriptionKey: 'shortcuts.spotlight',
implementedIn: 'Layout/Spotlight/Spotlight.tsx', implementedIn: 'Layout/Spotlight/Spotlight.tsx',
}, },
{ keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' }, { keys: 'Ctrl+?', descriptionKey: 'shortcuts.thisList', implementedIn: 'Layout/ShortcutsModal.tsx' },
], ],
}, },
{ {
title: 'Themes', titleKey: 'shortcuts.themes',
note: 'The browser applies as you move, so you watch the app change rather than reading names.', noteKey: 'shortcuts.themesNote',
shortcuts: [ shortcuts: [
{ {
keys: 'Ctrl+Shift+Space', keys: 'Ctrl+Shift+Space',
description: 'Open the theme browser', descriptionKey: 'shortcuts.openBrowser',
implementedIn: 'theme/useThemeKeys.ts', implementedIn: 'theme/useThemeKeys.ts',
}, },
{ {
keys: 'Ctrl+Shift+← / →', keys: 'Ctrl+Shift+← / →',
description: 'Previous / next theme, without opening anything', descriptionKey: 'shortcuts.prevNextTheme',
implementedIn: 'theme/useThemeKeys.ts', implementedIn: 'theme/useThemeKeys.ts',
}, },
{ {
keys: 'Ctrl+Shift+F', keys: 'Ctrl+Shift+F',
description: 'Star or unstar the current theme, from anywhere', descriptionKey: 'shortcuts.starCurrent',
implementedIn: 'theme/useThemeKeys.ts', implementedIn: 'theme/useThemeKeys.ts',
}, },
], ],
}, },
{ {
title: 'In the theme browser', titleKey: 'shortcuts.inBrowser',
shortcuts: [ shortcuts: [
{ keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' }, { keys: 'j / k ↑ / ↓', descriptionKey: 'shortcuts.moveList', implementedIn: 'theme/useThemeKeys.ts' },
{ {
keys: 'l →', keys: 'l →',
description: 'Apply the focused theme and keep browsing', descriptionKey: 'shortcuts.applyKeepBrowsing',
implementedIn: 'theme/useThemeKeys.ts', implementedIn: 'theme/useThemeKeys.ts',
}, },
{ keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' }, { keys: 'h ←', descriptionKey: 'shortcuts.restoreStart', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' }, { keys: 'f', descriptionKey: 'shortcuts.starFocused', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' }, { keys: 'Enter', descriptionKey: 'shortcuts.keepLooking', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' }, { keys: 'Esc', descriptionKey: 'shortcuts.cancelRestore', implementedIn: 'theme/useThemeKeys.ts' },
], ],
}, },
{ {
title: 'In Spotlight', titleKey: 'shortcuts.inSpotlight',
shortcuts: [ shortcuts: [
{ keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' }, { keys: '↑ / ↓', descriptionKey: 'shortcuts.moveResults', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' }, { keys: 'Enter', descriptionKey: 'shortcuts.goThere', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' }, { keys: 'Esc', descriptionKey: 'shortcuts.close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
], ],
}, },
{ {
title: 'Chat', titleKey: 'shortcuts.chat',
note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.', noteKey: 'shortcuts.chatNote',
shortcuts: [ shortcuts: [
{ {
keys: '↑ / ↓', keys: '↑ / ↓',
description: 'Previous / next prompt you sent, shell-style', descriptionKey: 'shortcuts.prevNextPrompt',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
}, },
{ {
keys: 'Enter', keys: 'Enter',
description: 'Send · on an empty composer with something queued, send that now', descriptionKey: 'shortcuts.send',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
{
keys: 'Shift+Enter',
descriptionKey: 'shortcuts.newline',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
}, },
{ keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
{ {
keys: 'Esc', keys: 'Esc',
description: 'Stop the turn that is generating', descriptionKey: 'shortcuts.stopTurn',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
}, },
], ],
@@ -15,6 +15,7 @@ export {
disarmCommandHqWatcher, disarmCommandHqWatcher,
recordCommandHqTurn, recordCommandHqTurn,
ingestSpawnedChildren, ingestSpawnedChildren,
parentCommanderOf,
recordSpawnedOutcome, recordSpawnedOutcome,
getCommandHqCostSummary, getCommandHqCostSummary,
listCommandHqTurns, listCommandHqTurns,
@@ -475,3 +475,44 @@ export async function recordSpawnedOutcome(
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)), and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
); );
} }
export type SpawnParent = {
/** The commander's transcript id — what `/command-hq/<id>` opens. */
sessionId: string;
/** What the owner called it, or null for a commander filed before the column existed. */
name: string | null;
firstSeenAt: string;
finishedAt: string | null;
};
/**
* Which commander spawned this session, if any.
*
* The same row `ingestSpawnedChildren` writes, read from the other end — so this needed no new table and no
* new write path. A commander's children were already recorded because deleting one has to take them with
* it; that record answers "who is my parent" for free, and nothing was asking.
*
* Null for almost every session, and that is the normal case rather than a miss: a chat somebody opened
* themselves has no parent, and a session spawned by anything other than a commander has no row here. The
* caller must treat absence as "not spawned", never as "lookup failed".
*/
export async function parentCommanderOf(spawnedSessionId: string): Promise<SpawnParent | null> {
const [row] = await db
.select({
sessionId: commandHqSessions.sessionId,
name: commandHqSessions.name,
firstSeenAt: commandHqSpawned.firstSeenAt,
finishedAt: commandHqSpawned.finishedAt,
})
.from(commandHqSpawned)
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
.where(eq(commandHqSpawned.spawnedSessionId, spawnedSessionId))
.limit(1);
if (!row) return null;
return {
sessionId: row.sessionId,
name: row.name,
firstSeenAt: row.firstSeenAt.toISOString(),
finishedAt: row.finishedAt ? row.finishedAt.toISOString() : null,
};
}
+10
View File
@@ -13,6 +13,7 @@ import {
getChatNotifyPreferences, getChatNotifyPreferences,
setChatNotifyPreference, setChatNotifyPreference,
forgetChatSessions, forgetChatSessions,
parentCommanderOf,
} from 'officerdb'; } from 'officerdb';
import type { NotifyPreference } from 'officerdb/types'; import type { NotifyPreference } from 'officerdb/types';
import { setNudge, getNudge, cancelNudge } from 'officerdb'; import { setNudge, getNudge, cancelNudge } from 'officerdb';
@@ -273,6 +274,15 @@ chatRouter.get('/sessions/:id', async (ctx) => {
travels with the conversation. travels with the conversation.
*/ */
isCommander: (await listCommandHqSessionIds(user.id)).has(id), isCommander: (await listCommandHqSessionIds(user.id)).has(id),
/*
Which commander spawned this, if any — the row `ingestSpawnedChildren` writes, read from the other
end. A spawned agent has always been fully readable here; what it could never say was who started
it, so opening one gave no way back to the conversation that owns it.
Null for almost every session and that is the normal case, not a miss: a chat somebody opened
themselves has no parent. Never fatal — a session must render when this lookup will not.
*/
spawnedBy: await parentCommanderOf(id).catch(() => null),
}); });
}); });
+133
View File
@@ -0,0 +1,133 @@
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import { chatIdentity } from '../chat/chat';
import { checkBudget } from './budget';
/*
POST /sub-agents/spawn — an agent asks Officer to run another agent.
── Why this exists rather than leaning on the harness ──
A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher
loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a
commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what
it started.
So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every
caller gets the same one, and none of it changes when a CLI does.
── The condition that comes before the feature ──
`checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are
spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the
last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's
conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a
floor: never spend the last 15%.
── What it is NOT ──
Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any
good is the caller's problem, and a caller that wants three tries writes three calls — because a retry
inside here would be a policy nobody chose, applied to work this endpoint cannot read.
*/
export const subAgentsRouter = createRouter();
const MAX_PROMPT = 100_000;
subAgentsRouter.post('/spawn', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean };
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`);
/*
Refused BEFORE anything is spawned, and the reason travels with the refusal.
A caller that gets "no room" needs to know which window and how close, or its only move is to try again
immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now",
not a "not you".
*/
const budget = await checkBudget();
if (!budget.ok) {
return ctx.json(
{ error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem },
429,
);
}
/*
The identity is resolved from the TOKEN, never from the body.
`chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same
resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to
run in.
*/
const who = await chatIdentity(user);
// Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first
// reports one, which is after this returns — see the note in the response below.
const sessionKey = crypto.randomUUID();
const spawn = {
userId: user.id,
email: who.email,
username: user.username ?? who.email.split('@')[0] ?? String(user.id),
prompt,
sessionKey,
...(body?.cwd ? { cwd: body.cwd } : {}),
...(body?.model ? { model: body.model } : {}),
/*
Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional
one let a call site forget it, and forgetting it means a member's agent silently running as the owner in
the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says
there is no Linux user to run as.
*/
member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined,
};
try {
/*
Two modes, and the default is the one a loop wants.
`wait: false` returns as soon as the agent is running, so a watcher can start work and get on with
watching. `wait: true` blocks for the answer, which is right for a question with a short answer and
wrong for anything that touches a fleet — the request would hold a socket open for the length of the
work.
*/
if (body?.wait === true) {
const result = await sidecar.spawnClaude(spawn);
return ctx.json({ sessionKey, result });
}
await sidecar.spawnClaudeStreaming(spawn);
/*
`sessionKey` and not a transcript id, because the transcript does not exist yet.
The agent announces its own uuid on its first turn, which is after this responds. A caller that needs
the transcript — to open `/chat/<id>`, or to record what it started — reads it from the live session
list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than
saying which id this is.
*/
return ctx.json({ sessionKey, running: true });
} catch (err) {
// The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it
// later is the correct response, which is a distinction a caller can act on.
// 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct
// response — a distinction the caller can act on.
throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent');
}
});
/**
* GET /sub-agents/budget — is there room to start one, without starting one.
*
* Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle
* than be refused mid-flight asks this first; the spawn checks again regardless, because between the two
* calls somebody else may have spent the window.
*/
subAgentsRouter.get('/budget', async (ctx) => {
const budget = await checkBudget();
return ctx.json(budget);
});
+2
View File
@@ -28,6 +28,7 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server'; import { terminalRouter } from './api/terminal/sidecar-server';
import { minesweeperRouter } from './api/minesweeper/router'; import { minesweeperRouter } from './api/minesweeper/router';
import { sendoffRouter } from './api/sendoff/router'; import { sendoffRouter } from './api/sendoff/router';
import { subAgentsRouter } from './api/sub-agents/router';
import { caldavRouter } from './api/dav/sidecar-server'; import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router'; // import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router'; import { appStoreRouter } from './api/app-store/router';
@@ -143,6 +144,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/terminal', terminalRouter], ['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter], ['/minesweeper', minesweeperRouter],
['/sendoff', sendoffRouter], ['/sendoff', sendoffRouter],
['/sub-agents', subAgentsRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13 // ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter], ['/app-store', appStoreRouter],
['/plugins', pluginsRouter], ['/plugins', pluginsRouter],
+12 -1
View File
@@ -241,8 +241,19 @@ describe('kinds', () => {
// both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed // both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential, // on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry. // transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
//
// `sub-agents` joined on 2026-08-23, and it is confined IN FACT on the day it was added rather than ahead of
// itself. `POST /sub-agents/spawn` resolves the identity through `chatIdentity` from the caller's token and
// passes `member: { osUser, home }` to the sidecar — the same mechanism `chat` runs under, reached by the
// same resolver. It inherits the containment rather than claiming it: there is no path in that endpoint that
// chooses a directory, so there is nothing for a caller to point somewhere else.
test('confined is a short, deliberate list', () => { test('confined is a short, deliberate list', () => {
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']); expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual([
'terminal',
'chat',
'files',
'sub-agents',
]);
}); });
test('admin permissions are never grantable', () => { test('admin permissions are never grantable', () => {
+18
View File
@@ -360,6 +360,24 @@ const CORE_REGISTRY: Permission[] = [
person who should decide whether somebody may put a file in your home is you, not whoever administers person who should decide whether somebody may put a file in your home is you, not whoever administers
roles. This grant only says the feature is reachable at all. roles. This grant only says the feature is reachable at all.
*/ */
/*
`confined`, and for the same reason chat is.
Spawning an agent runs a process in a home directory — the caller's own, resolved by `chatIdentity`
from their token. So it needs exactly what chat needs: a Linux account for a member, and nothing for
the owner, with the kernel doing the containing. `authorize.ts` drops a confined grant for an account
whose `osUser` is null, which is what stops "granted but unconfined" resolving to the owner's home.
Not `execution`: that kind exists for surfaces that can ONLY ever run as the owner. This one runs as
whoever called it, which is the whole distinction.
*/
{
key: 'sub-agents',
label: 'Sub-agents',
description: 'Start another agent from a prompt, subject to the account budget',
kind: 'confined',
api: ['/sub-agents'],
},
{ {
key: 'sendoff', key: 'sendoff',
label: 'SendOff', label: 'SendOff',
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router';
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels'; import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels';
import { useChat } from '../../hooks/useChat'; import { useChat } from '../../hooks/useChat';
@@ -21,8 +22,30 @@ type ResumeState = {
offset: number; offset: number;
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */ /** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
isCommander: boolean; isCommander: boolean;
/** The commander that started this, when one did — see `SpawnedByBar`. */
spawnedBy?: { sessionId: string; name: string | null } | null;
}; };
/**
* "Started by <commander>", above a transcript that was spawned rather than opened.
*
* A spawned agent has always rendered here perfectly — it writes an ordinary transcript and this view reads
* it like any other. What it could never say was where it came from, so somebody who followed a link into
* one had no way back to the conversation that owns it, and no way to tell it apart from a chat they had
* started themselves and forgotten.
*
* Renders nothing for the overwhelming majority of sessions, which have no parent. That is the normal case
* and it does not want a bar saying so.
*/
const SpawnedByBar = ({ parent }: { parent: { sessionId: string; name: string | null } }) => (
<div className="shrink-0 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
Started by{' '}
<Link to={`/command-hq/${parent.sessionId}`} className="text-foreground/80 underline hover:text-foreground">
{parent.name ?? 'a commander'}
</Link>
</div>
);
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in // Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
// on scroll. Matches the /chat screen. // on scroll. Matches the /chat screen.
const CHAT_TAIL = 20; const CHAT_TAIL = 20;
@@ -105,6 +128,7 @@ const ChatPanelInner = ({
return ( return (
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<AgentIdentityBar panel={agentPanel} /> <AgentIdentityBar panel={agentPanel} />
{resume?.spawnedBy && <SpawnedByBar parent={resume.spawnedBy} />}
<EmbeddableChat <EmbeddableChat
className="min-h-0 flex-1" className="min-h-0 flex-1"
chat={chat} chat={chat}
@@ -234,6 +258,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
total: detail.total, total: detail.total,
offset: detail.offset, offset: detail.offset,
isCommander: detail.isCommander === true, isCommander: detail.isCommander === true,
spawnedBy: detail.spawnedBy ?? null,
}); });
} catch { } catch {
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather // A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
@@ -104,6 +104,14 @@ export type ClaudeSessionDetail = {
* to decide it from the route, so the same commander opened in /chat quietly lost it. * to decide it from the route, so the same commander opened in /chat quietly lost it.
*/ */
isCommander?: boolean; isCommander?: boolean;
/**
* The commander that spawned this session, if one did.
*
* Read from the row a commander already keeps of its children — so this is the parent side of a link that
* existed for a different reason (deleting a commander has to take its children with it). Absent for
* almost every conversation, and absence means "nobody spawned this", never "the lookup failed".
*/
spawnedBy?: { sessionId: string; name: string | null; firstSeenAt: string; finishedAt: string | null } | null;
}; };
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean }; export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
@@ -1,8 +1,8 @@
--- ---
id: sub-agents/an-api-endpoint-for-spawning-an-agent-with-a id: sub-agents/an-api-endpoint-for-spawning-an-agent-with-a
title: "An API endpoint for spawning an agent with a prompt" title: 'An API endpoint for spawning an agent with a prompt'
theme: sub-agents theme: sub-agents
status: open status: done
created: 2026-08-21 created: 2026-08-21
date_source: stated date_source: stated
order: 1 order: 1
@@ -28,7 +28,6 @@ what Officer fixes.
optional.** An endpoint that spawns freely is exactly how a weekly limit gets spent by something nobody optional.** An endpoint that spawns freely is exactly how a weekly limit gets spent by something nobody
was watching. was watching.
--- ---
**2026-08-21, narrowing this considerably.** The endpoint largely exists: **2026-08-21, narrowing this considerably.** The endpoint largely exists:
@@ -43,3 +42,28 @@ already respects the window.
What is genuinely left is the SURFACE an agent can call — MCP tool or authenticated route — and how output What is genuinely left is the SURFACE an agent can call — MCP tool or authenticated route — and how output
returns to the caller. That is the remaining work on this item; the mechanism underneath it is done. returns to the caller. That is the remaining work on this item; the mechanism underneath it is done.
---
**Closed 2026-08-23.** `POST /api/sub-agents/spawn` — prompt, optional cwd and model, and either the session
key immediately or the result if you ask it to wait. `GET /api/sub-agents/budget` answers whether there is
room without consuming any.
Verified both modes against the live server rather than reasoned about: the synchronous call returned the
four words the agent was told to say, and the async call returned in milliseconds with the agent's file on
disk five seconds later.
Three things settled while building it, each worth not re-deriving:
- **The budget check is a precondition, not a refinement.** It runs before the spawn and refuses 429 naming
the window. `checkBudget()` already existed and was already tested, which is the only reason this was a
short job.
- **`confined`, and true on the day it shipped.** The registry test's own comment records that `chat` sat on
that list for a day before its turns actually ran as the member. This one inherits the mechanism rather
than claiming it — `chatIdentity` off the token, `member: { osUser, home }` to the sidecar — and no path in
the endpoint chooses a directory.
- **`member` is passed explicitly, never omitted.** The field is required-but-may-be-undefined on purpose:
optional let a call site forget it, and forgetting it means a member's agent running as the owner in the
owner's home.
What it deliberately is not: a queue, a supervisor, or a retry. One call starts one agent.