Compare commits

...
Author SHA1 Message Date
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
pastilhasandClaude Opus 5 7ce5af88aa todo: close the invisible spawned agent
The endpoint, table and ingest all existed and nothing called them, so the table
read empty and the feature looked unbuilt. Ingest on read means an empty table was
itself the symptom: nobody had looked.

The proposed second fix — the loop echoing the transcript id — turned out
unnecessary. The ids are already in the loop's spawned record, which the endpoint
ingests, so they reach the UI without the loop narrating anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:55:11 +00:00
pastilhasandClaude Opus 5 6bfc1d38f7 a spawned agent is a link, not a rumour
The endpoint, the table and the ingest all existed and nothing had ever called
them — so `command_hq_spawned` sat empty and a spawned agent stayed invisible even
though its transcript was readable the whole time. It writes an ordinary transcript
under the project directory for its cwd, so `/chat/<id>` has always rendered it
live. What was missing was anybody being told the id existed: the job panel showed
its output as a text stream in a small box with no link, so finding the session
meant knowing to look.

Now the commander's own pane lists them, each a link to its transcript, with a dot
on the ones still running. Ingest happens on read, so the list is right without a
timer that can stop working quietly.

Cost is stated as a floor when any child did not report one — `$1.20+` and a count
of unknowns, never a confident total. A commander's line reading $0.28 for a night
that spent several dollars is the specific understatement this exists to stop.

Renders nothing when there are none. A commander that has not fired yet is the
normal case and does not need a box explaining that.

Closes todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:54:54 +00:00
pastilhas c10dae3e6e scanner: text inside code, kbd, pre and samp is code, not prose
ApiKeys.tsx documents the header to send as <code>Authorization: Bearer …</code>.
That is an identifier crossing a boundary on the doc's never-translate list, and
translating it would break the thing it documents. Those four elements exist to mark
content as code, so the scanner now honours them.

Verified the delta across both remaining areas is exactly that one string.
2026-08-23 04:47:05 +00:00
pastilhasandClaude Opus 5 41fffbebe9 todo: close delivering a turn to a named session — answered from both ends
SendMessage does agent-to-agent with notify_when_idle instead of a poll, proven
tonight when one session took over the localisation hand-off from the owner. The
nudger does backend-to-session on a schedule, which is how a commander now reports
into its own conversation rather than a job panel.

Kept open deliberately: spawning a NEW session from a non-chat caller. The harness
Agent tool does not close that — it is available to a chat agent and nothing else,
which is why a commander's loop still shells out to claude -p.

Also kept: 'a spawned agent is invisible in the interface'. That is a defect in
Officer's own UI with a one-echo fix, and deleting it because an affordance now
describes coordination is exactly what docs/affordances.md warns about — an
affordance describing a defect calcifies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:47:03 +00:00
pastilhas c3c62828e3 settings framework and section metadata keyed (487 -> 425)
SettingsSection now carries titleKey/descriptionKey instead of title/description, and
the sidebar and content pane resolve them. The section arrays are module-level consts
holding JSX content, so they cannot call a hook — the data holds keys and the renderers
translate. Sidebar search matches the RESOLVED words, so it works in the language on
screen.

Capital K in titleKey is load-bearing: docs-check-settings.ts greps key:\s*'…' for
route segments, and a lowercase title_key would have fed it twenty bogus paths.
Verified 0 bogus, 26 declared, 20 offered, 0 missing.

Section titles now share one key with the Spotlight rows that name the same section,
per 'one key per meaning'. Two casings had to converge, so Settings shows Spotlight's:
'Change Password' now reads 'Change password'.

Settings sidebar search placeholder was 'Search...'; it is now 'Search settings…',
which is what the skeleton catalog already said and what distinguishes it from the
global Spotlight.
2026-08-23 04:46:07 +00:00
pastilhas 926034750f keyed the dock, header and shell chrome (555 -> 487)
Layout reaches zero. The dock, header, user menu, theme switcher, spotlight,
shortcuts modal, screen-error fallback, rescan and bug report.

Three things worth knowing:

CORE_DOCK_ITEMS now carries labelKey instead of label, and one useDockItems() hook
resolves the composed list. DashboardLayout, Spotlight and DockSettings each spread
the same two sources before, so each would have had to resolve labels itself.
Plugin tiles keep the name from their own manifest — a plugin's strings are not ours.

Spotlight resolves labels INSIDE the hook, so rank() still matches what the reader
can see rather than a dot-path. Its to: '/settings/...' literals are untouched, which
is what docs:check parses — verified still 20 offered, 0 missing.

The rescan toast said 'N skill(s)'. That is English giving up on a plural, so it is
now a real plural entry per noun and reads correctly at 0, 1 and 2 in both locales.

ThemeSwitcher used t as its theme loop variable; renamed to entry.
2026-08-23 04:40:39 +00:00
pastilhasandClaude Opus 5 6d8709c05a plan: roles as data, teams as scopes, and the owner as an identity
Design for tomorrow, written down so the work starts from a spec. Phase 0 is
already built; everything after it is plan.

The load-bearing decision is the smallest one. Ownership is answered today by
users.role === 'Super Admin', which was fine while roles were a hardcoded union
and nothing could write a new one — and becomes a privilege-escalation path the
moment roles are editable data, because then adding a role and becoming the owner
are the same feature. So phase 1 is just that: the owner is OWNER_USER_ID, every
existing call site keeps meaning owner, and nothing downstream starts until it
lands.

Teams union rather than restrict, which is the only coherent resolution and has a
consequence worth stating: a team can only ever grant. Deny rules are named under
what must not be built, along with a role that confers ownership and team-scoped
execution capabilities.

And the thing a reader would otherwise discover at the end of phase 4: there is
still no way to create a second account, so this is a permission model for
accounts that cannot yet exist. Sequenced last because it is orthogonal, flagged
because it may be worth doing first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:40:27 +00:00
pastilhas 2765e241d3 TranslationKeys stops at a plural entry instead of walking into it
A phase 0 defect that could only surface once a real catalog used a plural. The type
walked into { one, other } and contributed 'foo.one'/'foo.other' to the key union, so
t('foo', { count }) — the whole point of D7 — did not compile, while t('foo.one') did.

Found by the type checker on the first genuine plural in phase 2 (the rescan toast).
Mirrors isPluralForms at runtime: every key a CLDR category, every value a string.
Pinned with type-level @ts-expect-error assertions, since nothing else would notice.
2026-08-23 04:38:26 +00:00
pastilhas 36e18f935d scanner: do not count commented-out code
Header.tsx has a disabled dev-console button inside a JSX comment and
IntegrationsSettings has a commented-out section literal. Four hits in
officer-web, all of them code nobody ships — and an area cannot reach zero while
a comment counts against it.

Only two shapes stripped, both unambiguous: block comments, and lines whose first
non-space characters are //. Stripping every // would eat the tail of any string
holding a URL. Verified the delta is exactly those four.
2026-08-23 04:32:06 +00:00
pastilhas 0ddcdcbdbf memoize t in the typed wrappers, and give app code typed access to ui.*
Each wrapper built a fresh closure every render, so `t` in a dependency array
recomputed forever. That is the documented useClient()/Jellyfin trap in
platform/CLAUDE.md, and phase 2 needs t in useMemo deps immediately.

useTranslation in officer-web now also returns tUi, typed against the shared
components' catalog. 'Cancel' and 'Close' recur across the shell and already exist
in ui.* — the alternative was importing two hooks in a hundred files.
2026-08-23 04:30:07 +00:00
pastilhas 99a78f3c7f note the provider requirement shared components now carry, under open question 3
Found while converting them in phase 1: they resolve against whatever provider is
above them and render dot-paths with none. Safe today only because the second React
root imports nothing from components/. Written where the plugin-contract decision
will be made rather than solved here.
2026-08-23 04:27:04 +00:00
pastilhasandClaude Opus 5 845699e697 deploy: arming for the first time has nothing to rehearse against
A commander read the general doctrine — run the action once before arming — and
proposed replaying the last release against it. Fair reading of what was written,
and wrong here: every tag already has a success record, so the claim refuses, and
the only way to manufacture a trigger is to tag a release, which it must never do.

So the deploy procedure now says it outright: arm, and state that the action has
never executed under you. The next real release is the first run.

This belongs in deploy documentation rather than commander doctrine. The general
rule is sound; what is specific to this job is that its triggers are all spent, and
the agent should not have to be told that by the owner after the fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:23:43 +00:00
pastilhas fe000541b7 hold shared components at zero hardcoded strings
The area reached zero convertible strings, so it goes in ZEROED and the scanner's
--check now fails if a future feature reintroduces one. Verified both ways: exit 1
naming the string and file when one is added, exit 0 when it is gone.

Enabled per area rather than globally, as the doc says — officer-web and
officerdev are still at 559 and 578, and would be red for weeks.
2026-08-23 04:19:41 +00:00
pastilhasandClaude Opus 5 dfbf9be61c the two messages are an obligation in the brief, not advice in the skill
A commander read the skill section on telling the owner and did not mention it in
its read-back, so the owner would have had to remind it — which is the failure, not
the remedy. Doctrine in the skill is advice a commander may or may not weigh;
the brief is the prompt and always arrives. Same lesson as moving the output
constraints there earlier tonight.

So the brief now states it as owed: two messages per event, one when the thing
happens and one when the work is done, and never having to be asked. The skill
still carries the how.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:18:32 +00:00
pastilhas be29ae7aa7 delete two unreferenced components from another codebase
MarkdownEditor and ui/start-confetti-button had no importers anywhere in the repo
and both carried a different product's content: MarkdownEditor hardcoded
https://app.pertento.ai/logo.png with alt="Pertento", and start-confetti-button
spoke about 'the experiment' and 'Draft/Created' statuses.

Translating either would have put feature words into ui.*, which D6 forbids —
a shared namespace may not name a feature.

todo/inputs-outside-forms named MarkdownEditor as deliberately-unwrapped, so that
entry is updated rather than left pointing at a deleted file.
2026-08-23 04:18:08 +00:00
pastilhas e3e790fd2b command block and colour picker keyed
ColorPicker's one string was placeholder="Font Weight" on an <input type="color">,
where a placeholder is both wrong text and inert — browsers do not render one on a
colour input. Keyed it as ui.colorPicker.placeholder and moved it to the hex text
field beside it, which is a real text input and had none. Translating it in place
would have created a key guaranteed never to be seen.

Both files still have no importers; CommandBlock stays because commands the user
might run need a copy button by convention.
2026-08-23 04:16:54 +00:00
pastilhas 2b0b671895 scanner: separate never-translate hits from convertible ones, and stop counting tailwind as prose
The --check guard compared an area's whole hit count against zero, including
'thrown errors' — which the doc's never-translate list forbids converting. An
area holding one legitimate new Error() could therefore never be added to
ZEROED, so the debt guard could never be switched on for it at all. Categories
now carry neverTranslate, are still reported, and are excluded from --check.

Also drops sonner's 'group-[.toast]:text-muted-foreground', matched as an object
label. The filter tests per token rather than per string: the first attempt
tested the whole string and ate a real label, 'Application type:'.

--check now prints the offending strings and files instead of only a count.
2026-08-23 04:13:21 +00:00
pastilhas 2ec3f11cdd shadcn primitives and logos keyed: dialog, sheet, pagination, carousel, breadcrumb, theme toggles
These are the strings a user meets everywhere, mostly sr-only: the dialog and
sheet close buttons, pagination labels, carousel arrows, the breadcrumb ellipsis
and both colour-mode toggles. nav aria-label='pagination' became a real key too —
a screen reader announces it, so it is user-facing.

Logo alt text keeps the product name and translates only around it
('Logótipo Officer'). Arrow-button insets became logical (-start-12/-end-12).

The three 'useX must be used within <Y>' Errors are deliberately untouched:
never-translate, per the doc's list.
2026-08-23 04:10:39 +00:00
pastilhas 5ce1a2928e inputs and data views keyed: search, select, tabs, state blocks, data table
The DataTable's pagination carries the only interpolated strings in this area
('Showing page {{current}} of {{total}}', '{{size}} / page'). No plural forms:
nothing here is a count-varying noun, so inventing one would only add a key
nobody uses.

DataTable.test.tsx now renders inside an I18nProvider with the real en-GB
catalog, so it still asserts the English a user sees rather than a dot-path.

Left/right utilities became logical (ms-/me-/ps-/pe-/start-) per D3.
2026-08-23 04:10:33 +00:00
pastilhas 339d31f05c ui.* catalog and the dialogs: error dialogs, confirm and paper dialogs keyed
Establishes the ui.* namespace for phases 2 and 3. Both catalogs complete, so the
key-parity test holds. Two English copy defects fixed while keying, because the
Portuguese must not inherit them: 'Your are not authorized' -> 'You are', and
'Please Signin again' -> 'Please sign in again'.

The server's own message in ServerError/CustomError stays verbatim — it is a
response, not UI copy (D1).
2026-08-23 04:10:27 +00:00
pastilhas a444280cb4 deploy report 2026.08.23.03 2026-08-23 04:02:58 +00:00
78 changed files with 2449 additions and 991 deletions
+4 -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`.
@@ -477,6 +477,9 @@ explaining why it was safe.
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level, owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the - `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
+42
View File
@@ -0,0 +1,42 @@
# 2026.08.23.03 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
attempted, nothing spent on it).
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
authorised and none was needed.
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
## Per host
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
`online`** — not just `officer`, every entry.
## Notes, and what was not done
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
restarting `officer` on it is the owner's deliberate last step.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
never have collided with it, so it is not a reason to skip a host.
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
before `officer` itself was touched.
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
- **Nothing was retried and nothing was repaired.** No step failed on any host.
+13
View File
@@ -360,6 +360,19 @@ else will ever notice.
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered. obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin 3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
manifest. Their strings are not counted above. manifest. Their strings are not counted above.
**One constraint that contract has to answer, found while converting the shared components
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
provider-absent fallback inside the `components` package was considered and rejected: it would have
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not 4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
the UI. It is a different feature that happens to sit in the same object — do not conflate them. the UI. It is a different feature that happens to sit in the same object — do not conflate them.
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared 5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
+210
View File
@@ -0,0 +1,210 @@
# Roles and teams
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
design, written down so the work starts from a spec rather than from a conversation.
## What this is
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
a homelab and it cannot express a company, where the same person is an administrator of one area and an
ordinary member of another.
Two things are wanted, and they are separable:
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
provisioning.
- **Teams**, so a user belongs to several and holds a different role in each.
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
uses both. Nothing below may make teams mandatory to reach a feature.
---
## What already landed — phase 0, 2026-08-23
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
Verified against a scratch database: push reported the violation, created the table, skipped the
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
---
## Decisions
### D1. The owner is an identity. It is never a role.
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
whatever UI manages roles.
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
else can wait behind it.
### D2. Several Super Admins are allowed, and the role provisions sudo
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
a company has an owner and several administrators, and a homelab owner may add one or never do.
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
they left behind.
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
the UI to say what it is rather than presenting it as one more row in a dropdown.
### D3. `Admin` is app-wide authority with no operating-system privilege
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
"administering the platform is not developing on it". This decision only writes that down.
### D4. A role may be deleted, but not while anyone holds it
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
nobody asked for, and "absence denies" is the posture everywhere else in this system.
`isSystem` roles refuse both delete and rename, because the code names them by name:
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
`Member`. Renaming one is a code change that has not happened yet.
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
rename cascades to every user and every grant, atomically, with no code change.
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
### D6. A team is a scope. Permissions UNION across a user's teams.
Admin in marketing and member in developers means marketing's admin level does not reach developers'
files — but a user's effective permission for a given capability is the **highest level any of their teams
grants**, plus their global role.
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
should be refused rather than half-built.
### D7. The global role remains, and answers a different question
Two questions, two mechanisms:
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
- **"What may this account do in this area?"** — team membership with a role in that team.
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
has no natural team to ask it about. A homelab uses only the first and never makes a team.
### D8. Per-role provisioning is a script the role carries
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
in code that should become a row.
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
rootless specifically. `usermod -aG docker` is root by a longer route —
`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet seed. Any
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
---
## Phases
### Phase 0 — roles are a table ✅ DONE 2026-08-23
Additive only. Nothing reads it.
### Phase 1 — the owner stops being a role
The load-bearing phase, and small.
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
"owner or Super Admin" is a separate, reviewable change per route.
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
Reviewable on its own, and nothing downstream can start until ownership is an identity.
### Phase 2 — the foreign key, and role CRUD
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
replace what the compiler was doing.
### Phase 3 — per-role provisioning
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
D8's warning in front of whoever writes the first script.
### Phase 4 — teams
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
one table with a nullable `team_id`.
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
4. Teams UI, and the account screen showing which teams a user is in and as what.
### Phase 5 — the thing that actually unblocks all of it
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
for accounts that cannot yet exist.
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
find nothing to test it with. **It may be worth doing first.**
---
## What must not be built
- **Deny rules.** See D6. Teams grant; they never restrict.
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
the answer is a `confined` grant with a Linux user, which is per-account by nature.
## Verification
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
binary directly.
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
## Open questions
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
or a self-service one.
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
holds globally; the same question exists per team and is not answered here.
+21
View File
@@ -9,6 +9,27 @@ one.
--- ---
## Arming for the first time — there is nothing to rehearse against
General commander doctrine says to run the action once before arming, so it is known to work unattended.
**That does not apply here, and reading it as "replay the last release" is wrong.**
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
release, which you must never do.
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
fights the claim or invents an occasion.
Two things follow from it, and they are the reason this is fine rather than a gap:
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
What is unproven is this loop, not the rollout.
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
## What wakes you ## What wakes you
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
+80 -10
View File
@@ -26,29 +26,48 @@ const AREAS = [
/** /**
* Areas that have been converted and must stay converted. Add an area here the moment it reaches * Areas that have been converted and must stay converted. Add an area here the moment it reaches
* zero — enabling every area on day one would just be red for weeks and get ignored. * zero — enabling every area on day one would just be red for weeks and get ignored.
*
* '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
* neverTranslate split below has to exist for this entry to be possible at all.
*/ */
const ZEROED: string[] = []; const ZEROED: string[] = ['shared components'];
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/; const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
// A quoted literal, single or double. Kept as two groups because JS has no branch reset. // A quoted literal, single or double. Kept as two groups because JS has no branch reset.
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`; const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
const PATTERNS: { kind: string; rx: RegExp }[] = [ /**
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
*
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
*/
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
// >Some visible words< // >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g }, { kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends // placeholder="..." and friends
{ {
kind: 'attributes', kind: 'attributes',
rx: new RegExp(String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q, 'g'), rx: new RegExp(
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
'g',
),
}, },
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') }, { kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category. // label: 'Status' — nav items, menus, table columns. The largest hidden category.
{ {
kind: 'object labels', kind: 'object labels',
rx: new RegExp(String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q, 'g'), rx: new RegExp(
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
'g',
),
}, },
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g') }, // Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert. // `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g }, { kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
]; ];
@@ -62,9 +81,42 @@ function isHuman(value: string | undefined): value is string {
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; 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;
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
// `description: 'group-[.toast]:text-muted-foreground'`.
//
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
const tokens = s.split(/\s+/);
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
return true; return true;
} }
/**
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
*
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
*/
const stripComments = (source: string): string =>
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
/**
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
* thing it documents.
*/
const stripCodeElements = (source: string): string =>
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
function* walk(dir: string): Generator<string> { function* walk(dir: string): Generator<string> {
let entries: string[]; let entries: string[];
try { try {
@@ -96,7 +148,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
files++; files++;
let source: string; let source: string;
try { try {
source = readFileSync(file, 'utf8'); source = stripCodeElements(stripComments(readFileSync(file, 'utf8')));
} catch { } catch {
continue; continue;
} }
@@ -118,7 +170,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind); const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n); const pad = (s: string | number, n: number) => String(s).padStart(n);
console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`); console.log(
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
);
for (const r of results) { for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join(''); const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`); console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
@@ -128,9 +182,19 @@ const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value)); const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value)); const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`); const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
console.log(
`${'TOTAL'.padEnd(22)} ${pad(
results.reduce((n, r) => n + r.files, 0),
5,
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
);
console.log(); console.log();
console.log(`occurrences ${all.length}`); console.log(`occurrences ${all.length}`);
console.log(` convertible ${convertible(all).length}`);
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
console.log(`distinct strings ${distinct.size}`); console.log(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`); console.log(` needing params ${interpolated.size}`);
@@ -149,10 +213,16 @@ if (listPrefix) {
} }
if (args.has('--check')) { if (args.has('--check')) {
const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0); const regressed = results
.filter((r) => ZEROED.includes(r.label))
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
.filter((r) => r.hits.length > 0);
if (regressed.length > 0) { if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:'); console.error('\nThese areas were converted and have regressed:');
for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`); for (const r of regressed) {
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
}
console.error('\nUse a catalog key. See docs/localization.md.'); console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1); process.exit(1);
} }
@@ -1,16 +1,18 @@
import { Bug } from 'lucide-react'; import { Bug } from 'lucide-react';
import { useBugReport } from './use-bug-report'; import { useBugReport } from './use-bug-report';
import { useTranslation } from '@/lib/i18n';
import { BugReportDialog } from './BugReportDialog'; import { BugReportDialog } from './BugReportDialog';
export const BugReportButton = () => { export const BugReportButton = () => {
const bugReport = useBugReport(); const bugReport = useBugReport();
const { t } = useTranslation();
return ( return (
<> <>
<button <button
onClick={bugReport.handleOpen} onClick={bugReport.handleOpen}
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110" className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
aria-label="Report a bug" aria-label={t('bugReport.label')}
> >
<Bug size={16} /> <Bug size={16} />
</button> </button>
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslation } from '@/lib/i18n';
type BugReportDialogProps = { type BugReportDialogProps = {
open: boolean; open: boolean;
@@ -21,6 +22,7 @@ export const BugReportDialog = ({
onClose, onClose,
onSubmit, onSubmit,
}: BugReportDialogProps) => { }: BugReportDialogProps) => {
const { t, tUi } = useTranslation();
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]); const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
@@ -36,7 +38,7 @@ export const BugReportDialog = ({
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Report a Bug</DialogTitle> <DialogTitle>{t('bugReport.title')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
@@ -46,34 +48,32 @@ export const BugReportDialog = ({
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : previewUrl ? ( ) : previewUrl ? (
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" /> <img src={previewUrl} alt={t('bugReport.screenshot')} className="max-h-48 w-full object-contain" />
) : ( ) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground"> <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
No screenshot captured {t('bugReport.noScreenshot')}
</div> </div>
)} )}
</div> </div>
<Textarea <Textarea
placeholder="Describe what went wrong..." placeholder={t('bugReport.describePlaceholder')}
value={description} value={description}
onChange={(ev) => setDescription(ev.target.value)} onChange={(ev) => setDescription(ev.target.value)}
rows={4} rows={4}
autoFocus autoFocus
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('bugReport.collectedNote')}</p>
Console logs, browser info, and recent API errors are collected automatically.
</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}> <Button variant="outline" onClick={onClose} disabled={submitting}>
Cancel {tUi('button.cancel')}
</Button> </Button>
<Button onClick={handleSubmit} disabled={!canSubmit}> <Button onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} {submitting ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
Submit {t('bugReport.submit')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -6,6 +6,7 @@ import { useGlobal } from 'hooks/useGlobal';
import type { ErrorDetails } from 'hooks/useClient'; import type { ErrorDetails } from 'hooks/useClient';
import { getConsoleBuffer } from '@/lib/console-buffer'; import { getConsoleBuffer } from '@/lib/console-buffer';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from '@/lib/i18n';
export const useBugReport = () => { export const useBugReport = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -14,6 +15,7 @@ export const useBugReport = () => {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const client = useClient(); const client = useClient();
const { user } = useAuth(); const { user } = useAuth();
const { t } = useTranslation();
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null); const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
const handleOpen = useCallback(async () => { const handleOpen = useCallback(async () => {
@@ -57,15 +59,15 @@ export const useBugReport = () => {
} }
await client.post('/bug-report', formData); await client.post('/bug-report', formData);
toast.success('Bug report submitted'); toast.success(t('bugReport.submitted'));
handleClose(); handleClose();
} catch { } catch {
toast.error('Failed to submit bug report'); toast.error(t('bugReport.failed'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, },
[screenshot, client, user, apiError, handleClose], [screenshot, client, user, apiError, handleClose, t],
); );
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit }; return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
@@ -6,7 +6,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback'; import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background'; import { Background } from './Background';
import { Header } from './Header'; import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock'; import { Dock, useDockItems, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight'; import { Spotlight } from './Spotlight/Spotlight';
import { ShortcutsModal } from './ShortcutsModal'; import { ShortcutsModal } from './ShortcutsModal';
import { useIsTouch } from './useIsTouch'; import { useIsTouch } from './useIsTouch';
@@ -24,10 +24,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
// will then slam is worse than one that never showed it. // will then slam is worse than one that never showed it.
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes // The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one. // anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
const permitted = useMemo( const allDockItems = useDockItems(plugins);
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)), const permitted = useMemo(() => allDockItems.filter((item) => canVisit(item.to)), [canVisit, allDockItems]);
[canVisit, plugins],
);
const { items: visibleItems } = useDock( const { items: visibleItems } = useDock(
permitted, permitted,
useMemo(() => defaultDockPaths(plugins), [plugins]), useMemo(() => defaultDockPaths(plugins), [plugins]),
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink } from 'react-router'; import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { resolveIcon } from 'officerdev'; import { resolveIcon } from 'officerdev';
import type { PluginManifest } from 'hooks/usePermissions'; import type { PluginManifest } from 'hooks/usePermissions';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
export type DockItem = { export type DockItem = {
/** Already resolved for the active locale — see `useDockItems`. */
label: string; label: string;
to: string; to: string;
color: string; color: string;
@@ -169,22 +171,22 @@ import {
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in * These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
* the light profile because it fronts a remote instance and installs nothing locally. * the light profile because it fronts a remote instance and installs nothing locally.
*/ */
export const CORE_DOCK_ITEMS: DockItem[] = [ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' }, { labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, { labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, { labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' }, { labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, { labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, { labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' }, { labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, { labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because it is the door into another account's filesystem, and the permission that gates it is // Core because it is the door into another account's filesystem, and the permission that gates it is
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment // `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets. // every other grantable tile here gets.
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' }, { labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and // Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working // contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure // while the dock and the settings list went silent about them, which is exactly the shape of failure
@@ -194,16 +196,16 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork // the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color` // does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
// still earns its place: the active glow, the indicator dot and the settings tint all use it. // still earns its place: the active glow, the indicator dot and the settings tint all use it.
{ label: 'Calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' }, { labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
{ label: 'Contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' }, { labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the // Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`, // tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one. // and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
// Core by necessity: the store is how every other feature arrives, so it can never be one of the // Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled. // things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' }, { labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' },
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one. // Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
{ label: 'Marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' }, { labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
]; ];
/** /**
@@ -213,6 +215,30 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than * have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock. * throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
*/ */
/**
* A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only
* dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest,
* which is the plugin's string and not ours to translate (docs/localization.md open question 3).
*/
type CoreDockItem = Omit<DockItem, 'label'> & { labelKey: AppTranslationKey };
/**
* The composed dock, with the shell's own labels in the active locale.
*
* One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by
* spreading the same two sources, and each would otherwise have to resolve labels itself.
*/
export function useDockItems(plugins: PluginManifest[]): DockItem[] {
const { t } = useTranslation();
return useMemo(
() => [
...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })),
...dockItemsFromPlugins(plugins),
],
[t, plugins],
);
}
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] { export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
return plugins.flatMap((plugin) => { return plugins.flatMap((plugin) => {
const tile = (t: { const tile = (t: {
@@ -13,6 +13,7 @@ import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle'; import { usePageTitle } from '@/state/usePageTitle';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useThemeAsset } from '@/components/ui/ThemeProvider'; import { useThemeAsset } from '@/components/ui/ThemeProvider';
import { useTranslation } from '@/lib/i18n';
// import { BugReportButton } from '../BugReport/BugReportButton'; // import { BugReportButton } from '../BugReport/BugReportButton';
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a // Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
@@ -20,14 +21,15 @@ import { useThemeAsset } from '@/components/ui/ThemeProvider';
// the field hand the tab back to the route's own name. // the field hand the tab back to the route's own name.
function EditablePageTitle() { function EditablePageTitle() {
const [title, rename] = usePageTitle(); const [title, rename] = usePageTitle();
const { t } = useTranslation();
return ( return (
<EditableTitle <EditableTitle
value={title} value={title}
onCommit={rename} onCommit={rename}
allowEmpty allowEmpty
ariaLabel="Tab name" ariaLabel={t('header.tabName')}
hint="Click to rename this tab — clear it to go back to the page name" hint={t('header.tabNameHint')}
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl" className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl" inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
/> />
@@ -48,6 +50,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isTouch = useIsTouch(); const isTouch = useIsTouch();
const { user } = useAuth(); const { user } = useAuth();
const { t } = useTranslation();
// The mark comes from the theme — a white-labelled build replaces it without touching this file. // The mark comes from the theme — a white-labelled build replaces it without touching this file.
const logo = useThemeAsset('--logo-src'); const logo = useThemeAsset('--logo-src');
@@ -81,7 +84,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
*/} */}
<div <div
role="img" role="img"
aria-label="Officer" aria-label={t('header.logoAlt')}
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat" className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark — /* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
@@ -119,7 +122,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="left"> <SheetContent side="left">
<SheetHeader> <SheetHeader>
<SheetTitle>Navigation</SheetTitle> <SheetTitle>{t('header.navigation')}</SheetTitle>
</SheetHeader> </SheetHeader>
<nav className="flex flex-col gap-1 mt-4 px-2"> <nav className="flex flex-col gap-1 mt-4 px-2">
{dockItems.map((item) => ( {dockItems.map((item) => (
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
import { Loader2, ListOrdered } from 'lucide-react'; import { Loader2, ListOrdered } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions'; import { usePermissions } from 'hooks/usePermissions';
import { useTranslation } from '@/lib/i18n';
type Counts = { running: number; runningJobId: string | null; queued: number }; type Counts = { running: number; runningJobId: string | null; queued: number };
@@ -15,6 +16,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
export const JobsIndicator = () => { export const JobsIndicator = () => {
const client = useClient(); const client = useClient();
const { can } = usePermissions(); const { can } = usePermissions();
const { t } = useTranslation();
const allowed = can('tasks'); const allowed = can('tasks');
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 }); const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
@@ -44,7 +46,7 @@ export const JobsIndicator = () => {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Link <Link
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'} to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
title={counts.running ? 'Running job' : 'Nothing running'} title={counts.running ? t('header.jobsRunning') : t('header.jobsNothingRunning')}
className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`} className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`}
> >
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} /> <Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
@@ -52,7 +54,7 @@ export const JobsIndicator = () => {
</Link> </Link>
<Link <Link
to="/jobs" to="/jobs"
title="Queued jobs" title={t('header.jobsQueued')}
className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`} className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`}
> >
<ListOrdered className="h-3.5 w-3.5" /> <ListOrdered className="h-3.5 w-3.5" />
@@ -5,6 +5,7 @@ import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
import * as Dropdown from '@/components/ui/dropdown-menu'; import * as Dropdown from '@/components/ui/dropdown-menu';
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider'; import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
import { useThemeFavorites } from '@/state/useThemeFavorites'; import { useThemeFavorites } from '@/state/useThemeFavorites';
import { useTranslation } from '@/lib/i18n';
const { const {
DropdownMenu, DropdownMenu,
@@ -32,6 +33,7 @@ const {
*/ */
export const ThemeSwitcher = () => { export const ThemeSwitcher = () => {
const { theme, themeId, setTheme, themes } = useTheme(); const { theme, themeId, setTheme, themes } = useTheme();
const { t } = useTranslation();
const { favorites, isFavorite, toggle } = useThemeFavorites(); const { favorites, isFavorite, toggle } = useThemeFavorites();
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere. // Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle }); const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
@@ -50,7 +52,7 @@ export const ThemeSwitcher = () => {
*/ */
const ordered = useMemo(() => { const ordered = useMemo(() => {
const rotated = rotateToSelected(themes, themeId); const rotated = rotateToSelected(themes, themeId);
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated; const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
return favouritesFirst(listed, favorites); return favouritesFirst(listed, favorites);
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those. // `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]); }, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
@@ -62,10 +64,11 @@ export const ThemeSwitcher = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${ title={t('theme.pillTitle', {
isFavorite(themeId) ? 'unstar' : 'star' name: theme.name,
}`} starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
aria-label={`Theme: ${theme.name}`} })}
aria-label={t('theme.pillLabel', { name: theme.name })}
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth // Named rather than a palette glyph: with 227 themes the one you are wearing is worth
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so // reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
// the cluster stays one shape. // the cluster stays one shape.
@@ -89,8 +92,8 @@ export const ThemeSwitcher = () => {
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0"> <DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
<div className="shrink-0 border-b border-border px-2 py-1.5"> <div className="shrink-0 border-b border-border px-2 py-1.5">
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground"> <DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span> <span className="block">{t('theme.cycleHint')}</span>
<span className="block">ctrl+? for every shortcut</span> <span className="block">{t('theme.everyShortcutHint')}</span>
</DropdownMenuLabel> </DropdownMenuLabel>
<button <button
type="button" type="button"
@@ -105,35 +108,41 @@ export const ThemeSwitcher = () => {
}`} }`}
> >
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} /> <Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`} {onlyFavorites
? t('theme.favouritesOnly', { count: favorites.size })
: t('theme.favourites', { count: favorites.size })}
</button> </button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-1"> <div className="min-h-0 flex-1 overflow-y-auto p-1">
{ordered.length === 0 ? ( {ordered.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-muted-foreground"> <p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
No favourites yet — star one with the button on its row.
</p>
) : ( ) : (
families.map(([family, entries]) => ( families.map(([family, entries]) => (
<div key={family}> <div key={family}>
{entries.map((t) => ( {entries.map((entry) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1"> <DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} /> <Check
<span className="min-w-0 flex-1 truncate">{t.name}</span> className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */} {/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
<button <button
type="button" type="button"
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`} aria-label={
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'} isFavorite(entry.id)
? t('theme.unstarNamed', { name: entry.name })
: t('theme.starNamed', { name: entry.name })
}
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
onClick={(ev) => { onClick={(ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
toggle(t.id); toggle(entry.id);
}} }}
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary" className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
> >
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} /> <Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -165,22 +174,24 @@ export const ThemeSwitcher = () => {
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
same thing in both, or the browse teaches you an order the menu then contradicts. */} same thing in both, or the browse teaches you an order the menu then contradicts. */}
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto"> <ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
{ordered.map((t) => { {ordered.map((entry) => {
const isFocused = t.id === focused; const isFocused = entry.id === focused;
return ( return (
<li key={t.id}> <li key={entry.id}>
<button <button
onMouseEnter={() => setFocused(t.id)} onMouseEnter={() => setFocused(entry.id)}
onClick={() => { onClick={() => {
setTheme(t.id); setTheme(entry.id);
setOpen(false); setOpen(false);
}} }}
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${ className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100' isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
}`} }`}
> >
<span className="min-w-0 flex-1 truncate">{t.name}</span> <span className="min-w-0 flex-1 truncate">{entry.name}</span>
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>} {entry.id === themeId && (
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
)}
{/* {/*
A span, not a button: this sits inside the row's own <button>, and a button inside A span, not a button: this sits inside the row's own <button>, and a button inside
a button is invalid HTML that browsers resolve by dropping one of them. The click a button is invalid HTML that browsers resolve by dropping one of them. The click
@@ -190,18 +201,24 @@ export const ThemeSwitcher = () => {
<span <span
role="button" role="button"
tabIndex={-1} tabIndex={-1}
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`} aria-label={
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'} isFavorite(entry.id)
? t('theme.unstarNamed', { name: entry.name })
: t('theme.starNamed', { name: entry.name })
}
title={
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
}
onClick={(ev) => { onClick={(ev) => {
// Or the row's own onClick applies the theme and closes the overlay, which is // Or the row's own onClick applies the theme and closes the overlay, which is
// the opposite of what starring is for. // the opposite of what starring is for.
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
toggle(t.id); toggle(entry.id);
}} }}
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100" className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
> >
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} /> <Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
</span> </span>
</button> </button>
</li> </li>
@@ -210,9 +227,7 @@ export const ThemeSwitcher = () => {
</ul> </ul>
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's {/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
header does. */} header does. */}
<p className="mt-6 text-center text-xs opacity-60"> <p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
</p>
</div> </div>
</div>, </div>,
document.body, document.body,
@@ -27,13 +27,13 @@ export function UserMenu() {
<DropdownMenuContent align="end" className="z-10 w-48"> <DropdownMenuContent align="end" className="z-10 w-48">
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile"> <Link to="/settings/profile">
<User className="mr-2 h-4 w-4" /> <User className="me-2 h-4 w-4" />
{t('header.userMenu.profile')} {t('header.userMenu.profile')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/system"> <Link to="/settings/system">
<Settings className="mr-2 h-4 w-4" /> <Settings className="me-2 h-4 w-4" />
{t('header.userMenu.systemSettings')} {t('header.userMenu.systemSettings')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@@ -42,14 +42,14 @@ export function UserMenu() {
(navigation-audit M8.) */} (navigation-audit M8.) */}
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai"> <Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" /> <Bot className="me-2 h-4 w-4" />
AI {t('header.userMenu.ai')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/integrations"> <Link to="/settings/integrations">
<Puzzle className="mr-2 h-4 w-4" /> <Puzzle className="me-2 h-4 w-4" />
Integrations {t('header.userMenu.integrations')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{/* Owner only. This is tidiness, not access control — the route stays reachable and its {/* Owner only. This is tidiness, not access control — the route stays reachable and its
@@ -58,15 +58,15 @@ export function UserMenu() {
{user?.role === 'Super Admin' && ( {user?.role === 'Super Admin' && (
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/user-management"> <Link to="/settings/user-management">
<UserCog className="mr-2 h-4 w-4" /> <UserCog className="me-2 h-4 w-4" />
User Management {t('header.userMenu.userManagement')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout"> <Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="me-2 h-4 w-4" />
{t('header.userMenu.signOut')} {t('header.userMenu.signOut')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@@ -4,9 +4,29 @@ import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions'; import { usePermissions } from 'hooks/usePermissions';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
type RescanResponse = { ok: boolean; counts: Record<string, number> }; type RescanResponse = { ok: boolean; counts: Record<string, number> };
/**
* One key per counted noun rather than one sentence with six numbers in it.
*
* Each noun pluralizes on its own count — English needs it for `1 task` vs `2 tasks`, and a locale with
* more than two categories needs it for every value. Assembling the sentence in code and translating only
* the frame would have hardcoded English plural rules into the frame.
*
* Spelled out as literal pairs rather than built from the count name: a key assembled at runtime defeats
* the type checker and cannot be grepped (see the key naming convention in docs/localization.md).
*/
const RESCAN_COUNTS: readonly (readonly [string, AppTranslationKey])[] = [
['tasks', 'rescan.counts.tasks'],
['agents', 'rescan.counts.agents'],
['tools', 'rescan.counts.tools'],
['skills', 'rescan.counts.skills'],
['processes', 'rescan.counts.processes'],
['affordances', 'rescan.counts.affordances'],
];
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the // Item query caches the header refreshes after a rescan (matches the queryKeys used by the
// Automation and Capability pages). // Automation and Capability pages).
const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories']; const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories'];
@@ -15,6 +35,7 @@ export function RescanButton() {
const client = useClient(); const client = useClient();
const qc = useQueryClient(); const qc = useQueryClient();
const { can } = usePermissions(); const { can } = usePermissions();
const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the // `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
@@ -32,32 +53,28 @@ export function RescanButton() {
// you next open that page. This refreshes every item cache immediately, wherever you press it. // you next open that page. This refreshes every item cache immediately, wherever you press it.
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' }))); await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
const c = res.counts ?? {}; const c = res.counts ?? {};
toast.success( const summary = RESCAN_COUNTS.map(([noun, key]) => t(key, { count: c[noun] ?? 0 })).join(', ');
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`, toast.success(t('rescan.done', { summary }), {
{ // Two things said here rather than left to be discovered.
// Two things said here rather than left to be discovered. //
// // A rescan cannot reach a chat that is already open — its system prompt was assembled when it
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it // started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an // agent, because the loader skips it: a line that does not say when to reach for something
// agent, because the loader skips it: a line that does not say when to reach for something // costs context in every conversation and earns nothing. So a skill can be present, listed and
// costs context in every conversation and earns nothing. So a skill can be present, listed and // editable while being invisible to every agent, and the only symptom is two numbers
// editable while being invisible to every agent, and the only symptom is two numbers // disagreeing where nobody is comparing them.
// disagreeing where nobody is comparing them. description:
description: [
[ c.affordances || c.skillsOffered ? t('rescan.newChatsOnly') : null,
c.affordances || c.skillsOffered typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
? 'Applies to new chats — an open one keeps what it started with.' ? t('rescan.noDescription', { count: c.skills - c.skillsOffered })
: null, : null,
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills ]
? `${c.skills - c.skillsOffered} skill(s) have no description, so no agent is offered them.` .filter(Boolean)
: null, .join(' ') || undefined,
] });
.filter(Boolean)
.join(' ') || undefined,
},
);
} catch { } catch {
toast.error('Failed to rescan items'); toast.error(t('rescan.failed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -68,8 +85,8 @@ export function RescanButton() {
onClick={rescan} onClick={rescan}
disabled={loading} disabled={loading}
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50" className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
aria-label="Rescan items" aria-label={t('rescan.label')}
title="Rescan officer-items (apply changes without restarting)" title={t('rescan.title')}
> >
<RotateCw size={16} className={loading ? 'animate-spin' : ''} /> <RotateCw size={16} className={loading ? 'animate-spin' : ''} />
</button> </button>
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/lib/i18n';
type ScreenErrorFallbackProps = { error: Error; reset: () => void }; type ScreenErrorFallbackProps = { error: Error; reset: () => void };
@@ -18,6 +19,7 @@ type ScreenErrorFallbackProps = { error: Error; reset: () => void };
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => { export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
@@ -46,36 +48,34 @@ export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps)
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" /> <AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-xl font-semibold">This screen failed to render</h1> <h1 className="text-xl font-semibold">{t('screenError.title')}</h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">{t('screenError.description')}</p>
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
untouched.
</p>
</div> </div>
</div> </div>
<details className="bg-muted/40 rounded-md border p-3 text-sm"> <details className="bg-muted/40 rounded-md border p-3 text-sm">
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary> <summary className="cursor-pointer select-none font-medium">
{error.message || t('screenError.unknownError')}
</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre> <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
</details> </details>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={reset}>Try again</Button> <Button onClick={reset}>{t('screenError.tryAgain')}</Button>
<Button variant="secondary" onClick={() => window.location.reload()}> <Button variant="secondary" onClick={() => window.location.reload()}>
Reload {t('screenError.reload')}
</Button> </Button>
<Button variant="secondary" asChild> <Button variant="secondary" asChild>
<Link to="/">Go home</Link> <Link to="/">{t('screenError.goHome')}</Link>
</Button> </Button>
</div> </div>
<div className="space-y-2 border-t pt-4"> <div className="space-y-2 border-t pt-4">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's {t('screenError.resetHint')} <strong>{t('screenError.dashboardsSafe')}</strong>
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
</p> </p>
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}> <Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'} {resetting ? t('screenError.resetting') : confirming ? t('screenError.resetConfirm') : t('screenError.reset')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Keyboard } from 'lucide-react'; import { Keyboard } from 'lucide-react';
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts'; import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
import { useTranslation } from '@/lib/i18n';
/* /*
`Ctrl+?` — every shortcut the shell owns, on one screen. `Ctrl+?` — every shortcut the shell owns, on one screen.
@@ -27,6 +28,7 @@ const Row = ({ keys, description }: { keys: string; description: string }) => (
export const ShortcutsModal = () => { export const ShortcutsModal = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const onKey = (ev: KeyboardEvent) => { const onKey = (ev: KeyboardEvent) => {
@@ -57,8 +59,8 @@ export const ShortcutsModal = () => {
> >
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3"> <div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
<Keyboard className="h-4 w-4 text-muted-foreground" /> <Keyboard className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2> <h2 className="text-sm font-semibold">{t('shortcutsModal.title')}</h2>
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span> <span className="ms-auto text-xs text-muted-foreground">{t('shortcutsModal.hint')}</span>
</div> </div>
{/* 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. */}
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { rank } from './match'; import { rank } from './match';
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems'; import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
import { useTranslation } from '@/lib/i18n';
// Spotlight: type a few letters, go somewhere. // Spotlight: type a few letters, go somewhere.
// //
@@ -181,6 +182,7 @@ export const Spotlight = () => {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const items = useSpotlightItems(); const items = useSpotlightItems();
const { t } = useTranslation();
const results = useMemo(() => rank(items, query), [items, query]); const results = useMemo(() => rank(items, query), [items, query]);
@@ -287,14 +289,14 @@ export const Spotlight = () => {
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(ev) => setQuery(ev.target.value)} onChange={(ev) => setQuery(ev.target.value)}
placeholder="Go to…" placeholder={t('spotlight.placeholder')}
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground" className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
/> />
</div> </div>
<div className="max-h-[60vh] overflow-y-auto p-2"> <div className="max-h-[60vh] overflow-y-auto p-2">
{results.length === 0 ? ( {results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p> <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t('spotlight.noResults', { query })}</p>
) : ( ) : (
results.map((result, i) => ( results.map((result, i) => (
<Row <Row
@@ -19,7 +19,8 @@ import {
Wrench, Wrench,
} from 'lucide-react'; } from 'lucide-react';
import { usePermissions } from 'hooks/usePermissions'; import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock'; import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
import { useDockItems } from '../Dock';
// What Spotlight can take you to. // What Spotlight can take you to.
// //
@@ -40,6 +41,7 @@ import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
export type SpotlightItem = { export type SpotlightItem = {
/** Stable across renders — used as the React key and for the recency store. */ /** Stable across renders — used as the React key and for the recency store. */
id: string; id: string;
/** Resolved for the active locale, because `rank()` matches what the reader can actually see. */
label: string; label: string;
/** Where it goes. Matched against `canVisit` for anything not already filtered. */ /** Where it goes. Matched against `canVisit` for anything not already filtered. */
to: string; to: string;
@@ -78,64 +80,137 @@ export type SpotlightItem = {
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone * `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
* does not say where it lives. * does not say where it lives.
*/ */
const SETTINGS_ITEMS: SpotlightItem[] = [ type SettingsItem = Omit<SpotlightItem, 'label' | 'hint'> & {
labelKey: AppTranslationKey;
hintKey?: AppTranslationKey;
};
const SETTINGS_ITEMS: SettingsItem[] = [
// Profile — ProfileSettings/index.tsx // Profile — ProfileSettings/index.tsx
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User }, {
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock }, id: 'set:profile',
labelKey: 'settings.sections.profile.label',
to: '/settings/profile',
hintKey: 'settings.sections.profile.hint',
icon: User,
},
{
id: 'set:password',
labelKey: 'settings.sections.changePassword.label',
to: '/settings/profile/change-password',
icon: Lock,
},
{ {
id: 'set:api-keys', id: 'set:api-keys',
label: 'API keys', labelKey: 'settings.sections.apiKeys.label',
to: '/settings/profile/api-keys', to: '/settings/profile/api-keys',
hint: 'keys that sign in as you', hintKey: 'settings.sections.apiKeys.hint',
icon: KeyRound, icon: KeyRound,
}, },
{ {
id: 'set:languages', id: 'set:languages',
label: 'Languages', labelKey: 'settings.sections.languages.label',
to: '/settings/profile/languages', to: '/settings/profile/languages',
hint: 'spoken and translation', hintKey: 'settings.sections.languages.hint',
icon: Globe, icon: Globe,
}, },
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 }, {
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid }, id: 'set:voice',
labelKey: 'settings.sections.voice.label',
to: '/settings/profile/voice',
hintKey: 'settings.sections.voice.hint',
icon: Volume2,
},
{
id: 'set:dock',
labelKey: 'settings.sections.dock.label',
to: '/settings/profile/dock',
hintKey: 'settings.sections.dock.hint',
icon: LayoutGrid,
},
// AI — AISettings.tsx // AI — AISettings.tsx
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles }, {
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles }, id: 'set:ai-my-models',
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles }, labelKey: 'settings.sections.myModels.label',
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot }, to: '/settings/ai/my-models',
icon: Sparkles,
},
{
id: 'set:ai-channel-models',
labelKey: 'settings.sections.channelModels.label',
to: '/settings/ai/member-models',
icon: Sparkles,
},
{
id: 'set:ai-default-models',
labelKey: 'settings.sections.defaultModels.label',
to: '/settings/ai/default-models',
icon: Sparkles,
},
{
id: 'set:ai-harnesses',
labelKey: 'settings.sections.providers.label',
to: '/settings/ai/ai-harnesses',
hintKey: 'settings.sections.providers.hint',
icon: Bot,
},
// System — SystemSettings.tsx // System — SystemSettings.tsx
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 }, { id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic }, { id: 'set:stt', labelKey: 'settings.sections.stt.label', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText }, { id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
{ {
id: 'set:maintenance', id: 'set:maintenance',
label: 'Rebuild & restart', labelKey: 'settings.sections.maintenance.label',
to: '/settings/system/maintenance', to: '/settings/system/maintenance',
hint: 'maintenance', hintKey: 'settings.sections.maintenance.hint',
icon: Cpu, icon: Cpu,
}, },
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail }, {
id: 'set:smtp',
labelKey: 'settings.sections.smtp.label',
to: '/settings/system/smtp',
hintKey: 'settings.sections.smtp.hint',
icon: Mail,
},
// Integrations — IntegrationsSettings // Integrations — IntegrationsSettings
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound }, {
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench }, id: 'set:google-oauth',
labelKey: 'settings.sections.googleOauth.label',
to: '/settings/integrations/google-oauth',
icon: KeyRound,
},
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
{ {
id: 'set:dav', id: 'set:dav',
label: 'Calendar & Contacts sync', labelKey: 'settings.sections.davSync.label',
to: '/settings/integrations/dav-app-passwords', to: '/settings/integrations/dav-app-passwords',
hint: 'app passwords', hintKey: 'settings.sections.davSync.hint',
icon: CalendarDays, icon: CalendarDays,
}, },
// Users — UserManagement // Users — UserManagement
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users }, {
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck }, id: 'set:users',
labelKey: 'settings.sections.accounts.label',
to: '/settings/user-management/users',
hintKey: 'settings.sections.accounts.hint',
icon: Users,
},
{
id: 'set:permissions',
labelKey: 'settings.sections.permissions.label',
to: '/settings/user-management/permissions',
icon: ShieldCheck,
},
]; ];
/** Everything Spotlight can offer this account, in a stable order. */ /** Everything Spotlight can offer this account, in a stable order. */
export function useSpotlightItems(): SpotlightItem[] { export function useSpotlightItems(): SpotlightItem[] {
const { canVisit, plugins } = usePermissions(); const { canVisit, plugins } = usePermissions();
const { t } = useTranslation();
const dockItems = useDockItems(plugins);
return useMemo(() => { return useMemo(() => {
const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)] const fromDock: SpotlightItem[] = dockItems
.filter((item) => canVisit(item.to)) .filter((item) => canVisit(item.to))
.map((item) => ({ .map((item) => ({
id: `nav:${item.to}`, id: `nav:${item.to}`,
@@ -149,8 +224,14 @@ export function useSpotlightItems(): SpotlightItem[] {
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a // Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings. // courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to)); const settings: SpotlightItem[] = SETTINGS_ITEMS.filter((item) => canVisit(item.to)).map(
({ labelKey, hintKey, ...item }) => ({
...item,
label: t(labelKey),
...(hintKey ? { hint: t(hintKey) } : {}),
}),
);
return [...fromDock, ...settings]; return [...fromDock, ...settings];
}, [canVisit, plugins]); }, [canVisit, dockItems, t]);
} }
@@ -11,6 +11,7 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings'; import type { UserSettings } from 'state/useSettings';
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels'; import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
import { useAccessPolicy } from 'state/useAccessPolicy'; import { useAccessPolicy } from 'state/useAccessPolicy';
import { useUserState } from 'state/useUserState'; import { useUserState } from 'state/useUserState';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
// --- My Models Section (per-user hidden models) --- // --- My Models Section (per-user hidden models) ---
function MyModelsSection() { function MyModelsSection() {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const visibleModels = useModels(); const visibleModels = useModels();
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', ''); const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
@@ -174,11 +176,7 @@ function MyModelsSection() {
}, []); }, []);
if (providerGroups.length === 0) { if (providerGroups.length === 0) {
return ( return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
} }
return ( return (
@@ -199,9 +197,9 @@ function MyModelsSection() {
))} ))}
</div> </div>
<DropZone label="Visible" onDrop={showModel}> <DropZone label={t('settings.ai.visible')} onDrop={showModel}>
{shown.length === 0 && ( {shown.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to show</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.dragToShow')}</span>
)} )}
{shown.map((m) => ( {shown.map((m) => (
<ModelPill <ModelPill
@@ -215,9 +213,9 @@ function MyModelsSection() {
))} ))}
</DropZone> </DropZone>
<DropZone label="Hidden" onDrop={hideModel}> <DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
{hidden.length === 0 && ( {hidden.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models visible</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allVisible')}</span>
)} )}
{hidden.map((m) => ( {hidden.map((m) => (
<ModelPill <ModelPill
@@ -237,6 +235,7 @@ function MyModelsSection() {
// --- Member Models Section (system-wide policy, admin only) --- // --- Member Models Section (system-wide policy, admin only) ---
function MemberModelsSection() { function MemberModelsSection() {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { policy, savePolicy } = useAccessPolicy(); const { policy, savePolicy } = useAccessPolicy();
@@ -249,9 +248,9 @@ function MemberModelsSection() {
try { try {
await client.post('/server-settings/chat-providers/local-providers/refresh'); await client.post('/server-settings/chat-providers/local-providers/refresh');
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] }); queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success('Models refreshed'); toast.success(t('settings.ai.refreshed'));
} catch { } catch {
toast.error('Failed to refresh models'); toast.error(t('settings.ai.refreshFailed'));
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
@@ -325,11 +324,7 @@ function MemberModelsSection() {
}, []); }, []);
if (providerGroups.length === 0) { if (providerGroups.length === 0) {
return ( return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
} }
return ( return (
@@ -353,15 +348,17 @@ function MemberModelsSection() {
onClick={refreshModels} onClick={refreshModels}
disabled={refreshing} disabled={refreshing}
className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30" className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Refresh models from providers" title={t('settings.ai.refresh')}
> >
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
</button> </button>
</div> </div>
<DropZone label="Enabled" onDrop={enableModel}> <DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
{enabled.length === 0 && ( {enabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.ai.dragToEnable')}
</span>
)} )}
{enabled.map((m) => ( {enabled.map((m) => (
<ModelPill <ModelPill
@@ -375,9 +372,9 @@ function MemberModelsSection() {
))} ))}
</DropZone> </DropZone>
<DropZone label="Disabled" onDrop={disableModel}> <DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
{disabled.length === 0 && ( {disabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allEnabled')}</span>
)} )}
{disabled.map((m) => ( {disabled.map((m) => (
<ModelPill <ModelPill
@@ -410,49 +407,49 @@ function MemberModelsSection() {
function useAISettingsGroups(): SettingsSectionGroup[] { function useAISettingsGroups(): SettingsSectionGroup[] {
return useMemo(() => { return useMemo(() => {
const modelsGroup: SettingsSectionGroup = { const modelsGroup: SettingsSectionGroup = {
label: 'Models', labelKey: 'settings.groups.models',
icon: Eye, icon: Eye,
sections: [ sections: [
{ {
key: 'my-models', key: 'my-models',
icon: Eye, icon: Eye,
title: 'My Models', titleKey: 'settings.sections.myModels.label',
description: 'Show or hide models for yourself', descriptionKey: 'settings.sections.myModels.description',
content: <MyModelsSection />, content: <MyModelsSection />,
}, },
{ {
key: 'member-models', key: 'member-models',
icon: Eye, icon: Eye,
title: 'Channel Models', titleKey: 'settings.sections.channelModels.label',
description: 'Models reachable by non-owner accounts', descriptionKey: 'settings.sections.channelModels.description',
content: <MemberModelsSection />, content: <MemberModelsSection />,
}, },
], ],
}; };
const defaultsGroup: SettingsSectionGroup = { const defaultsGroup: SettingsSectionGroup = {
label: 'Defaults', labelKey: 'settings.groups.defaults',
icon: Sparkles, icon: Sparkles,
sections: [ sections: [
{ {
key: 'default-models', key: 'default-models',
icon: Sparkles, icon: Sparkles,
title: 'Default Models', titleKey: 'settings.sections.defaultModels.label',
description: 'Default models for chat, projects, and tasks', descriptionKey: 'settings.sections.defaultModels.description',
content: <AIModels />, content: <AIModels />,
}, },
], ],
}; };
const providersGroup: SettingsSectionGroup = { const providersGroup: SettingsSectionGroup = {
label: 'Providers', labelKey: 'settings.groups.providers',
icon: Terminal, icon: Terminal,
sections: [ sections: [
{ {
key: 'ai-harnesses', key: 'ai-harnesses',
icon: Terminal, icon: Terminal,
title: 'Providers', titleKey: 'settings.sections.providers.label',
description: 'Remote and local AI providers', descriptionKey: 'settings.sections.providers.description',
content: <AIHarnessesSection />, content: <AIHarnessesSection />,
}, },
], ],
@@ -482,7 +479,7 @@ export const AISettings = () => {
createSettingsPanelComponents({ createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: Bot, sidebarIcon: Bot,
sidebarLabel: 'AI', sidebarLabelKey: 'settings.nav.ai',
groups, groups,
}), }),
[groups], [groups],
@@ -10,6 +10,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now // import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { ApifyConfig } from './ApifyConfig'; import { ApifyConfig } from './ApifyConfig';
import { DavAppPasswords } from './DavAppPasswords'; import { DavAppPasswords } from './DavAppPasswords';
import { useTranslation } from '@/lib/i18n';
const BASE_PATH = '/settings/integrations'; const BASE_PATH = '/settings/integrations';
@@ -17,15 +18,15 @@ const enterpriseSections: SettingsSection[] = [
{ {
key: 'google-oauth', key: 'google-oauth',
icon: KeyRound, icon: KeyRound,
title: 'Google OAuth', titleKey: 'settings.sections.googleOauth.label',
description: 'Client ID and secret for Google APIs', descriptionKey: 'settings.sections.googleOauth.description',
content: <GoogleOAuthConfig />, content: <GoogleOAuthConfig />,
}, },
{ {
key: 'apify', key: 'apify',
icon: Wrench, icon: Wrench,
title: 'Apify', titleKey: 'settings.sections.apify.label',
description: 'API token for web scraping actors', descriptionKey: 'settings.sections.apify.description',
content: <ApifyConfig />, content: <ApifyConfig />,
}, },
]; ];
@@ -39,14 +40,14 @@ const personalSections: SettingsSection[] = [
// key: 'google-account', // key: 'google-account',
// icon: UserCircle, // icon: UserCircle,
// title: 'Google Account', // title: 'Google Account',
// description: 'Connect your Google account', // descriptionKey: 'settings.sections.apify.description',
// content: <GoogleAccount />, // content: <GoogleAccount />,
// }, // },
{ {
key: 'dav-app-passwords', key: 'dav-app-passwords',
icon: CalendarDays, icon: CalendarDays,
title: 'Calendar & Contacts sync', titleKey: 'settings.sections.davSync.label',
description: 'Per-device passwords for CalDAV/CardDAV clients', descriptionKey: 'settings.sections.davSync.description',
content: <DavAppPasswords />, content: <DavAppPasswords />,
}, },
]; ];
@@ -60,6 +61,7 @@ const tabForSection = (section: string | undefined) =>
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise'; personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
const IntegrationsSidebar = () => { const IntegrationsSidebar = () => {
const { t } = useTranslation();
const { section } = useParams<{ section?: string }>(); const { section } = useParams<{ section?: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const tab = tabForSection(section); const tab = tabForSection(section);
@@ -77,22 +79,28 @@ const IntegrationsSidebar = () => {
<div className="p-3 pb-2"> <div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"> <div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Puzzle className="h-4 w-4" /> <Puzzle className="h-4 w-4" />
Integrations {t('settings.nav.integrations')}
</div> </div>
</div> </div>
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<Tabs value={tab} onValueChange={setTab}> <Tabs value={tab} onValueChange={setTab}>
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer"> <TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
Enterprise {t('settings.integrations.enterprise')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="personal" className="flex-1 cursor-pointer"> <TabsTrigger value="personal" className="flex-1 cursor-pointer">
Personal {t('settings.integrations.personal')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
</div> </div>
<SettingsSidebar basePath={BASE_PATH} icon={Puzzle} label="Integrations" sections={sections} hideHeader /> <SettingsSidebar
basePath={BASE_PATH}
icon={Puzzle}
labelKey="settings.nav.integrations"
sections={sections}
hideHeader
/>
</div> </div>
); );
}; };
@@ -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>
@@ -3,7 +3,8 @@ import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button'; 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 { CORE_DOCK_ITEMS, dockItemsFromPlugins, 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,10 +105,11 @@ 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();
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]); const allDockItems = useDockItems(plugins);
const { items, allItems, setItems, reset } = useDock( const { items, allItems, setItems, reset } = useDock(
allDockItems, allDockItems,
useMemo(() => defaultDockPaths(plugins), [plugins]), useMemo(() => defaultDockPaths(plugins), [plugins]),
@@ -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>
); );
@@ -14,12 +14,18 @@ import { VoicePreference } from './VoicePreference';
const BASE_PATH = '/settings/profile'; const BASE_PATH = '/settings/profile';
const sections: SettingsSection[] = [ const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> }, {
key: 'profile',
icon: User,
titleKey: 'settings.sections.profile.label',
descriptionKey: 'settings.sections.profile.description',
content: <UserData />,
},
{ {
key: 'change-password', key: 'change-password',
icon: Lock, icon: Lock,
title: 'Change Password', titleKey: 'settings.sections.changePassword.label',
description: 'Update your password', descriptionKey: 'settings.sections.changePassword.description',
content: <ChangePassword />, content: <ChangePassword />,
}, },
// Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an // Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an
@@ -29,29 +35,29 @@ const sections: SettingsSection[] = [
{ {
key: 'api-keys', key: 'api-keys',
icon: KeyRound, icon: KeyRound,
title: 'API keys', titleKey: 'settings.sections.apiKeys.label',
description: 'Keys that sign in as you, revocable one at a time', descriptionKey: 'settings.sections.apiKeys.description',
content: <ApiKeys />, content: <ApiKeys />,
}, },
{ {
key: 'languages', key: 'languages',
icon: Globe, icon: Globe,
title: 'Languages', titleKey: 'settings.sections.languages.label',
description: 'Spoken, default, and translation', descriptionKey: 'settings.sections.languages.description',
content: <Languages />, content: <Languages />,
}, },
{ {
key: 'voice', key: 'voice',
icon: Volume2, icon: Volume2,
title: 'Voice', titleKey: 'settings.sections.voice.label',
description: 'Text-to-speech voice preference', descriptionKey: 'settings.sections.voice.description',
content: <VoicePreference />, content: <VoicePreference />,
}, },
{ {
key: 'dock', key: 'dock',
icon: LayoutGrid, icon: LayoutGrid,
title: 'Dock', titleKey: 'settings.sections.dock.label',
description: 'Choose and reorder dock items', descriptionKey: 'settings.sections.dock.description',
content: <DockSettings />, content: <DockSettings />,
}, },
]; ];
@@ -59,7 +65,7 @@ const sections: SettingsSection[] = [
const { Sidebar, Content } = createSettingsPanelComponents({ const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: User, sidebarIcon: User,
sidebarLabel: 'Profile', sidebarLabelKey: 'settings.nav.profile',
sections, sections,
}); });
@@ -3,17 +3,24 @@ import { useState } from 'react';
import { NavLink, Navigate, useParams } from 'react-router'; import { NavLink, Navigate, useParams } from 'react-router';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
/**
* Sections are declared as module-level consts holding JSX `content`, so they cannot call a hook —
* they carry catalog KEYS and the renderers below resolve them. `titleKey` rather than `titleKeyName`
* and deliberately not `title_key`: docs-check-settings.ts greps `key:\s*'…'` for route segments, and
* a capital K keeps `titleKey` out of that match.
*/
export type SettingsSection = { export type SettingsSection = {
key: string; key: string;
icon: LucideIcon; icon: LucideIcon;
title: string; titleKey: AppTranslationKey;
description: string; descriptionKey: AppTranslationKey;
content: ReactNode; content: ReactNode;
}; };
export type SettingsSectionGroup = { export type SettingsSectionGroup = {
label: string; labelKey: AppTranslationKey;
icon: LucideIcon; icon: LucideIcon;
sections: SettingsSection[]; sections: SettingsSection[];
}; };
@@ -21,7 +28,7 @@ export type SettingsSectionGroup = {
type SettingsSidebarProps = { type SettingsSidebarProps = {
basePath: string; basePath: string;
icon: LucideIcon; icon: LucideIcon;
label: string; labelKey: AppTranslationKey;
sections: SettingsSection[]; sections: SettingsSection[];
groups?: SettingsSectionGroup[]; groups?: SettingsSectionGroup[];
hideHeader?: boolean; hideHeader?: boolean;
@@ -32,44 +39,51 @@ type SettingsSidebarProps = {
* the browser knows it has been visited, and `isActive` comes from the router instead of from a global * the browser knows it has been visited, and `isActive` comes from the router instead of from a global
* that the content pane had to be told about separately. * that the content pane had to be told about separately.
*/ */
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => ( const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => {
<NavLink const { t } = useTranslation();
to={to} return (
className={({ isActive }) => <NavLink
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${ to={to}
isActive className={({ isActive }) =>
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' `flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground' isActive
}` ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
} : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
> }`
{({ isActive }) => ( }
<> >
<section.icon {({ isActive }) => (
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} <>
/> <section.icon
<div className="min-w-0 flex-1"> className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
<div className="text-sm font-medium truncate">{section.title}</div> />
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div> <div className="min-w-0 flex-1">
</div> <div className="text-sm font-medium truncate">{t(section.titleKey)}</div>
</> <div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">
)} {t(section.descriptionKey)}
</NavLink> </div>
); </div>
</>
)}
</NavLink>
);
};
export const SettingsSidebar = ({ export const SettingsSidebar = ({
basePath, basePath,
icon: Icon, icon: Icon,
label, labelKey,
sections, sections,
groups, groups,
hideHeader, hideHeader,
}: SettingsSidebarProps) => { }: SettingsSidebarProps) => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { t } = useTranslation();
const query = search.toLowerCase(); const query = search.toLowerCase();
// Matched against the RESOLVED words, so searching works in the language on screen.
const matchesSearch = (s: SettingsSection) => const matchesSearch = (s: SettingsSection) =>
s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query); t(s.titleKey).toLowerCase().includes(query) || t(s.descriptionKey).toLowerCase().includes(query);
return ( return (
<div className="flex flex-col h-full overflow-y-auto"> <div className="flex flex-col h-full overflow-y-auto">
@@ -77,13 +91,13 @@ export const SettingsSidebar = ({
<div className="p-3 pb-2"> <div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"> <div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{label} {t(labelKey)}
</div> </div>
</div> </div>
)} )}
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<Input <Input
placeholder="Search..." placeholder={t('settings.searchPlaceholder')}
value={search} value={search}
onChange={(ev) => setSearch(ev.target.value)} onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs" className="h-8 text-xs"
@@ -95,11 +109,11 @@ export const SettingsSidebar = ({
const filtered = group.sections.filter(matchesSearch); const filtered = group.sections.filter(matchesSearch);
if (filtered.length === 0) return null; if (filtered.length === 0) return null;
return ( return (
<div key={group.label}> <div key={group.labelKey}>
<div className="flex items-center gap-2 px-3 pt-3 pb-1"> <div className="flex items-center gap-2 px-3 pt-3 pb-1">
<group.icon className="h-3 w-3 text-duck-dark/30 dark:text-foreground/30" /> <group.icon className="h-3 w-3 text-duck-dark/30 dark:text-foreground/30" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30"> <span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30">
{group.label} {t(group.labelKey)}
</span> </span>
</div> </div>
{filtered.map((s) => ( {filtered.map((s) => (
@@ -121,6 +135,7 @@ type SettingsContentProps = {
}; };
export const SettingsContent = ({ sections }: SettingsContentProps) => { export const SettingsContent = ({ sections }: SettingsContentProps) => {
const { t } = useTranslation();
// The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key. // The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key.
// They agree via the URL now, so neither has to be told about the other — and neither can be the one // They agree via the URL now, so neither has to be told about the other — and neither can be the one
// that seeds the slot, which is what made the old defaulting order matter (see §5.6). // that seeds the slot, which is what made the old defaulting order matter (see §5.6).
@@ -130,7 +145,7 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
if (!section) { if (!section) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p> <p className="text-sm text-duck-dark/30 dark:text-foreground/30">{t('settings.selectSection')}</p>
</div> </div>
); );
} }
@@ -139,9 +154,9 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
<div className="h-full overflow-y-auto p-6"> <div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2.5 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" /> <section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2> <h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{t(section.titleKey)}</h2>
</div> </div>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p> <p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{t(section.descriptionKey)}</p>
<div>{section.content}</div> <div>{section.content}</div>
</div> </div>
); );
@@ -166,7 +181,7 @@ export const SettingsRoute = ({ basePath, sections, children }: SettingsRoutePro
type CreateSettingsPanelParams = { type CreateSettingsPanelParams = {
basePath: string; basePath: string;
sidebarIcon: LucideIcon; sidebarIcon: LucideIcon;
sidebarLabel: string; sidebarLabelKey: AppTranslationKey;
sections?: SettingsSection[]; sections?: SettingsSection[];
groups?: SettingsSectionGroup[]; groups?: SettingsSectionGroup[];
}; };
@@ -174,7 +189,7 @@ type CreateSettingsPanelParams = {
export const createSettingsPanelComponents = ({ export const createSettingsPanelComponents = ({
basePath, basePath,
sidebarIcon, sidebarIcon,
sidebarLabel, sidebarLabelKey,
sections = [], sections = [],
groups, groups,
}: CreateSettingsPanelParams) => { }: CreateSettingsPanelParams) => {
@@ -183,7 +198,7 @@ export const createSettingsPanelComponents = ({
<SettingsSidebar <SettingsSidebar
basePath={basePath} basePath={basePath}
icon={sidebarIcon} icon={sidebarIcon}
label={sidebarLabel} labelKey={sidebarLabelKey}
sections={allSections} sections={allSections}
groups={groups} groups={groups}
/> />
@@ -12,44 +12,56 @@ import { OCRSection } from './ServerSettings/OCRSection';
const groups: SettingsSectionGroup[] = [ const groups: SettingsSectionGroup[] = [
{ {
label: 'AI', labelKey: 'settings.groups.ai',
icon: Bot, icon: Bot,
sections: [ sections: [
{ {
key: 'tts', key: 'tts',
icon: Volume2, icon: Volume2,
title: 'Text to Speech', titleKey: 'settings.sections.tts.label',
description: 'TTS provider and voice', descriptionKey: 'settings.sections.tts.description',
content: <TTSSection />, content: <TTSSection />,
}, },
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> }, {
key: 'stt',
icon: Mic,
titleKey: 'settings.sections.stt.label',
descriptionKey: 'settings.sections.stt.description',
content: <STTSection />,
},
{ {
key: 'ocr', key: 'ocr',
icon: ScanText, icon: ScanText,
title: 'OCR', titleKey: 'settings.sections.ocr.label',
description: 'Vision model for text extraction', descriptionKey: 'settings.sections.ocr.description',
content: <OCRSection />, content: <OCRSection />,
}, },
], ],
}, },
{ {
label: 'Maintenance', labelKey: 'settings.groups.maintenance',
icon: Hammer, icon: Hammer,
sections: [ sections: [
{ {
key: 'maintenance', key: 'maintenance',
icon: Hammer, icon: Hammer,
title: 'Rebuild & restart', titleKey: 'settings.sections.maintenance.label',
description: 'Load changes without a terminal', descriptionKey: 'settings.sections.maintenance.description',
content: <MaintenanceSection />, content: <MaintenanceSection />,
}, },
], ],
}, },
{ {
label: 'Email', labelKey: 'settings.groups.email',
icon: Mail, icon: Mail,
sections: [ sections: [
{ key: 'smtp', icon: Mail, title: 'SMTP', description: 'Email delivery provider', content: <SMTPSection /> }, {
key: 'smtp',
icon: Mail,
titleKey: 'settings.sections.smtp.label',
descriptionKey: 'settings.sections.smtp.description',
content: <SMTPSection />,
},
], ],
}, },
]; ];
@@ -59,7 +71,7 @@ const BASE_PATH = '/settings/system';
const { Sidebar, Content, allSections } = createSettingsPanelComponents({ const { Sidebar, Content, allSections } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: Settings, sidebarIcon: Settings,
sidebarLabel: 'System', sidebarLabelKey: 'settings.nav.system',
groups, groups,
}); });
@@ -15,15 +15,15 @@ const sections: SettingsSection[] = [
{ {
key: 'users', key: 'users',
icon: Users, icon: Users,
title: 'Accounts', titleKey: 'settings.sections.accounts.label',
description: 'Who has access, and as what', descriptionKey: 'settings.sections.accounts.description',
content: <UsersSection />, content: <UsersSection />,
}, },
{ {
key: 'permissions', key: 'permissions',
icon: ShieldCheck, icon: ShieldCheck,
title: 'Permissions', titleKey: 'settings.sections.permissions.label',
description: 'What each role can reach', descriptionKey: 'settings.sections.permissions.description',
content: <PermissionsSection />, content: <PermissionsSection />,
}, },
]; ];
@@ -33,7 +33,7 @@ const BASE_PATH = '/settings/user-management';
const { Sidebar, Content } = createSettingsPanelComponents({ const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: UserCog, sidebarIcon: UserCog,
sidebarLabel: 'Users', sidebarLabelKey: 'settings.nav.users',
sections, sections,
}); });
+22 -4
View File
@@ -1,14 +1,32 @@
import type { TranslationKeys } from 'i18n'; import type { TranslationKeys } from 'i18n';
import type { InterpolationParams } from 'i18n'; import type { InterpolationParams } from 'i18n';
import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n'; import { useTranslation as useTranslationBase } from 'i18n';
import type en from '../locales/en-GB.json'; import type en from '../locales/en-GB.json';
import type uiEn from '@/components/locales/en-GB.json';
type AppTranslationKey = TranslationKeys<typeof en>; type AppTranslationKey = TranslationKeys<typeof en>;
type UiTranslationKey = TranslationKeys<typeof uiEn>;
// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they /**
// pass the same bare key the JSON file uses. * `t` for this workspace's own catalog, `tUi` for the shared-component one.
*
* Both are here rather than in two imports because "Cancel", "Close" and "Search" recur across the
* shell and genuinely belong to `ui.*` — phase 1 put them there so phases 2 and 3 could reach for them
* instead of inventing a second key for the same word. Anything feature-specific stays in `app.*`:
* per D6 a `ui.*` key may not name a feature.
*
* Both are memoized. An unstable `t` is unusable in a dependency array, and this repo has already lost
* days to exactly that shape — see platform/CLAUDE.md on `useClient()` and the Jellyfin player.
*/
export const useTranslation = () => { export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase(); const { t: baseT, locale } = useTranslationBase();
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params);
return { t, locale }; const t = useCallback((key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params), [baseT]);
const tUi = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
return { t, tUi, locale };
}; };
export type { AppTranslationKey };
+313 -33
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,31 +80,269 @@
"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": {
"profile": {
"label": "Profile",
"hint": "avatar, name, email",
"description": "Avatar, name, and email"
},
"changePassword": {
"label": "Change password",
"description": "Update your password"
},
"apiKeys": {
"label": "API keys",
"hint": "keys that sign in as you",
"description": "Keys that sign in as you, revocable one at a time"
},
"languages": {
"label": "Languages",
"hint": "spoken and translation",
"description": "Spoken, default, and translation"
},
"voice": {
"label": "Voice",
"hint": "text-to-speech",
"description": "Text-to-speech voice preference"
},
"dock": {
"label": "Dock",
"hint": "choose and reorder",
"description": "Choose and reorder dock items"
},
"myModels": {
"label": "My Models",
"description": "Show or hide models for yourself"
},
"channelModels": {
"label": "Channel Models",
"description": "Models reachable by non-owner accounts"
},
"defaultModels": {
"label": "Default Models",
"description": "Default models for chat, projects, and tasks"
},
"providers": {
"label": "Providers",
"hint": "harnesses",
"description": "Remote and local AI providers"
},
"tts": {
"label": "Text to Speech",
"description": "TTS provider and voice"
},
"stt": {
"label": "Speech to Text",
"description": "Whisper server URL"
},
"ocr": {
"label": "OCR",
"description": "Vision model for text extraction"
},
"maintenance": {
"label": "Rebuild & restart",
"hint": "maintenance",
"description": "Load changes without a terminal"
},
"smtp": {
"label": "SMTP",
"hint": "outgoing mail",
"description": "Email delivery provider"
},
"googleOauth": {
"label": "Google OAuth",
"description": "Client ID and secret for Google APIs"
},
"apify": {
"label": "Apify",
"description": "API token for web scraping actors"
},
"davSync": {
"label": "Calendar & Contacts sync",
"hint": "app passwords",
"description": "Per-device passwords for CalDAV/CardDAV clients"
},
"accounts": {
"label": "Accounts",
"hint": "users",
"description": "Who has access, and as what"
},
"permissions": {
"label": "Permissions",
"description": "What each role can reach"
}
},
"searchPlaceholder": "Search settings…",
"selectSection": "Select a section",
"groups": {
"models": "Models",
"defaults": "Defaults",
"providers": "Providers",
"ai": "AI",
"maintenance": "Maintenance",
"email": "Email"
},
"nav": {
"profile": "Profile",
"ai": "AI",
"system": "System",
"integrations": "Integrations",
"users": "Users"
},
"ai": {
"noModels": "No models available. Configure API keys in Providers.",
"dragToShow": "Drag models here to show",
"allVisible": "All models visible",
"dragToEnable": "Drag models here to enable",
"allEnabled": "All models enabled",
"visible": "Visible",
"hidden": "Hidden",
"enabled": "Enabled",
"disabled": "Disabled",
"refresh": "Refresh models from providers",
"refreshed": "Models refreshed",
"refreshFailed": "Failed to refresh models"
},
"integrations": {
"enterprise": "Enterprise",
"personal": "Personal"
} }
}, },
"header": { "header": {
"userMenu": { "userMenu": {
"profile": "Profile", "profile": "Profile",
"systemSettings": "System Settings", "systemSettings": "System Settings",
"signOut": "Sign Out" "signOut": "Sign Out",
"ai": "AI",
"integrations": "Integrations",
"userManagement": "User Management"
},
"tabName": "Tab name",
"tabNameHint": "Click to rename this tab — clear it to go back to the page name",
"logoAlt": "Officer",
"navigation": "Navigation",
"jobsRunning": "Running job",
"jobsNothingRunning": "Nothing running",
"jobsQueued": "Queued jobs"
},
"nav": {
"home": "Home",
"files": "Files",
"chat": "Chat",
"commandHq": "Command HQ",
"editor": "Editor",
"jobs": "Jobs",
"terminal": "Terminal",
"browser": "Browser",
"monitor": "Monitor",
"activity": "Activity",
"dashboards": "Dashboards",
"sendOff": "SendOff",
"calendar": "Calendar",
"contacts": "Contacts",
"appStore": "App store",
"marketplace": "Marketplace"
},
"theme": {
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}",
"pillLabel": "Theme: {{name}}",
"star": "star",
"unstar": "unstar",
"cycleHint": "ctrl+shift+←/→ cycle · ctrl+shift+space browse",
"everyShortcutHint": "ctrl+? for every shortcut",
"favouritesOnly": "Favourites only ({{count}})",
"favourites": "Favourites ({{count}})",
"noFavourites": "No favourites yet — star one with the button on its row.",
"starNamed": "Star {{name}}",
"unstarNamed": "Unstar {{name}}",
"addToFavourites": "Add to favourites",
"removeFromFavourites": "Remove from favourites",
"addToFavouritesKey": "Add to favourites (f)",
"removeFromFavouritesKey": "Remove from favourites (f)",
"active": "active",
"browseHint": "j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel"
},
"spotlight": {
"placeholder": "Go to…",
"noResults": "Nothing matches “{{query}}”."
},
"shortcutsModal": {
"title": "Keyboard shortcuts",
"hint": "Ctrl+? · Esc to close"
},
"screenError": {
"title": "This screen failed to render",
"description": "Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are untouched.",
"unknownError": "Unknown error",
"tryAgain": "Try again",
"reload": "Reload",
"goHome": "Go home",
"resetHint": "If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen’s panes back to their defaults.",
"dashboardsSafe": "Your dashboards are not touched.",
"resetting": "Resetting…",
"resetConfirm": "Yes — reset every screen layout",
"reset": "Reset screen layouts"
},
"rescan": {
"label": "Rescan items",
"title": "Rescan officer-items (apply changes without restarting)",
"failed": "Failed to rescan items",
"done": "Rescanned items — {{summary}}",
"counts": {
"tasks": {
"one": "{{count}} task",
"other": "{{count}} tasks"
},
"agents": {
"one": "{{count}} agent",
"other": "{{count}} agents"
},
"tools": {
"one": "{{count}} tool",
"other": "{{count}} tools"
},
"skills": {
"one": "{{count}} skill",
"other": "{{count}} skills"
},
"processes": {
"one": "{{count}} process",
"other": "{{count}} processes"
},
"affordances": {
"one": "{{count}} affordance",
"other": "{{count}} affordances"
}
},
"newChatsOnly": "Applies to new chats — an open one keeps what it started with.",
"noDescription": {
"one": "{{count}} skill has no description, so no agent is offered it.",
"other": "{{count}} skills have no description, so no agent is offered them."
} }
}, },
"dock": { "bugReport": {
"chat": "Chat", "label": "Report a bug",
"files": "Files", "title": "Report a Bug",
"terminal": "Terminal", "describePlaceholder": "Describe what went wrong...",
"plans": "Plans", "screenshot": "Screenshot",
"skills": "Skills", "noScreenshot": "No screenshot captured",
"tasks": "Tasks", "collectedNote": "Console logs, browser info, and recent API errors are collected automatically.",
"processes": "Processes", "submitted": "Bug report submitted",
"logs": "Logs" "failed": "Failed to submit bug report",
"submit": "Submit"
} }
} }
+315 -35
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,31 +80,269 @@
"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": {
"profile": {
"label": "Perfil",
"hint": "avatar, nome, email",
"description": "Avatar, nome e email"
},
"changePassword": {
"label": "Alterar palavra-passe",
"description": "Atualize a sua palavra-passe"
},
"apiKeys": {
"label": "Chaves de API",
"hint": "chaves que iniciam sessão como você",
"description": "Chaves que iniciam sessão como você, revogáveis uma a uma"
},
"languages": {
"label": "Idiomas",
"hint": "falados e tradução",
"description": "Falados, predefinido e tradução"
},
"voice": {
"label": "Voz",
"hint": "conversão de texto em fala",
"description": "Preferência de voz para conversão de texto em fala"
},
"dock": {
"label": "Dock",
"hint": "escolher e reordenar",
"description": "Escolher e reordenar os itens do dock"
},
"myModels": {
"label": "Os meus modelos",
"description": "Mostrar ou ocultar modelos para si"
},
"channelModels": {
"label": "Modelos do canal",
"description": "Modelos acessíveis a contas que não são do proprietário"
},
"defaultModels": {
"label": "Modelos predefinidos",
"description": "Modelos predefinidos para chat, projetos e tarefas"
},
"providers": {
"label": "Fornecedores",
"hint": "harnesses",
"description": "Fornecedores de IA remotos e locais"
},
"tts": {
"label": "Texto para fala",
"description": "Fornecedor de TTS e voz"
},
"stt": {
"label": "Fala para texto",
"description": "URL do servidor Whisper"
},
"ocr": {
"label": "OCR",
"description": "Modelo de visão para extração de texto"
},
"maintenance": {
"label": "Reconstruir e reiniciar",
"hint": "manutenção",
"description": "Carregar alterações sem um terminal"
},
"smtp": {
"label": "SMTP",
"hint": "correio a enviar",
"description": "Fornecedor de entrega de email"
},
"googleOauth": {
"label": "Google OAuth",
"description": "Client ID e secret para as APIs Google"
},
"apify": {
"label": "Apify",
"description": "Token de API para atores de web scraping"
},
"davSync": {
"label": "Sincronização de Calendário e Contactos",
"hint": "palavras-passe de aplicação",
"description": "Palavras-passe por dispositivo para clientes CalDAV/CardDAV"
},
"accounts": {
"label": "Contas",
"hint": "utilizadores",
"description": "Quem tem acesso, e como"
},
"permissions": {
"label": "Permissões",
"description": "O que cada função pode alcançar"
}
},
"searchPlaceholder": "Pesquisar definições…",
"selectSection": "Selecione uma secção",
"groups": {
"models": "Modelos",
"defaults": "Predefinições",
"providers": "Fornecedores",
"ai": "IA",
"maintenance": "Manutenção",
"email": "Email"
},
"nav": {
"profile": "Perfil",
"ai": "IA",
"system": "Sistema",
"integrations": "Integrações",
"users": "Utilizadores"
},
"ai": {
"noModels": "Nenhum modelo disponível. Configure as chaves de API em Fornecedores.",
"dragToShow": "Arraste modelos para aqui para os mostrar",
"allVisible": "Todos os modelos visíveis",
"dragToEnable": "Arraste modelos para aqui para os ativar",
"allEnabled": "Todos os modelos ativados",
"visible": "Visíveis",
"hidden": "Ocultos",
"enabled": "Ativados",
"disabled": "Desativados",
"refresh": "Atualizar modelos a partir dos fornecedores",
"refreshed": "Modelos atualizados",
"refreshFailed": "Falha ao atualizar modelos"
},
"integrations": {
"enterprise": "Empresa",
"personal": "Pessoal"
} }
}, },
"header": { "header": {
"userMenu": { "userMenu": {
"profile": "Perfil", "profile": "Perfil",
"systemSettings": "Definições do Sistema", "systemSettings": "Definições do Sistema",
"signOut": "Terminar Sessão" "signOut": "Terminar Sessão",
"ai": "IA",
"integrations": "Integrações",
"userManagement": "Gestão de Utilizadores"
},
"tabName": "Nome do separador",
"tabNameHint": "Clique para renomear este separador — limpe-o para voltar ao nome da página",
"logoAlt": "Officer",
"navigation": "Navegação",
"jobsRunning": "Trabalho em execução",
"jobsNothingRunning": "Nada em execução",
"jobsQueued": "Trabalhos em fila"
},
"nav": {
"home": "Início",
"files": "Ficheiros",
"chat": "Chat",
"commandHq": "Command HQ",
"editor": "Editor",
"jobs": "Trabalhos",
"terminal": "Terminal",
"browser": "Navegador",
"monitor": "Monitor",
"activity": "Atividade",
"dashboards": "Painéis",
"sendOff": "SendOff",
"calendar": "Calendário",
"contacts": "Contactos",
"appStore": "Loja de aplicações",
"marketplace": "Marketplace"
},
"theme": {
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
"pillLabel": "Tema: {{name}}",
"star": "marcar",
"unstar": "desmarcar",
"cycleHint": "ctrl+shift+←/→ percorrer · ctrl+shift+space explorar",
"everyShortcutHint": "ctrl+? para todos os atalhos",
"favouritesOnly": "Apenas favoritos ({{count}})",
"favourites": "Favoritos ({{count}})",
"noFavourites": "Ainda não há favoritos — marque um com o botão na sua linha.",
"starNamed": "Marcar {{name}}",
"unstarNamed": "Desmarcar {{name}}",
"addToFavourites": "Adicionar aos favoritos",
"removeFromFavourites": "Remover dos favoritos",
"addToFavouritesKey": "Adicionar aos favoritos (f)",
"removeFromFavouritesKey": "Remover dos favoritos (f)",
"active": "ativo",
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f marcar · h ou ← reiniciar · enter manter · esc cancelar"
},
"spotlight": {
"placeholder": "Ir para…",
"noResults": "Nada corresponde a “{{query}}”."
},
"shortcutsModal": {
"title": "Atalhos de teclado",
"hint": "Ctrl+? · Esc para fechar"
},
"screenError": {
"title": "Este ecrã não conseguiu ser apresentado",
"description": "Nada foi perdido — esta é uma falha de apresentação, não de dados. As transcrições do agente, os trabalhos e os ficheiros estão intactos.",
"unknownError": "Erro desconhecido",
"tryAgain": "Tentar novamente",
"reload": "Recarregar",
"goHome": "Ir para o início",
"resetHint": "Se falhar novamente no mesmo local, a causa habitual é um layout de painéis guardado. Reiniciar devolve os painéis de todos os ecrãs às predefinições.",
"dashboardsSafe": "Os seus painéis não são afetados.",
"resetting": "A reiniciar…",
"resetConfirm": "Sim — reiniciar o layout de todos os ecrãs",
"reset": "Reiniciar layouts dos ecrãs"
},
"rescan": {
"label": "Reanalisar itens",
"title": "Reanalisar officer-items (aplicar alterações sem reiniciar)",
"failed": "Falha ao reanalisar itens",
"done": "Itens reanalisados — {{summary}}",
"counts": {
"tasks": {
"one": "{{count}} tarefa",
"other": "{{count}} tarefas"
},
"agents": {
"one": "{{count}} agente",
"other": "{{count}} agentes"
},
"tools": {
"one": "{{count}} ferramenta",
"other": "{{count}} ferramentas"
},
"skills": {
"one": "{{count}} skill",
"other": "{{count}} skills"
},
"processes": {
"one": "{{count}} processo",
"other": "{{count}} processos"
},
"affordances": {
"one": "{{count}} affordance",
"other": "{{count}} affordances"
}
},
"newChatsOnly": "Aplica-se a novos chats — um chat aberto mantém o que tinha ao iniciar.",
"noDescription": {
"one": "{{count}} skill não tem descrição, pelo que não é oferecida a nenhum agente.",
"other": "{{count}} skills não têm descrição, pelo que não são oferecidas a nenhum agente."
} }
}, },
"dock": { "bugReport": {
"chat": "Chat", "label": "Comunicar um erro",
"files": "Ficheiros", "title": "Comunicar um erro",
"terminal": "Terminal", "describePlaceholder": "Descreva o que aconteceu...",
"plans": "Planos", "screenshot": "Captura de ecrã",
"skills": "Skills", "noScreenshot": "Nenhuma captura de ecrã obtida",
"tasks": "Tarefas", "collectedNote": "Os registos da consola, a informação do navegador e os erros recentes da API são recolhidos automaticamente.",
"processes": "Processos", "submitted": "Comunicação de erro enviada",
"logs": "Registos" "failed": "Falha ao enviar a comunicação de erro",
"submit": "Enviar"
} }
} }
+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',
+7 -3
View File
@@ -1,11 +1,15 @@
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useTranslation } from '@/components/locales';
type ColorPickerProps = { value?: string; name: string; label?: string }; type ColorPickerProps = { value?: string; name: string; label?: string };
export const ColorPicker = (props: ColorPickerProps) => { export const ColorPicker = (props: ColorPickerProps) => {
const { value = '#000000', name, label } = props; const { value = '#000000', name, label } = props;
const { t } = useTranslation();
return ( return (
<div style={{ borderWidth: '1px' }} className="relative w-full rounded"> <div style={{ borderWidth: '1px' }} className="relative w-full rounded">
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>} {label && (
<span className="text-muted-foreground absolute z-10 -mt-2 ms-5 bg-background px-2 text-xs">{label}</span>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="h-12 overflow-hidden rounded-full p-0"> <div className="h-12 overflow-hidden rounded-full p-0">
<Input <Input
@@ -13,14 +17,14 @@ export const ColorPicker = (props: ColorPickerProps) => {
className="absolute h-12 w-24 rounded-full border-0 text-lg" className="absolute h-12 w-24 rounded-full border-0 text-lg"
type="color" type="color"
name={name} name={name}
placeholder="Font Weight"
/> />
</div> </div>
<div> <div>
<Input <Input
name={name} name={name}
className="border-0 text-right text-base outline-none focus:outline-none" className="border-0 text-end text-base outline-none focus:outline-none"
defaultValue={value} defaultValue={value}
placeholder={t('colorPicker.placeholder')}
/> />
</div> </div>
</div> </div>
+5 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Copy, Check, Play } from 'lucide-react'; import { Copy, Check, Play } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard'; import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/components/locales';
type CommandBlockProps = { type CommandBlockProps = {
label?: string; label?: string;
@@ -9,6 +10,7 @@ type CommandBlockProps = {
}; };
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => { export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copy = () => { const copy = () => {
@@ -20,14 +22,14 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
return ( return (
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
{label && <span className="text-xs text-duck-dark/50">{label}</span>} {label && <span className="text-xs text-duck-dark/50">{label}</span>}
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5"> <div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg ps-3 pe-1 py-1.5">
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code> <code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
{onRun && ( {onRun && (
<button <button
type="button" type="button"
onClick={() => onRun(command)} onClick={() => onRun(command)}
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors" className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
title="Run in terminal" title={t('commandBlock.run')}
> >
<Play className="h-3.5 w-3.5 text-duck-teal" /> <Play className="h-3.5 w-3.5 text-duck-teal" />
</button> </button>
@@ -36,7 +38,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
type="button" type="button"
onClick={copy} onClick={copy}
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors" className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
title="Copy command" title={t('commandBlock.copy')}
> >
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />} {copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
</button> </button>
+10 -6
View File
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Loader2, TriangleAlert } from 'lucide-react'; import { Loader2, TriangleAlert } from 'lucide-react';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
// Loading, failed, empty. See docs/design-language-interface.md § 6. // Loading, failed, empty. See docs/design-language-interface.md § 6.
// //
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
</div> </div>
); );
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => ( export const LoadingBlock = ({ label }: { label?: string }) => {
<Shell> const { t } = useTranslation();
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> return (
<p className="text-sm text-muted-foreground">{label}</p> <Shell>
</Shell> <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
); <p className="text-sm text-muted-foreground">{label ?? t('state.loading')}</p>
</Shell>
);
};
/** /**
* The real message is shown, not a friendly substitute for it. On a self-hosted platform the person * The real message is shown, not a friendly substitute for it. On a self-hosted platform the person
@@ -1,7 +1,21 @@
import type { Column } from './DataTable'; import type { Column } from './DataTable';
import type { ReactNode } from 'react';
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { render, screen, fireEvent, within } from '@testing-library/react'; import { render, screen, fireEvent, within } from '@testing-library/react';
import { I18nProvider } from 'i18n';
import { DataTable, useDataControl } from './DataTable'; import { DataTable, useDataControl } from './DataTable';
import enGB from '../locales/en-GB.json';
/**
* The table's own chrome ("Next", "No Data Found.") comes from the `ui.*` catalog now, so these
* assertions only hold inside a provider — the same one the app mounts. Asserting the English a user
* actually sees, rather than a key, is the point of wrapping rather than loosening the expectations.
*/
const withI18n = (ui: ReactNode) => (
<I18nProvider translations={{ 'en-GB': { ui: enGB } }} locale="en-GB" fallbackLocale="en-GB">
{ui}
</I18nProvider>
);
type TestItem = { type TestItem = {
id: number; id: number;
@@ -55,7 +69,7 @@ const TestWrapper = ({
describe('DataTable', () => { describe('DataTable', () => {
describe('rendering', () => { describe('rendering', () => {
test('renders table with data', () => { test('renders table with data', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
expect(screen.getByRole('table')).toBeDefined(); expect(screen.getByRole('table')).toBeDefined();
expect(screen.getByText('Alpha')).toBeDefined(); expect(screen.getByText('Alpha')).toBeDefined();
@@ -63,7 +77,7 @@ describe('DataTable', () => {
}); });
test('renders column headers', () => { test('renders column headers', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
// Component renders both desktop and mobile versions (hidden via CSS) // Component renders both desktop and mobile versions (hidden via CSS)
// so we use getAllByText and verify at least one exists // so we use getAllByText and verify at least one exists
@@ -74,7 +88,7 @@ describe('DataTable', () => {
}); });
test('renders correct number of rows based on page size', () => { test('renders correct number of rows based on page size', () => {
render(<TestWrapper pageSize={3} />); render(withI18n(<TestWrapper pageSize={3} />));
const rows = screen.getAllByRole('row'); const rows = screen.getAllByRole('row');
// 1 header row + 3 data rows // 1 header row + 3 data rows
@@ -82,20 +96,20 @@ describe('DataTable', () => {
}); });
test('shows no data message when data is empty', () => { test('shows no data message when data is empty', () => {
render(<TestWrapper data={[]} noDataMessage="No items found" />); render(withI18n(<TestWrapper data={[]} noDataMessage="No items found" />));
expect(screen.getByText('No items found')).toBeDefined(); expect(screen.getByText('No items found')).toBeDefined();
expect(screen.queryByRole('table')).toBeNull(); expect(screen.queryByRole('table')).toBeNull();
}); });
test('shows default no data message', () => { test('shows default no data message', () => {
render(<TestWrapper data={[]} />); render(withI18n(<TestWrapper data={[]} />));
expect(screen.getByText('No Data Found.')).toBeDefined(); expect(screen.getByText('No Data Found.')).toBeDefined();
}); });
test('renders noDataCTA when provided', () => { test('renders noDataCTA when provided', () => {
render(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />); render(withI18n(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />));
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined(); expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
}); });
@@ -106,7 +120,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} columns={columns} />; return <DataTable dataController={controller} columns={columns} />;
}; };
const { container } = render(<NullWrapper />); const { container } = render(withI18n(<NullWrapper />));
expect(container.innerHTML).toBe(''); expect(container.innerHTML).toBe('');
}); });
}); });
@@ -118,7 +132,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} />; return <DataTable dataController={controller} />;
}; };
render(<AutoColumnsWrapper />); render(withI18n(<AutoColumnsWrapper />));
// Should generate columns from object keys (rendered for both mobile/desktop) // Should generate columns from object keys (rendered for both mobile/desktop)
expect(screen.getAllByText('Id').length).toBeGreaterThan(0); expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
@@ -130,7 +144,7 @@ describe('DataTable', () => {
describe('sorting', () => { describe('sorting', () => {
test('clicking sortable column header triggers sort', () => { test('clicking sortable column header triggers sort', () => {
render(<TestWrapper pageSize={10} />); render(withI18n(<TestWrapper pageSize={10} />));
// Get the first "Name" element (desktop version) // Get the first "Name" element (desktop version)
const nameHeaders = screen.getAllByText('Name'); const nameHeaders = screen.getAllByText('Name');
@@ -143,7 +157,7 @@ describe('DataTable', () => {
}); });
test('clicking same column twice reverses sort order', () => { test('clicking same column twice reverses sort order', () => {
render(<TestWrapper pageSize={10} />); render(withI18n(<TestWrapper pageSize={10} />));
const nameHeaders = screen.getAllByText('Name'); const nameHeaders = screen.getAllByText('Name');
fireEvent.click(nameHeaders[0]!); // asc fireEvent.click(nameHeaders[0]!); // asc
@@ -156,7 +170,7 @@ describe('DataTable', () => {
}); });
test('sort icon is displayed for sortable columns', () => { test('sort icon is displayed for sortable columns', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
// Each sortable column should have the ArrowUpDown icon // Each sortable column should have the ArrowUpDown icon
const headers = screen.getAllByRole('columnheader'); const headers = screen.getAllByRole('columnheader');
@@ -169,19 +183,19 @@ describe('DataTable', () => {
describe('pagination', () => { describe('pagination', () => {
test('shows pagination bar when data exceeds page size', () => { test('shows pagination bar when data exceeds page size', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined(); expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
}); });
test('does not show pagination when all data fits on one page', () => { test('does not show pagination when all data fits on one page', () => {
render(<TestWrapper pageSize={10} />); render(withI18n(<TestWrapper pageSize={10} />));
expect(screen.queryByText(/Showing page/)).toBeNull(); expect(screen.queryByText(/Showing page/)).toBeNull();
}); });
test('next button navigates to next page', () => { test('next button navigates to next page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -192,7 +206,7 @@ describe('DataTable', () => {
}); });
test('previous button navigates to previous page', () => { test('previous button navigates to previous page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -205,7 +219,7 @@ describe('DataTable', () => {
}); });
test('first button goes to first page', () => { test('first button goes to first page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -217,7 +231,7 @@ describe('DataTable', () => {
}); });
test('last button goes to last page', () => { test('last button goes to last page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const lastButton = screen.getByRole('button', { name: 'Last' }); const lastButton = screen.getByRole('button', { name: 'Last' });
fireEvent.click(lastButton); fireEvent.click(lastButton);
@@ -226,7 +240,7 @@ describe('DataTable', () => {
}); });
test('navigation buttons are disabled at boundaries', () => { test('navigation buttons are disabled at boundaries', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const firstBtn = screen.getByRole('button', { name: 'First' }); const firstBtn = screen.getByRole('button', { name: 'First' });
const prevBtn = screen.getByRole('button', { name: 'Prev' }); const prevBtn = screen.getByRole('button', { name: 'Prev' });
@@ -258,7 +272,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: false }, { field: 'status', label: 'Status', condition: false },
]; ];
render(<TestWrapper cols={conditionalColumns} />); render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('ID').length).toBeGreaterThan(0); expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
expect(screen.getAllByText('Name').length).toBeGreaterThan(0); expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
@@ -271,7 +285,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: true }, { field: 'status', label: 'Status', condition: true },
]; ];
render(<TestWrapper cols={conditionalColumns} />); render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0); expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
}); });
@@ -283,11 +297,11 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: () => showStatus }, { field: 'status', label: 'Status', condition: () => showStatus },
]; ];
const { rerender } = render(<TestWrapper cols={conditionalColumns} />); const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.queryByText('Status')).toBeNull(); expect(screen.queryByText('Status')).toBeNull();
showStatus = true; showStatus = true;
rerender(<TestWrapper cols={conditionalColumns} />); rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0); expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
}); });
}); });
@@ -303,7 +317,7 @@ describe('DataTable', () => {
}, },
]; ];
render(<TestWrapper cols={formattedColumns} />); render(withI18n(<TestWrapper cols={formattedColumns} />));
const formattedNames = screen.getAllByTestId('formatted-name'); const formattedNames = screen.getAllByTestId('formatted-name');
expect(formattedNames.length).toBeGreaterThan(0); expect(formattedNames.length).toBeGreaterThan(0);
@@ -319,7 +333,7 @@ describe('DataTable', () => {
}, },
]; ];
render(<TestWrapper cols={formattedColumns} />); render(withI18n(<TestWrapper cols={formattedColumns} />));
// Each row should have a unique test ID based on item.id // Each row should have a unique test ID based on item.id
expect(screen.getByTestId('item-1')).toBeDefined(); expect(screen.getByTestId('item-1')).toBeDefined();
@@ -337,7 +351,7 @@ describe('DataTable', () => {
}, },
]; ];
render(<TestWrapper cols={actionColumns} />); render(withI18n(<TestWrapper cols={actionColumns} />));
expect(screen.getByTestId('action-1')).toBeDefined(); expect(screen.getByTestId('action-1')).toBeDefined();
expect(screen.getByText('Edit Alpha')).toBeDefined(); expect(screen.getByText('Edit Alpha')).toBeDefined();
@@ -351,7 +365,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Name' }, { field: 'name', label: 'Name' },
]; ];
render(<TestWrapper cols={tooltipColumns} />); render(withI18n(<TestWrapper cols={tooltipColumns} />));
// The tooltip indicator (ⓘ) should be present in the ID column headers // The tooltip indicator (ⓘ) should be present in the ID column headers
const idHeaders = screen.getAllByText(/ID/); const idHeaders = screen.getAllByText(/ID/);
@@ -367,7 +381,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Full Name', labelMobile: 'Name' }, { field: 'name', label: 'Full Name', labelMobile: 'Name' },
]; ];
render(<TestWrapper cols={mobileColumns} />); render(withI18n(<TestWrapper cols={mobileColumns} />));
// Both desktop and mobile labels should be in the DOM (hidden/shown via CSS) // Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0); expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
@@ -396,7 +410,7 @@ describe('DataTable', () => {
); );
}; };
render(<SearchableTable />); render(withI18n(<SearchableTable />));
const searchInput = screen.getByTestId('search-input'); const searchInput = screen.getByTestId('search-input');
fireEvent.change(searchInput, { target: { value: 'Alpha' } }); fireEvent.change(searchInput, { target: { value: 'Alpha' } });
@@ -420,7 +434,7 @@ describe('DataTable', () => {
); );
}; };
render(<ExternalSortTable />); render(withI18n(<ExternalSortTable />));
fireEvent.click(screen.getByTestId('sort-btn')); fireEvent.click(screen.getByTestId('sort-btn'));
@@ -6,8 +6,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl'; import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
import { PaginationBar } from './PaginationBar'; import { PaginationBar } from './PaginationBar';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const DataTable = <T,>(props: DataTableProps<T>) => { export const DataTable = <T,>(props: DataTableProps<T>) => {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column<T>[]>([]); const [columns, setColumns] = useState<Column<T>[]>([]);
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props; const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
const theUniqueKey = uniqueKey || 'id'; const theUniqueKey = uniqueKey || 'id';
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
if (rawData.length === 0) { if (rawData.length === 0) {
return ( return (
<div className="py-12 text-center"> <div className="py-12 text-center">
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div> <div className="text-muted-foreground">{noDataMessage || t('dataTable.empty')}</div>
{noDataCTA && <>{noDataCTA}</>} {noDataCTA && <>{noDataCTA}</>}
</div> </div>
); );
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
<span className="min-[500px]:hidden"> <span className="min-[500px]:hidden">
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''} {String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
</span> </span>
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />} {sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
{tooltip && ( {tooltip && (
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
import { useMemo } from 'react'; import { useMemo } from 'react';
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 '@/components/locales';
type PaginationBarProps<T> = { type PaginationBarProps<T> = {
dataManager: UseDataControlReturn<T>; dataManager: UseDataControlReturn<T>;
}; };
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => { export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
const { t } = useTranslation();
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager; const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []); const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
{/* LEFT */} {/* LEFT */}
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap"> <div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
<span className="hidden min-[500px]:inline"> <span className="hidden min-[500px]:inline">
Showing page {currentPage} of {pageCount} {t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
</span> </span>
<span className="min-[500px]:hidden"> <span className="min-[500px]:hidden">
p {currentPage}/{pageCount} {t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
</span> </span>
<span className="bg-border h-5 w-px" aria-hidden="true" /> <span className="bg-border h-5 w-px" aria-hidden="true" />
@@ -47,11 +49,11 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
hover:bg-background hover:bg-background
data-[state=open]:bg-muted data-[state=open]:bg-muted
" "
aria-label="Rows per page" aria-label={t('dataTable.pagination.rowsPerPage')}
> >
<SelectValue className="text-muted-foreground"> <SelectValue className="text-muted-foreground">
<span className="hidden min-[500px]:inline">{pageSize} / page</span> <span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
<span className="min-[500px]:hidden">{pageSize}/p</span> <span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
data-[state=checked]:text-muted-foreground data-[state=checked]:text-muted-foreground
" "
> >
<span className="hidden min-[500px]:inline">{n} / page</span> <span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
<span className="min-[500px]:hidden">{n}/p</span> <span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(1)} onClick={() => changePage(1)}
disabled={currentPage === 1} disabled={currentPage === 1}
aria-label="First" aria-label={t('dataTable.pagination.first')}
> >
« «
</Button> </Button>
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(currentPage - 1)} onClick={() => changePage(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
aria-label="Prev" aria-label={t('dataTable.pagination.prev')}
> >
‹ ‹
</Button> </Button>
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(currentPage + 1)} onClick={() => changePage(currentPage + 1)}
disabled={currentPage === pageCount} disabled={currentPage === pageCount}
aria-label="Next" aria-label={t('dataTable.pagination.next')}
> >
› ›
</Button> </Button>
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(pageCount)} onClick={() => changePage(pageCount)}
disabled={currentPage === pageCount} disabled={currentPage === pageCount}
aria-label="Last" aria-label={t('dataTable.pagination.last')}
> >
» »
</Button> </Button>
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type ConfirmDialogProps = { type ConfirmDialogProps = {
open: boolean; open: boolean;
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
export const ConfirmDialog = (props: ConfirmDialogProps) => { export const ConfirmDialog = (props: ConfirmDialogProps) => {
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const { t } = useTranslation();
const { open, disabled = false, title, description, children } = props; const { open, disabled = false, title, description, children } = props;
const { onConfirm, onCancel, confirmLabel = 'Confirm', confirmVariant = 'default' } = props; const { onConfirm, onCancel, confirmLabel, confirmVariant = 'default' } = props;
const handleConfirm = async () => { const handleConfirm = async () => {
setConfirming(true); setConfirming(true);
@@ -37,16 +39,16 @@ export const ConfirmDialog = (props: ConfirmDialogProps) => {
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
Cancel {t('button.cancel')}
</Button> </Button>
<Button <Button
type="button" type="button"
variant={confirmVariant} variant={confirmVariant}
onClick={handleConfirm} onClick={handleConfirm}
disabled={disabled || confirming} disabled={disabled || confirming}
className="sm:ml-2" className="sm:ms-2"
> >
{confirmLabel} {confirmLabel ?? t('button.confirm')}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card'; import { cardStyle } from '@/components/Card';
import { useTranslation } from '@/components/locales';
type PaperDialogProps = { type PaperDialogProps = {
open: boolean; open: boolean;
@@ -11,6 +12,8 @@ type PaperDialogProps = {
}; };
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => { export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
const { t } = useTranslation();
const handleOpenAutoFocus = (ev: Event) => { const handleOpenAutoFocus = (ev: Event) => {
ev.preventDefault(); ev.preventDefault();
onOpenAutoFocus?.(ev); onOpenAutoFocus?.(ev);
@@ -25,9 +28,9 @@ export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: P
className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg" className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
style={cardStyle()} style={cardStyle()}
> >
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<span className="h-4 w-4 text-duck-dark">✕</span> <span className="h-4 w-4 text-duck-dark">✕</span>
<span className="sr-only">Close</span> <span className="sr-only">{t('button.close')}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
{children} {children}
</DialogPrimitive.Content> </DialogPrimitive.Content>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => { export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Server Error {t('error.server.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription> <DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader> </DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p> <p>{apiError?.message}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => { export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Unauthorized Access {t('error.forbidden.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5"> <DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
Your are not authorized to perform this action.
</DialogDescription>
</DialogHeader> </DialogHeader>
<p>If you think this is an error, please contact your manager.</p> <p>{t('error.forbidden.hint')}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => { export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,14 +18,15 @@ export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Server Error {t('error.server.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription> <DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader> </DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p> <p>{apiError?.message}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -3,10 +3,12 @@ import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => { export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
const { signout } = useAuth(); const { signout } = useAuth();
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -20,14 +22,16 @@ export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Invalid Credentials {t('error.unauthorized.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">Your access token has expired</DialogDescription> <DialogDescription className="flex flex-col gap-4 py-5">
{t('error.unauthorized.description')}
</DialogDescription>
</DialogHeader> </DialogHeader>
<p>Please Signin again</p> <p>{t('error.unauthorized.hint')}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,17 +1,14 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = { type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}; };
export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) { export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig[size]; const config = sizeConfig[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.icon')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Icon"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
const sizeConfig = { const sizeConfig = {
@@ -1,3 +1,5 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = { type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
square?: boolean; square?: boolean;
@@ -12,27 +14,17 @@ export function OfficerSvgLogo({ size = 'xl', square = false }: SvgLogoProps) {
} }
export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) { export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.regular[size]; const config = sizeConfig.regular[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) { export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.square[size]; const config = sizeConfig.square[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
const sizeConfig = { const sizeConfig = {
@@ -1,115 +0,0 @@
import { useState } from 'react';
import md from 'markdown-it';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Button } from '@/components/ui/button';
import { Code, Eye } from 'lucide-react';
type MarkdownEditorProps = {
content: string;
onChange: (content: string) => void;
placeholder?: string;
greeting?: string;
firstName?: string;
lastName?: string;
};
export function MarkdownEditor({
content,
onChange,
placeholder = 'Enter markdown...',
greeting,
firstName,
lastName,
}: MarkdownEditorProps) {
const [showPreview, setShowPreview] = useState(false);
const markdown = md({
html: true,
breaks: true,
linkify: true,
});
const processGreeting = (text: string) => {
return text
.replace(/\{\{firstName\}\}/g, firstName || '')
.replace(/\{\{fullName\}\}/g, `${firstName || ''} ${lastName || ''}`.trim());
};
const processedGreeting = greeting ? processGreeting(greeting) : '';
const greetingHtml = processedGreeting ? `<p>${processedGreeting}</p>` : '';
const preview = markdown.render(content);
return (
<div className="flex flex-col rounded-lg border border-border bg-background overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-2 border-b border-border bg-muted px-4 py-2">
<Button size="sm" variant={!showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(false)}>
<Code className="h-4 w-4 mr-2" />
Markdown
</Button>
<Button size="sm" variant={showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(true)}>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-hidden">
{!showPreview ? (
<textarea
rows={15}
name="content"
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-full resize-none p-4 font-mono text-sm focus:outline-none bg-background text-foreground placeholder:text-muted-foreground overflow-auto"
/>
) : (
<div className="w-full h-full overflow-auto bg-slate-50 dark:bg-slate-900 p-4">
{/* Email wrapper simulation */}
<div className="mx-auto max-w-2xl rounded-lg bg-background p-8 shadow-lg">
{/* Logo */}
<div className="mb-8 text-center">
<img src="https://app.pertento.ai/logo.png" alt="Pertento" className="h-16 mx-auto" />
</div>
{/* Content */}
<div className="space-y-4">
{processedGreeting && <p className="text-base leading-relaxed">{processedGreeting}</p>}
<style>{`
.markdown-preview h1 { font-size: 2rem; font-weight: bold; margin: 1.5rem 0 1rem 0; }
.markdown-preview h2 { font-size: 1.5rem; font-weight: bold; margin: 1.25rem 0 0.75rem 0; }
.markdown-preview h3 { font-size: 1.25rem; font-weight: bold; margin: 1rem 0 0.5rem 0; }
.markdown-preview p { margin: 0.75rem 0; line-height: 1.6; }
/* list-style is restored explicitly — tailwind preflight strips it, which leaves an
ordered list indented but unnumbered. */
.markdown-preview ul, .markdown-preview ol { margin: 1rem 0; padding-left: 2rem; list-style-position: outside; }
.markdown-preview ul { list-style-type: disc; }
.markdown-preview ol { list-style-type: decimal; }
.markdown-preview ul ul { list-style-type: circle; }
.markdown-preview ol ol { list-style-type: lower-alpha; }
.markdown-preview li { margin: 0.5rem 0; line-height: 1.6; }
.markdown-preview code { background-color: rgb(229, 229, 229); color: rgb(31, 41, 55); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.875rem; }
.markdown-preview pre { background-color: rgb(17, 24, 39); color: rgb(243, 244, 246); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
.markdown-preview pre code { background-color: transparent; color: inherit; padding: 0; }
.markdown-preview a { color: rgb(37, 99, 235); text-decoration: underline; }
.markdown-preview blockquote { border-left: 4px solid rgb(209, 213, 219); padding-left: 1rem; font-style: italic; margin: 1rem 0; }
.dark .markdown-preview code { background-color: rgb(55, 65, 81); color: rgb(243, 244, 246); }
.dark .markdown-preview a { color: rgb(96, 165, 250); }
.dark .markdown-preview blockquote { border-left-color: rgb(75, 85, 99); }
`}</style>
<div className="markdown-preview">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+10 -8
View File
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Search as SearchIcon, X as ClearIcon } from 'lucide-react'; import { Search as SearchIcon, X as ClearIcon } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
type SearchInputProps = { type SearchInputProps = {
className?: string; className?: string;
@@ -12,7 +13,8 @@ type SearchInputProps = {
}; };
export function SearchInput(props: SearchInputProps) { export function SearchInput(props: SearchInputProps) {
const { className, handleSearch, value = '', placeholder = 'Search' } = props; const { t } = useTranslation();
const { className, handleSearch, value = '', placeholder = t('search.placeholder') } = props;
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -22,14 +24,14 @@ export function SearchInput(props: SearchInputProps) {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
aria-label={isMobileSearchOpen ? 'Close search' : 'Open search'} aria-label={isMobileSearchOpen ? t('search.close') : t('search.open')}
onClick={() => { onClick={() => {
setIsMobileSearchOpen((v) => !v); setIsMobileSearchOpen((v) => !v);
if (!isMobileSearchOpen) { if (!isMobileSearchOpen) {
setTimeout(() => inputRef.current?.focus(), 160); setTimeout(() => inputRef.current?.focus(), 160);
} }
}} }}
className="ml-auto shrink-0 md:hidden" className="ms-auto shrink-0 md:hidden"
> >
{isMobileSearchOpen ? <ClearIcon className="h-5 w-5" /> : <SearchIcon className="h-5 w-5" />} {isMobileSearchOpen ? <ClearIcon className="h-5 w-5" /> : <SearchIcon className="h-5 w-5" />}
</Button> </Button>
@@ -37,7 +39,7 @@ export function SearchInput(props: SearchInputProps) {
className={cn( className={cn(
'relative w-fit max-w-lg', 'relative w-fit max-w-lg',
isMobileSearchOpen isMobileSearchOpen
? 'absolute left-0 mt-4 block w-full md:relative md:mt-0 md:w-fit' ? 'absolute start-0 mt-4 block w-full md:relative md:mt-0 md:w-fit'
: 'hidden w-auto md:block', : 'hidden w-auto md:block',
className, className,
)} )}
@@ -45,14 +47,14 @@ export function SearchInput(props: SearchInputProps) {
{!!value ? ( {!!value ? (
<button <button
type="button" type="button"
aria-label="Clear search" aria-label={t('search.clear')}
onClick={() => handleSearch('')} onClick={() => handleSearch('')}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted" className="absolute start-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted"
> >
<ClearIcon className="h-4 w-4 text-muted-foreground" /> <ClearIcon className="h-4 w-4 text-muted-foreground" />
</button> </button>
) : ( ) : (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2"> <span className="pointer-events-none absolute start-3 top-1/2 z-10 -translate-y-1/2">
<SearchIcon className="text-muted-foreground h-4 w-4" /> <SearchIcon className="text-muted-foreground h-4 w-4" />
</span> </span>
)} )}
@@ -62,7 +64,7 @@ export function SearchInput(props: SearchInputProps) {
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(ev) => handleSearch(ev.target.value)} onChange={(ev) => handleSearch(ev.target.value)}
className="pl-10 pr-4 text-sm" className="ps-10 pe-4 text-sm"
/> />
</div> </div>
</div> </div>
+3 -1
View File
@@ -1,10 +1,12 @@
import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select'; import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select';
import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const Select = (props: SelectProps) => { export const Select = (props: SelectProps) => {
const { t } = useTranslation();
const { options, value, onChange, onValueChange, label } = props; const { options, value, onChange, onValueChange, label } = props;
const { name = '', className, placeholder = 'Select...', update } = props; const { name = '', className, placeholder = t('select.placeholder'), update } = props;
if (!options) return null; if (!options) return null;
+3 -1
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from '@/components/locales';
type TabsProps = { type TabsProps = {
tabList: { value: string; label: string }[]; tabList: { value: string; label: string }[];
@@ -8,6 +9,7 @@ type TabsProps = {
setActiveTab: (tab: string) => void; setActiveTab: (tab: string) => void;
}; };
const TabsComponent = (props: TabsProps) => { const TabsComponent = (props: TabsProps) => {
const { t } = useTranslation();
const { tabList, activeTab, setActiveTab } = props; const { tabList, activeTab, setActiveTab } = props;
const tabsListRef = useRef<HTMLDivElement>(null); const tabsListRef = useRef<HTMLDivElement>(null);
return ( return (
@@ -38,7 +40,7 @@ const TabsComponent = (props: TabsProps) => {
<div className="block lg:hidden"> <div className="block lg:hidden">
<Select value={activeTab} onValueChange={(v) => setActiveTab(v)}> <Select value={activeTab} onValueChange={(v) => setActiveTab(v)}>
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Filter" /> <SelectValue placeholder={t('tabs.filterPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tabList.map((f) => ( {tabList.map((f) => (
+81 -1
View File
@@ -1 +1,81 @@
{} {
"button": {
"cancel": "Cancel",
"confirm": "Confirm",
"ok": "Ok",
"close": "Close"
},
"search": {
"placeholder": "Search",
"open": "Open search",
"close": "Close search",
"clear": "Clear search"
},
"select": {
"placeholder": "Select..."
},
"tabs": {
"filterPlaceholder": "Filter"
},
"commandBlock": {
"run": "Run in terminal",
"copy": "Copy command"
},
"colorPicker": {
"placeholder": "Hex colour"
},
"state": {
"loading": "Loading…"
},
"dataTable": {
"empty": "No Data Found.",
"pagination": {
"showingPage": "Showing page {{current}} of {{total}}",
"showingPageShort": "p {{current}}/{{total}}",
"rowsPerPage": "Rows per page",
"perPage": "{{size}} / page",
"perPageShort": "{{size}}/p",
"first": "First",
"prev": "Prev",
"next": "Next",
"last": "Last"
}
},
"pagination": {
"label": "Pagination",
"previous": "Previous",
"next": "Next",
"morePages": "More pages",
"goToPrevious": "Go to previous page",
"goToNext": "Go to next page"
},
"carousel": {
"previous": "Previous slide",
"next": "Next slide"
},
"breadcrumb": {
"more": "More"
},
"theme": {
"toggle": "Toggle color mode"
},
"error": {
"forbidden": {
"title": "Unauthorized Access",
"description": "You are not authorized to perform this action.",
"hint": "If you think this is an error, please contact your manager."
},
"unauthorized": {
"title": "Invalid Credentials",
"description": "Your access token has expired",
"hint": "Please sign in again"
},
"server": {
"title": "Server Error"
}
},
"logo": {
"icon": "Officer Icon",
"logo": "Officer Logo"
}
}
+81 -1
View File
@@ -1 +1,81 @@
{} {
"button": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"ok": "OK",
"close": "Fechar"
},
"search": {
"placeholder": "Pesquisar",
"open": "Abrir pesquisa",
"close": "Fechar pesquisa",
"clear": "Limpar pesquisa"
},
"select": {
"placeholder": "Selecionar..."
},
"tabs": {
"filterPlaceholder": "Filtrar"
},
"commandBlock": {
"run": "Executar no terminal",
"copy": "Copiar comando"
},
"colorPicker": {
"placeholder": "Cor hexadecimal"
},
"state": {
"loading": "A carregar…"
},
"dataTable": {
"empty": "Não foram encontrados dados.",
"pagination": {
"showingPage": "A mostrar página {{current}} de {{total}}",
"showingPageShort": "p {{current}}/{{total}}",
"rowsPerPage": "Linhas por página",
"perPage": "{{size}} / página",
"perPageShort": "{{size}}/p",
"first": "Primeira",
"prev": "Anterior",
"next": "Seguinte",
"last": "Última"
}
},
"pagination": {
"label": "Paginação",
"previous": "Anterior",
"next": "Seguinte",
"morePages": "Mais páginas",
"goToPrevious": "Ir para a página anterior",
"goToNext": "Ir para a página seguinte"
},
"carousel": {
"previous": "Diapositivo anterior",
"next": "Diapositivo seguinte"
},
"breadcrumb": {
"more": "Mais"
},
"theme": {
"toggle": "Alternar modo de cor"
},
"error": {
"forbidden": {
"title": "Acesso não autorizado",
"description": "Não tem autorização para executar esta ação.",
"hint": "Se acha que isto é um erro, contacte o seu gestor."
},
"unauthorized": {
"title": "Credenciais inválidas",
"description": "O seu token de acesso expirou",
"hint": "Inicie sessão novamente"
},
"server": {
"title": "Erro do servidor"
}
},
"logo": {
"icon": "Ícone Officer",
"logo": "Logótipo Officer"
}
}
@@ -1,4 +1,5 @@
import type { InterpolationParams, TranslationKeys } from 'i18n'; import type { InterpolationParams, TranslationKeys } from 'i18n';
import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n'; import { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json'; import type en from './en-GB.json';
@@ -8,6 +9,8 @@ type UiTranslationKey = TranslationKeys<typeof en>;
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention). // pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
export const useTranslation = () => { export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase(); const { t: baseT, locale } = useTranslationBase();
const t = (key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params); // Memoized: an unstable `t` is unusable in a dependency array. See platform/CLAUDE.md on the
// `useClient()` trap that silently disabled the Jellyfin player's playback reports.
const t = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
return { t, locale }; return { t, locale };
}; };
+3 -1
View File
@@ -1,12 +1,14 @@
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useColorMode } from './ThemeProvider'; import { useColorMode } from './ThemeProvider';
import { useTranslation } from '@/components/locales';
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean }; type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
export const ThemeToggle = (props: ThemeToggleProps) => { export const ThemeToggle = (props: ThemeToggleProps) => {
const { colorDark, colorLight } = props; const { colorDark, colorLight } = props;
const { colorMode, setColorMode } = useColorMode(); const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
const toggle = () => { const toggle = () => {
setColorMode(colorMode === 'dark' ? 'light' : 'dark'); setColorMode(colorMode === 'dark' ? 'light' : 'dark');
@@ -26,7 +28,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
colorDark && 'text-black', colorDark && 'text-black',
)} )}
/> />
<span className="sr-only">Toggle color mode</span> <span className="sr-only">{t('theme.toggle')}</span>
</div> </div>
); );
}; };
+15 -11
View File
@@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react'; import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Breadcrumb = React.forwardRef< const Breadcrumb = React.forwardRef<
HTMLElement, HTMLElement,
@@ -66,17 +67,20 @@ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentP
); );
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'; BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => ( const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
<span const { t } = useTranslation();
role="presentation" return (
aria-hidden="true" <span
className={cn('flex h-9 w-9 items-center justify-center', className)} role="presentation"
{...props} aria-hidden="true"
> className={cn('flex h-9 w-9 items-center justify-center', className)}
<MoreHorizontal className="h-4 w-4" /> {...props}
<span className="sr-only">More</span> >
</span> <MoreHorizontal className="h-4 w-4" />
); <span className="sr-only">{t('breadcrumb.more')}</span>
</span>
);
};
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'; BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export { export {
+7 -4
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type CarouselApi = UseEmblaCarouselType[1]; type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -168,6 +169,7 @@ CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>( const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => { ({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel(); const { orientation, scrollPrev, canScrollPrev } = useCarousel();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -177,7 +179,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
className={cn( className={cn(
'absolute h-8 w-8 rounded-full', 'absolute h-8 w-8 rounded-full',
orientation === 'horizontal' orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2' ? '-start-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90', : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className, className,
)} )}
@@ -186,7 +188,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
{...props} {...props}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span> <span className="sr-only">{t('carousel.previous')}</span>
</Button> </Button>
); );
}, },
@@ -196,6 +198,7 @@ CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>( const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => { ({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel(); const { orientation, scrollNext, canScrollNext } = useCarousel();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -205,7 +208,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
className={cn( className={cn(
'absolute h-8 w-8 rounded-full', 'absolute h-8 w-8 rounded-full',
orientation === 'horizontal' orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2' ? '-end-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className, className,
)} )}
@@ -214,7 +217,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
{...props} {...props}
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span> <span className="sr-only">{t('carousel.next')}</span>
</Button> </Button>
); );
}, },
+23 -19
View File
@@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Dialog = DialogPrimitive.Root; const Dialog = DialogPrimitive.Root;
@@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => {
<DialogPortal> const { t } = useTranslation();
<DialogOverlay /> return (
<DialogPrimitive.Content <DialogPortal>
ref={ref} <DialogOverlay />
className={cn( <DialogPrimitive.Content
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', ref={ref}
className, className={cn(
)} 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
{...props} className,
> )}
{children} {...props}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> >
<X className="h-4 w-4" /> {children}
<span className="sr-only">Close</span> <DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
</DialogPrimitive.Close> <X className="h-4 w-4" />
</DialogPrimitive.Content> <span className="sr-only">{t('button.close')}</span>
</DialogPortal> </DialogPrimitive.Close>
)); </DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+3 -1
View File
@@ -3,10 +3,12 @@ import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useColorMode } from '@/components/ui/ThemeProvider'; import { useColorMode } from '@/components/ui/ThemeProvider';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>( export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
const { colorMode, setColorMode } = useColorMode(); const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -19,7 +21,7 @@ export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, Reac
> >
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle color mode</span> <span className="sr-only">{t('theme.toggle')}</span>
</Button> </Button>
); );
}, },
+49 -26
View File
@@ -4,15 +4,19 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ButtonProps } from '@/components/ui/button'; import type { ButtonProps } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button'; import { buttonVariants } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => ( const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => {
<nav const { t } = useTranslation();
role="navigation" return (
aria-label="pagination" <nav
className={cn('mx-auto flex w-full justify-center', className)} role="navigation"
{...props} aria-label={t('pagination.label')}
/> className={cn('mx-auto flex w-full justify-center', className)}
); {...props}
/>
);
};
Pagination.displayName = 'Pagination'; Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>( const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
@@ -47,28 +51,47 @@ const PaginationLink = ({ className, isActive, size = 'icon', ...props }: Pagina
); );
PaginationLink.displayName = 'PaginationLink'; PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
<PaginationLink aria-label="Go to previous page" size="default" className={cn('gap-1 pl-2.5', className)} {...props}> const { t } = useTranslation();
<ChevronLeft className="h-4 w-4" /> return (
<span>Previous</span> <PaginationLink
</PaginationLink> aria-label={t('pagination.goToPrevious')}
); size="default"
className={cn('gap-1 ps-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>{t('pagination.previous')}</span>
</PaginationLink>
);
};
PaginationPrevious.displayName = 'PaginationPrevious'; PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
<PaginationLink aria-label="Go to next page" size="default" className={cn('gap-1 pr-2.5', className)} {...props}> const { t } = useTranslation();
<span>Next</span> return (
<ChevronRight className="h-4 w-4" /> <PaginationLink
</PaginationLink> aria-label={t('pagination.goToNext')}
); size="default"
className={cn('gap-1 pe-2.5', className)}
{...props}
>
<span>{t('pagination.next')}</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
};
PaginationNext.displayName = 'PaginationNext'; PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => ( const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}> const { t } = useTranslation();
<MoreHorizontal className="h-4 w-4" /> return (
<span className="sr-only">More pages</span> <span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
</span> <MoreHorizontal className="h-4 w-4" />
); <span className="sr-only">{t('pagination.morePages')}</span>
</span>
);
};
PaginationEllipsis.displayName = 'PaginationEllipsis'; PaginationEllipsis.displayName = 'PaginationEllipsis';
export { export {
+16 -12
View File
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Sheet = SheetPrimitive.Root; const Sheet = SheetPrimitive.Root;
@@ -51,18 +52,21 @@ interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {} extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>( const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = 'right', className, children, ...props }, ref) => ( ({ side = 'right', className, children, ...props }, ref) => {
<SheetPortal> const { t } = useTranslation();
<SheetOverlay /> return (
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> <SheetPortal>
{children} <SheetOverlay />
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<X className="h-4 w-4" /> {children}
<span className="sr-only">Close</span> <SheetPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
</SheetPrimitive.Close> <X className="h-4 w-4" />
</SheetPrimitive.Content> <span className="sr-only">{t('button.close')}</span>
</SheetPortal> </SheetPrimitive.Close>
), </SheetPrimitive.Content>
</SheetPortal>
);
},
); );
SheetContent.displayName = SheetPrimitive.Content.displayName; SheetContent.displayName = SheetPrimitive.Content.displayName;
@@ -1,212 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Play } from 'lucide-react';
export default function StartConfettiButton({
isDraftLike,
onStart,
className,
label = 'Start',
}: {
isDraftLike: boolean;
onStart?: () => void;
className?: string;
label?: string;
}) {
const [showConfetti, setShowConfetti] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rafRef = useRef<number | null>(null);
// ---- Confetti Engine (inline, light) ----
const runConfetti = useCallback(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
// size canvas
const resize = () => {
const { innerWidth: w, innerHeight: h } = window;
canvas.width = Math.floor(w * DPR);
canvas.height = Math.floor(h * DPR);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
};
resize();
const onResize = () => resize();
window.addEventListener('resize', onResize);
const colors = [
'#ef4444', // red-500
'#f59e0b', // amber-500
'#10b981', // emerald-500
'#3b82f6', // blue-500
'#8b5cf6', // violet-500
'#ec4899', // pink-500
'#22c55e', // green-500
'#eab308', // yellow-500
];
type Particle = {
x: number;
y: number;
w: number;
h: number;
vx: number;
vy: number;
rot: number;
rotSpeed: number;
tilt: number;
tiltSpeed: number;
color: string;
alpha: number;
decay: number;
shape: 'rect' | 'triangle';
};
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
const particles: Particle[] = [];
const bursts = 5;
const perBurst = 80;
const spread = Math.PI / 3;
const gravity = 0.25;
const air = 0.002;
const floorFriction = 0.96;
for (let b = 0; b < bursts; b++) {
const originX = (window.innerWidth / (bursts + 1)) * (b + 1);
const originY = window.innerHeight * 0.15;
const angleBase = -Math.PI / 2;
for (let i = 0; i < perBurst; i++) {
const angle = angleBase + rand(-spread, spread);
const speed = rand(6, 12);
particles.push({
x: originX + rand(-8, 8),
y: originY + rand(-8, 8),
w: rand(4, 8),
h: rand(6, 14),
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
rot: rand(0, Math.PI * 2),
rotSpeed: rand(-0.2, 0.2),
tilt: rand(-0.8, 0.8),
tiltSpeed: rand(-0.02, 0.02),
color: colors[(Math.random() * colors.length) | 0] ?? '#ef4444',
alpha: 1,
decay: rand(0.004, 0.01),
shape: Math.random() < 0.3 ? 'triangle' : 'rect',
});
}
}
const tick = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
// physics
p.vy += gravity;
p.vx *= 1 - air;
p.vy *= 1 - air * 0.5;
p.x += p.vx;
p.y += p.vy;
p.rot += p.rotSpeed;
p.tilt += p.tiltSpeed;
p.alpha -= p.decay;
// floor bounce & friction
const floor = window.innerHeight - 6;
if (p.y > floor) {
p.y = floor;
p.vy *= -0.45;
p.vx *= floorFriction;
p.rotSpeed *= floorFriction;
p.tiltSpeed *= floorFriction;
}
// draw
if (p.alpha > 0) {
ctx.globalAlpha = Math.max(0, p.alpha);
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot + p.tilt);
ctx.fillStyle = p.color;
if (p.shape === 'rect') {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
} else {
ctx.beginPath();
ctx.moveTo(0, -p.h / 2);
ctx.lineTo(p.w / 2, p.h / 2);
ctx.lineTo(-p.w / 2, p.h / 2);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
}
const alive = particles.some((p) => p.alpha > 0.02);
if (alive) {
rafRef.current = requestAnimationFrame(tick);
} else {
// cleanup
ctx.clearRect(0, 0, canvas.width, canvas.height);
window.removeEventListener('resize', onResize);
}
};
tick();
}, []);
const handleStart = () => {
onStart?.();
if (isDraftLike) {
setShowConfetti(true);
}
};
useEffect(() => {
if (!showConfetti) return;
runConfetti();
const t = setTimeout(() => {
setShowConfetti(false);
}, 2200);
return () => {
clearTimeout(t);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, [showConfetti, runConfetti]);
return (
<>
<Button
size="sm"
onClick={handleStart}
className={`min-w-24 ${isDraftLike ? 'bg-green-600 text-white hover:bg-green-700' : ''} ${className ?? ''}`}
disabled={!isDraftLike}
title={isDraftLike ? 'Start the experiment' : 'Only available for Draft/Created'}
>
<Play className="w-4 h-4 mr-2" />
{label}
</Button>
{/* Confetti Overlay */}
{showConfetti && (
<div
className="fixed inset-0 z-100 pointer-events-none mask-[radial-gradient(80%_80%_at_50%_40%,#000,transparent)]"
aria-hidden
>
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
<div className="absolute inset-x-0 top-0 h-32 bg-linear-to-b from-white/60 to-transparent" />
</div>
)}
</>
);
}
+26 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve'; import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
import { findMissingKeys } from './parity'; import { findMissingKeys } from './parity';
import type { TranslationMap } from './types'; import type { TranslationKeys, TranslationMap } from './types';
describe('resolveKey', () => { describe('resolveKey', () => {
const translations: TranslationMap = { const translations: TranslationMap = {
@@ -100,3 +100,28 @@ describe('findMissingKeys', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]); expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
}); });
}); });
/**
* Type-level, not behavioural: a plural entry must be addressable by its own key and must NOT
* contribute its CLDR categories to the union. Asserted here because nothing else would notice —
* the failure mode is `t('files', { count })` refusing to compile while `t('files.one')` compiles.
*/
describe('TranslationKeys', () => {
test('stops at a plural leaf and still walks plain nesting', () => {
const catalog = {
files: { one: '{{count}} file', other: '{{count}} files' },
button: { save: 'Save', nested: { deep: 'Deep' } },
};
type Key = TranslationKeys<typeof catalog>;
const assertKey = <K extends Key>(k: K) => k;
expect(assertKey('files')).toBe('files');
expect(assertKey('button.save')).toBe('button.save');
expect(assertKey('button.nested.deep')).toBe('button.nested.deep');
// @ts-expect-error a plural category is not a key on its own
assertKey('files.one');
// @ts-expect-error a branch node is not a key
assertKey('button');
});
});
+15 -3
View File
@@ -13,10 +13,22 @@ export type I18nContextValue = {
locale: string; locale: string;
}; };
/**
* A node that IS a plural entry, so `TranslationKeys` stops at it rather than walking into it.
*
* Without this, `{ one, other }` contributed `foo.one` and `foo.other` to the key union and `t('foo',
* { count })` did not typecheck at all — the plural machinery landed in phase 0 with unit tests, and the
* type gap only surfaced in phase 2 when a real catalog first used one. Mirrors `isPluralForms` at
* runtime: every key a CLDR category, every value a string.
*/
type IsPluralLeaf<T> = keyof T extends Intl.LDMLPluralRule ? (T[keyof T] extends string ? true : false) : false;
export type TranslationKeys<T, Prefix extends string = ''> = T extends string export type TranslationKeys<T, Prefix extends string = ''> = T extends string
? Prefix ? Prefix
: T extends Record<string, unknown> : T extends Record<string, unknown>
? { ? IsPluralLeaf<T> extends true
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>; ? Prefix
}[keyof T & string] : {
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T & string]
: never; : never;
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react'; import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/sonner'; import { toast } from '@/components/ui/sonner';
@@ -12,7 +12,12 @@ import { useChat, EmbeddableChat } from '../Chat';
import { DirPickerModal } from '../ChatHistory/DirPickerModal'; import { DirPickerModal } from '../ChatHistory/DirPickerModal';
import type { ChatMessage } from '../Chat/types'; import type { ChatMessage } from '../Chat/types';
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes'; import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq'; import {
useCommandHqCategories,
useCommandHqChildren,
useCommandHqCommander,
useCommandHqCommanderActions,
} from './useCommandHq';
import { formatCommanderCost, formatTokens } from './format'; import { formatCommanderCost, formatTokens } from './format';
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide'; import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
@@ -96,6 +101,10 @@ const startBrief = (name: string): string =>
'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.', 'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.',
'If you CANNOT — ask me what you should be watching for here. One question, and wait.', 'If you CANNOT — ask me what you should be watching for here. One question, and wait.',
'', '',
'Once you are running, you owe me exactly two messages here per event, and I should never have to ask',
'for them: one line when the thing you watch for happens, and a short report when the work is done.',
'The job panel is not where I read things. The skill explains how to wake this conversation yourself.',
'',
'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag', 'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag',
'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,', 'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,',
'so saying you looked is not news. Length is whatever the answer honestly needs and no more.', 'so saying you looked is not news. Length is whatever the answer honestly needs and no more.',
@@ -386,6 +395,62 @@ function NewCommander({
); );
} }
/**
* The agents this commander spawned, each a link to its own transcript.
*
* A spawned agent has always been fully readable — it writes an ordinary transcript under the project
* directory for its cwd, and `/chat/<id>` renders it live like any other session. What was missing was
* anybody being told the id existed. The job panel showed its output as a text stream in a small box with
* no link, so the only way to find the session was to know to go looking.
*
* Renders nothing when there are none, rather than an empty state. A commander that has not fired yet is
* the normal case and does not need a box explaining that.
*/
const SpawnedChildren = ({ sessionId }: { sessionId: string }) => {
const { children, totalUsd, unknownCost } = useCommandHqChildren(sessionId);
if (!children.length) return null;
return (
<div className="shrink-0 border-b border-border px-4 py-2">
<div className="flex items-baseline gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Spawned</span>
<span>{children.length}</span>
{/*
Cost is stated as a floor when any child's is unknown, never as a confident total. A commander's
line reading $0.28 for a night that spent several dollars is the specific understatement this
exists to stop.
*/}
{totalUsd > 0 && (
<span>
· ${totalUsd.toFixed(2)}
{unknownCost ? '+' : ''}
</span>
)}
{unknownCost > 0 && (
<span title={`${unknownCost} child${unknownCost === 1 ? '' : 'ren'} did not report a cost`}>
· {unknownCost} unknown
</span>
)}
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{children.map((c) => (
<Link
key={c.spawnedSessionId}
to={`/chat/${c.spawnedSessionId}`}
title={`spawned ${new Date(c.firstSeenAt).toLocaleString()}${c.finishedAt ? `, finished ${new Date(c.finishedAt).toLocaleString()}` : ' — still running'}`}
className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-foreground/80 hover:border-primary/50 hover:text-foreground"
>
{c.spawnedSessionId.slice(0, 8)}
{!c.finishedAt && (
<span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-duck-orange align-middle" />
)}
</Link>
))}
</div>
</div>
);
};
type MetaBarProps = { type MetaBarProps = {
sessionId: string; sessionId: string;
title: string | undefined; title: string | undefined;
@@ -572,6 +637,7 @@ function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: Re
isGenerating={chat.isGenerating} isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession} onDisconnect={chat.disconnectSession}
/> />
<SpawnedChildren sessionId={sessionId} />
<EmbeddableChat <EmbeddableChat
chat={chat} chat={chat}
sessionId={undefined} sessionId={undefined}
@@ -102,3 +102,39 @@ export function useCommandHqCommanderActions(sessionId: string) {
return { setCategory, rename, disarmWatcher, deleteCommander }; return { setCategory, rename, disarmWatcher, deleteCommander };
} }
export type SpawnedChild = {
spawnedSessionId: string;
firstSeenAt: string;
finishedAt: string | null;
totalUsd: number | null;
outcome: string | null;
};
/**
* The agents this commander spawned.
*
* The backend ingests the loop's record file on every read, so calling this often is free and there is no
* timer to stop working quietly. Which is the reason this hook exists at all: the endpoint and the table
* were built, and nothing ever called them — so the table sat empty and a spawned agent stayed invisible
* even though its transcript was sitting there, readable, the whole time.
*/
export function useCommandHqChildren(sessionId: string | undefined) {
const client = useClient();
const { data, isLoading } = useQuery<{ children: SpawnedChild[]; totalUsd: number; unknownCost: number }>({
queryKey: ['command-hq', 'children', sessionId ?? ''],
queryFn: () => client.get(`/command-hq/commanders/${sessionId}/children`),
enabled: !!sessionId,
// A spawn appears while the panel is open, so a stale window here means watching a stale list during
// exactly the minutes somebody is watching.
staleTime: 0,
refetchInterval: 15_000,
retry: false,
});
return {
children: data?.children ?? [],
totalUsd: data?.totalUsd ?? 0,
unknownCost: data?.unknownCost ?? 0,
isLoading,
};
}
@@ -1,4 +1,5 @@
import type { InterpolationParams, TranslationKeys } from 'i18n'; import type { InterpolationParams, TranslationKeys } from 'i18n';
import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n'; import { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json'; import type en from './en-GB.json';
@@ -8,6 +9,11 @@ type PanelTranslationKey = TranslationKeys<typeof en>;
// pass the same bare key the JSON file uses. // pass the same bare key the JSON file uses.
export const useTranslation = () => { export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase(); const { t: baseT, locale } = useTranslationBase();
const t = (key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params); // Memoized: an unstable `t` is unusable in a dependency array. See platform/CLAUDE.md on the
// `useClient()` trap that silently disabled the Jellyfin player's playback reports.
const t = useCallback(
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
[baseT],
);
return { t, locale }; return { t, locale };
}; };
@@ -1,8 +1,8 @@
--- ---
id: agent-delivery/generalise-it-deliver-a-turn-to-a-named id: agent-delivery/generalise-it-deliver-a-turn-to-a-named
title: "Generalise it: deliver a turn to a named session, from anywhere in the backend" title: 'Generalise it: deliver a turn to a named session, from anywhere in the backend'
theme: agent-delivery theme: agent-delivery
status: open status: done
created: 2026-08-21 created: 2026-08-21
date_source: stated date_source: stated
order: 1 order: 1
@@ -12,7 +12,7 @@ order: 1
2026-08-21 — "ideal" is the owner's word for it. Today the only server-side caller is the nudger, 2026-08-21 — "ideal" is the owner's word for it. Today the only server-side caller is the nudger,
and delivery is welded to its schedule: a row in `nudges`, one per (user, session), a fixed and delivery is welded to its schedule: a row in `nudges`, one per (user, session), a fixed
message, a tick. What the dispatcher, the file watchers and Command HQ all want instead is a plain message, a tick. What the dispatcher, the file watchers and Command HQ all want instead is a plain
verb — *here is some text, deliver it to session X* — with the schedule, if any, belonging to the verb — _here is some text, deliver it to session X_ — with the schedule, if any, belonging to the
caller. caller.
Two things already established and worth not re-deriving. The nudger's `checkSafe` holds three Two things already established and worth not re-deriving. The nudger's `checkSafe` holds three
@@ -20,3 +20,20 @@ refusals — not live, live but generating, another live session in the same dir
comments above it carry why each one is there. And "not live" is the case that differs by caller: comments above it carry why each one is there. And "not live" is the case that differs by caller:
the nudger refuses (nudging a dead session would CREATE an agent, which is not what nudge means), the nudger refuses (nudging a dead session would CREATE an agent, which is not what nudge means),
while a dispatcher handing over five todos does want it created. while a dispatcher handing over five todos does want it created.
---
**Closed 2026-08-23.** Answered from both ends, and neither needed the general verb this asked for.
`SendMessage` delivers agent-to-agent with `notify_when_idle` replacing a poll — proven that night by one
session taking over the localisation hand-off from the owner, who had been relaying prompts by hand. And the
nudger delivers backend-to-session on a schedule, which is what a commander now uses to wake its own
conversation and report to the owner rather than into a job panel nobody reads.
The two together cover what was wanted. What remains genuinely missing is spawning a NEW session from a
non-chat caller — a shell loop, a plugin, a background job — and that is
`todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a`, which stays open. The harness `Agent` tool
does not close it: it is available to a chat agent and to nothing else, which is exactly why a commander's
loop shells out to `claude -p` by hand.
The pattern is written down as an affordance now — `capabilities/affordances/officer/core/coordinating-another-agent.md`.
@@ -2,7 +2,7 @@
id: background-jobs/a-spawned-agent-is-invisible-in-the-interface id: background-jobs/a-spawned-agent-is-invisible-in-the-interface
title: 'An agent spawned by a background job is invisible in the interface' title: 'An agent spawned by a background job is invisible in the interface'
theme: background-jobs theme: background-jobs
status: open status: done
created: 2026-08-21 created: 2026-08-21
date_source: stated date_source: stated
order: 1 order: 1
@@ -27,6 +27,25 @@ Two fixes, and the small one is most of the value:
against the commander that spawned it, which would also fix cost attribution — today the child's tokens against the commander that spawned it, which would also fix cost attribution — today the child's tokens
land nowhere, so a commander's cost line reads `$0.28` for a night that spent several dollars. land nowhere, so a commander's cost line reads `$0.28` for a night that spent several dollars.
The second one needs a decision about what the child *is*. A commander is defined by its directory The second one needs a decision about what the child _is_. A commander is defined by its directory
(`isCommandersDir`), and the child runs in the platform checkout, so it does not qualify — deliberately. (`isCommandersDir`), and the child runs in the platform checkout, so it does not qualify — deliberately.
Making it visible probably means a different relationship than "another commander". Making it visible probably means a different relationship than "another commander".
---
**Closed 2026-08-23.** The commander's pane lists its spawned agents, each a link to `/chat/<id>`, with a
dot on the ones still running.
Worth recording what the work actually turned out to be. The endpoint
(`GET /command-hq/commanders/:id/children`), the `command_hq_spawned` table and the ingest were all built
earlier the same night — and **nothing had ever called them**, so the table read empty and the feature looked
unbuilt. Ingest happens on read rather than on a timer, which means the table being empty was itself the
symptom: nobody had looked.
The second fix this file proposed — the loop echoing the transcript id — was not needed in the end. The ids
are already in the loop's `spawned` record, which the endpoint ingests, so the id reaches the UI without the
loop having to narrate it.
Cost attribution is partly addressed and honestly so: the pane shows a floor (`$1.20+`) with a count of
children that reported no cost, never a confident total. The remaining gap is that a child's spend does not
flow back to the commander's own cost line — that is cost accounting, deferred by the owner, and not this.
+4 -3
View File
@@ -49,9 +49,10 @@ handling.
- `Dashboards/DashboardPreview`'s `NamePanel` (name + description) has its submit button in a - `Dashboards/DashboardPreview`'s `NamePanel` (name + description) has its submit button in a
_different_, independently-mounted panel (`CreatePanel`) elsewhere in the workspace layout tree — a _different_, independently-mounted panel (`CreatePanel`) elsewhere in the workspace layout tree — a
`<form>` can't span two panels without restructuring the wizard. `<form>` can't span two panels without restructuring the wizard.
- `ColorPicker` and `MarkdownEditor` have zero call sites anywhere in the repo right now; both are - `ColorPicker` has zero call sites anywhere in the repo right now; it is shaped to be embedded in a
shaped to be embedded in a future caller's own form (uncontrolled inputs with `name` attributes, or a future caller's own form (uncontrolled inputs with `name` attributes), so there's nothing to wrap yet.
plain controlled textarea with no submit of its own), so there's nothing to wrap yet. `MarkdownEditor` was named here too and was deleted on 2026-08-23 during the localization pass — it
had no call sites and hardcoded another product's logo URL.
- `widgets/Weather`'s `LocationSetup` is a live autocomplete with several result rows and no single - `widgets/Weather`'s `LocationSetup` is a live autocomplete with several result rows and no single
obvious Enter target (which of N matches?). obvious Enter target (which of N matches?).
@@ -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.