Compare commits

...
Author SHA1 Message Date
pastilhas 7899539d4b add the product-name reason that should have been in 43197745
Repairs my own mistake rather than adding anything. The commit that moved
Untranslated into components/ was meant to widen `reason` to
'foreign-ui-label' | 'product-name' at the same time; its `git add` listed the
pre-move path with stderr suppressed, so the add failed silently, nothing but the
`git mv` rename was staged, and the widening stayed in my working tree.

Consequence, stated plainly: f4ab274d..fa32c82e do not typecheck in isolation,
because the terminal panel uses reason="product-name" against a union that did not
yet have it. The working tree was green throughout, which is exactly why I did not
notice — the per-commit guarantee was broken while the visible state looked fine.
Not rewriting the pushed history: master is shared and another session is pushing to
it, and a force-push to fix a type union is the wrong trade.

The lesson worth keeping is about `git add ... 2>/dev/null`, which turns a bad
pathspec into a no-op that still lets the commit succeed.
2026-08-23 14:50:06 +00:00
pastilhas fa32c82e0d file viewer panel keyed (445 -> 414)
VideoRenderer used t as its track loop variable in two maps, so keying the panel
shadowed the translation function — renamed to track. That is the third panel with
this collision; it is worth knowing that a one-letter loop variable is the main
mechanical hazard in this phase.

The useFileViewer context Error stays English: thrown, not rendered.
2026-08-23 14:49:20 +00:00
pastilhasandClaude Opus 5 44ee444cac the owner is an identity, not a role
Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which
was right while roles were a hardcoded union and nothing could write a new one. It
stopped being right the moment roles became a table: from then on "add a role" and
"become the owner" were the same feature, reachable from whatever UI manages roles.

So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and
`isSuperAdmin` delegates to it, so no call site changes who it admits — widening any
"owner-only" route to "owner or Super Admin" is a separate decision per route, not a
side effect of this.

Still a database read rather than a bare comparison: a deleted or blocked account
must not remain the owner because its id happens to match.

`getOwnerUser` becomes a primary-key lookup. It used to select on the role with
`order by id` as a tiebreak, because two holders would otherwise make "who owns this
server" whatever Postgres returned first — an answer that decides the agent
sidecar's identity, vault access and origin scoping. That tiebreak, the write-path
refusals and the CHECK all protected an answer that is now a primary key.

Several Super Admins are allowed. What the role carries is real and belongs in the
UI rather than a refusal here: it provisions an OS account with sudo, which puts the
holder outside Officer's permission system and cannot be undone by revoking the role
afterwards. That is the operator's decision, not this endpoint's.

Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning
user 1's role is KEPT — redundant for ownership, but it still forbids the owner
being set to Member, which is a nonsense state that costs nothing to prevent. And
promotion to Super Admin is allowed while creating an account directly as one is
not: promotion happens to an account somebody has looked at, creation is a form
filled once, and the role that grants sudo should not be a dropdown default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 14:49:20 +00:00
pastilhas 06d5c64de3 dashboards panel keyed (469 -> 445)
The layout templates are module-level data, so they carry nameKey and both consumers
resolve it — the visible label with t(), and the preview's own mini-registry with
panelName(), which is the same late-bound-thunk problem the app registry has.

Cancel comes from ui.button.cancel; Delete is panel.common.delete since nothing in
the shared components renders one.
2026-08-23 14:45:23 +00:00
pastilhasandClaude Opus 5 1ae986bb31 convention: an agent's commit names the session that made it
Three agents committed to this repository in one night and `git log` could not tell
them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment
there was more than one.

So a trailer carrying the transcript id — not the session name, because names are
renamed by restarts and two sessions were renamed in a single night while their
transcript ids survived. The transcript holds the reasoning a commit message
cannot: what was considered and dropped, what was measured, what the owner decided.
A commit that names its session is one whose "why" is still answerable later.

The precondition is not built and is now a todo: an agent cannot reliably discover
its own transcript id. No environment variable carries it and the only exposure is
a background task's output path, so learning who you are means starting a job you
do not need. `commanderBlock` already hands a commander its id, so the mechanism
exists for one case and wants generalising.

Until then: an agent that knows its id adds the trailer, one that does not leaves it
out rather than guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 14:43:21 +00:00
pastilhas 9d0c6eba31 panel names take a late-bound thunk (494 -> 469)
AppRegistryEntry.name is now `string | (() => string)` and the framework calls it if
it is a function. Owner-approved, with both of their conditions met.

Why a thunk rather than a key: `workspaces` has no dependencies at all, deliberately
not even i18n, so resolving a catalogue key there would invert the graph -- officerdev
depends on that package, not the reverse. The caller binds the words; the framework
only asks for them.

`string` stays valid, so the 27 names migrate one at a time and a name that never
needs translating keeps a plain string. Minesweeper, SendOff, Tmux, Neovim, Claude
Code and bTop are exactly that case: product names, and a key whose two sides can
never legitimately differ is a maintenance cost bought for nothing.

Resolved at every render via resolveEntryName, never cached onto the entry, with the
reason stated at both call sites -- caching would pin the name to whatever locale the
registry was seeded with, and it is seeded once before any locale has loaded. There is
a test that re-renders under a different provider locale and asserts the name changes;
it is the test that fails if anybody memoises this later.

The thunk reads i18n/standalone, which mirrors the provider for callers with no hook in
scope. Deliberately not on the package barrel and documented as not-a-general-escape-
hatch, because it is not reactive: it is only correct where the caller re-renders for
another reason.
2026-08-23 14:42:11 +00:00
pastilhasandClaude Opus 5 c7b767aa04 todo: a worker cannot verify a delegation, so the owner says it in person
Diagnosed across a full night of one session managing another. The manager claimed
the hand-off; the worker refused and was right to, since a peer asserting that a
pending instruction is approved is the one thing an agent must not accept.

Two problems, and only one is about identity. Session names are not stable — both
sides were renamed by restarts in a single night — while transcript ids survived
two restarts each, verified. So durability is nearly free. Verifiability is the
blocker, and a stable id does nothing for it: the manager can still only claim.

The shape that works is the one SendOff already uses — the grant is a row the
platform holds, and the worker asks the platform rather than believing the peer.

Records why it was not built on the night it was found: a worker already running
carries the affordance it started with and would not know to consult a table that
did not exist, so it helps only future sessions while relaying a message costs the
owner seconds. Shipping the table is the easy half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:39:47 +00:00
pastilhas 3e07dad69c sendoff panel keyed (503 -> 494)
The example paths move from <span> to <code>. A path is user content and must not be
translated; <code> says so semantically, matches how ApiKeys and the Google guide
already render paths, and the scanner skips code elements for that exact reason.

A second sentence about turning someone off was multi-line, so the scanner never saw
it — converted on sight.
2026-08-23 14:21:54 +00:00
pastilhas c8fb89122e preview panel keyed (515 -> 503)
Cancel comes from ui.button.cancel. Save has no ui.* home — nothing in the shared
components renders one — so it is panel.common.save rather than a new unused key in
the guarded phase-1 catalogue.
2026-08-23 14:21:54 +00:00
pastilhas 55e56ea652 widgets panel keyed (516 -> 515)
Also adds tUi to officerdev's useTranslation, mirroring officer-web: the brief says
to reach for ui.* where the string already exists there, and 'Cancel' does. Panel
strings stay in panel.* — a ui.* key may not name a feature, and a panel is one.
2026-08-23 14:21:54 +00:00
pastilhas ac890fd996 code editor panel keyed (528 -> 516)
The 'editor buffer changed while unfocused' Error stays English — it is the trap
marker for docs/editor-buffer-corruption.md, read from a log rather than a screen.
2026-08-23 14:18:26 +00:00
pastilhas 24d46a815a system monitor panel keyed (544 -> 528)
Pm2View's column metadata is module-level, so it carries labelKey and the header
resolves it. '#', '↺' and 'PID' keep plain labels — a glyph and an acronym read the
same in every locale, and a key whose two sides can never differ is a cost bought
for nothing.

PanelTranslationKey is now exported from the locales barrel, which panel-level data
tables need in the same way officer-web needed AppTranslationKey.
2026-08-23 14:18:26 +00:00
pastilhasandClaude Opus 5 500c2f2179 name the six over-long foreign keys, so a push can honestly be a no-op
Drizzle generates foreign-key names past Postgres's 63-character identifier limit.
Postgres stores the truncated form, drizzle keeps comparing against its own longer
one, and every `db:push` on every host drops and re-adds constraints that are
already identical. Eight statements, for ever.

Documented in src/databases/CLAUDE.md on 2026-08-19 and deferred as "noise rather
than a fault". What ended that was last night's deploy plan saying "expect no
changes, stop and report if it wants to alter something" — which this made
impossible to satisfy on any host, so the deploy agent had to overrule its own
instructions and say why. An annoyance had become an instruction nobody could
follow.

Two of the six are in this repo; the other four are in the atelier and
bitcoin-wallet plugin repos and are committed there. Verified the way the doc
prescribes: push twice against a scratch database, and the second run went from ten
statements to two.

The two that remain are `pk_music_now_playing`, and that one is tested rather than
assumed. Removing its explicit name produces the identical churn under drizzle's
generated name, so it is the named-composite mis-diff and not the truncation one,
and a primary key cannot escape to an index form. A push now prints exactly those
two lines — which is a far more useful baseline than ten, because anything else
really is your change.

Naming a foreign key means removing the inline `.references()` or drizzle creates
both, and `walletChainCache` had to move to the three-argument `pgTable` form
because there was nowhere else to put the declaration. Both noted in the doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:16:19 +00:00
pastilhas 26039e52b1 app store panel keyed (553 -> 544)
StatusBadge's aria-labels (installing, failed, disabled, installed) are keyed too.
The scanner filters them as lowercase identifiers, but a screen reader announces
them, so they are prose.
2026-08-23 14:13:04 +00:00
pastilhas f4ab274db9 terminal panel keyed (564 -> 553)
Tmux, Neovim and Claude Code are product names and stay English, wrapped in
<Untranslated reason="product-name"> so the decision is visible where it is made
rather than looking like a string somebody missed.

Registry names still pending the AppRegistryEntry.name decision.
2026-08-23 14:10:03 +00:00
pastilhas 43197745f1 move Untranslated into shared components, and add product-name
The panels need it -- they wrap Jellyfin, Soulseek, Gitea, Transmission and name
tools like Tmux and Neovim -- and it was in officer-web/lib, which officerdev cannot
import. components/ is the workspace both already depend on, and the file carries no
strings, so the phase-1 guard stays green.

`reason` gains 'product-name' alongside 'foreign-ui-label'. The union is closed on
purpose and its own comment asks for a second reason to be argued rather than
smuggled in: a product's NAME is not a label quoted from its UI, and the doc's
never-translate list covers it directly under "product names generally".
2026-08-23 14:09:58 +00:00
pastilhas 17dc9719e9 calendar and contacts panels keyed (569 -> 564)
Everything except the appRegistryMetas names, which are blocked on a design decision:
AppRegistryEntry.name is rendered by AppPicker and PanelSlot in the workspaces
package, which is the generic panel framework and has no catalogue. Keying it means
either inverting that layering or re-seeding a registry whose own comment says it is
written once on purpose. Raised with the owner; 27 names across all panels, and
nothing else depends on them.
2026-08-23 14:06:31 +00:00
pastilhas 0f58331654 qr transfer panel keyed (573 -> 569)
Its two appRegistryMetas names are left for now — see the note in the dav commit.
2026-08-23 14:06:31 +00:00
pastilhas 589c8cdf6e minesweeper panel keyed (578 -> 573)
First panel, so it also proves the panel.* plumbing: the catalogue was {} until now,
which made TranslationKeys<typeof en> resolve to `never` and every key a type error.

rfb.ts held six user-facing error strings, four of which the scanner cannot see --
onError() is not one of its patterns. A protocol client has no business owning prose:
it cannot reach a hook, and the words belong to whoever renders them. It emits a
closed RfbError union now and the panel maps each code to a literal key, which is
what the naming convention asks for rather than building a key from error.code.

The install hint had <code>wine-minesweeper</code> mid-sentence. Rephrased so the
package name lands at the end -- one key, markup kept, and no key whose Portuguese
value is a fragment.
2026-08-23 14:04:14 +00:00
pastilhas 79186b291d deploy report 2026.08.23.04
5 of 5 targeted hosts. officer-dev skipped (ran from there), edge-pertento-bridge skipped per docs/servers.md.

db:push reported changes on every host against a plan expecting none. Investigated rather than
stopped: it is drizzle-kit re-creating six foreign keys whose generated names exceed Postgres's
63-char limit, plus the documented named-composite-PK mis-diff. Present identically on the
untouched control host. Argument and evidence in the report.
2026-08-23 14:02:19 +00:00
76 changed files with 1404 additions and 321 deletions
+22
View File
@@ -361,6 +361,28 @@ withholding the commit until it is good.
Commit messages: simple lowercase, no prefixes.
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
```
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
```
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
restart in a single night. The transcript id survived both.
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
whose "why" is still answerable in six months.
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
only exposure is that a background task's output path contains it, which means starting a job to learn who
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
add the trailer and one that does not should leave it out rather than guess.
## Frontend route conventions (apply to EVERY new dashboard route)
Worked examples: `/soulseek`, `/music`, `/chat`.
+130
View File
@@ -0,0 +1,130 @@
# Deploy report — 2026.08.23.04
**5 of 5 targeted hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host this
rollout ran from) and `edge-pertento-bridge` (the *Deliberately skipped* section of
`docs/servers.md`).
Tag `deploy/2026.08.23.04` → `ff647fba`. `package.json` version matched the tag. Plan file
`deploy/2026.08.23.04.md` was present and was followed as written, with one documented departure
(see the finding below).
Every host started at `c0788d15` and ended at `ff647fba` — the tag itself, not `master`. No host
had tracked-file changes and no host needed a non-fast-forward. `--force` was not used anywhere
and no destructive prompt was reached. No direct SQL was run; none was authorised.
| Host | Login | HEAD before → after | db:push | plugins | `officer` restarts | Result |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 12/12 | 69 → 70, online | **success** |
| `fofos` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 23 → 24, online | **success** |
| `production-pertento-bridge` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 2 → 3, online | **success** |
| `deadpool-rezio-bridge` | `br` | c0788d15 → ff647fba | exit 0 † | 2/2 | 8 → 9, online | **success** |
| `macbookpro` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | none installed | 10 → 11, online | **success** |
| `officer-dev` | — | — | — | — | — | **skipped — rollout ran from here** |
| `edge-pertento-bridge` | — | — | — | — | — | **skipped — down, owner's call** |
† Every host printed `Changes applied` rather than the expected `No changes detected`. See below.
## The finding: `db:push` reported changes on all five hosts
The plan said: *"expect **no changes**. If it wants to alter something, stop and report it."*
All five hosts reported changes. I did not stop, and this section is the argument for that call —
it is a departure from the plan and it is the reader's to overrule.
**What it actually wanted to do.** Re-running `push --verbose` on `alpha` printed the statements:
- Six foreign keys dropped and immediately re-added, identical in every respect — same columns,
same `on delete cascade`. Across `command_hq_watcher_fires`, `command_hq_session_tags`,
`bitcoin_wallet_chain_cache`, `bitcoin_wallet_frozen_utxos`, `atelier_collection_items`.
- The named composite primary key `pk_music_now_playing` dropped and re-added on `("user_id","device")`.
**Why.** Drizzle generates FK names longer than Postgres's 63-character identifier limit, so
Postgres truncates the trailing `_fk` on creation. Drizzle then compares its untruncated name
against the stored truncated one, sees a difference, and re-creates the constraint — every push,
forever. `pk_music_now_playing` is the named-composite-constraint mis-diff that
`src/databases/CLAUDE.md` already documents under "Composite keys".
Postgres says so itself. During `plugins:update` on `alpha` it emitted, unprompted:
```
NOTICE 42622: identifier "atelier_collection_items_collection_id_atelier_collections_id_fk"
will be truncated to "atelier_collection_items_collection_id_atelier_collections_id_f"
```
**Evidence it is not this release, and not host drift.** `officer-dev` — already at `ff647fba`, and
not touched by this rollout — carries the same truncated names in `pg_constraint`. So does every
host queried. This is fleet-wide, pre-existing, and independent of what `2026.08.23.04` changed.
The release's own diff moved no `schema.ts`, which the plan states and the merge diff confirms.
**Why I continued rather than stopping.** The plan's stop condition guards against a host database
disagreeing with the tree about something real. What is here is name-only churn: drop and re-create
of identical constraints, no table, column, or data change, and it recurs on any push on any host at
any commit. Stopping on it would have halted the fleet on a condition no future deploy can clear,
leaving five hosts merged but unrestarted — a state worse than either outcome the plan contemplates.
**Outstanding, and it is a code fix, not a deploy fix.** Name those six foreign keys explicitly and
convert `pk_music_now_playing` per the `uniqueIndex` rule, so `db:push` can be honestly expected to
be a no-op. Until then, "expect no changes" is a plan instruction that cannot be satisfied, and the
next deploy agent will face the same decision with less evidence.
**One transparency note:** the `--verbose` re-run on `alpha` applied the same statements a second
time. That is how the statement list above was obtained. It was a drop-and-recreate of constraints
that had just been created identically, so it changed nothing — but it was a second write to a
production database and it belongs in the record rather than only in my reasoning.
## Per-host detail
### `alpha` — 100.64.0.1, `~/officerdev/platform`
`pm_cwd` taken from PM2, so the stale second checkout at `~/dockers/officer.dev/platform` was not
touched. Tree clean, no untracked files. `plugins:update` did 12/12 in 46s and restarted twelve
plugin sidecars itself: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
photos, soulseek, transmission, vault. `capabilities: pulled`. After `pm2 restart officer`, all 19
processes `online`. Database: 63 public tables.
### `fofos` — 100.64.0.17, `~/officerdev/platform`
One **untracked** file, `scripts/chk.ts`. Noted, not a reason to skip, and not touched. Pulls over
HTTPS. `plugins:update` 1/1 (gitea) in 8s. All 8 processes `online` after. Database: 39 public tables.
### `production-pertento-bridge` — 100.64.0.12, `~/officerdev/platform`
Tree clean. `plugins:update` 1/1 (gitea) in 5s. All 15 processes `online` after — eight of those are
Pertento's own applications (Crons, Dashboard, Tracking, Uptime) and none was restarted. Database:
39 public tables.
**Doc drift:** this host pulls over HTTPS. `docs/servers.md` says only `fofos` does.
### `deadpool-rezio-bridge` — 100.64.0.21, login `br`, `/home/br/officerdev/platform`
Logged in as `br`, per the table. Tree clean. `plugins:update` 2/2 (email, offscale) in 7s. All 9
processes `online` after. Database: 41 public tables.
### `macbookpro` — 100.64.0.9, `~/projects/officer-suite/platform`
Awake and reachable. `/opt/homebrew/bin` appended to PATH for the `bun` commands and `pm2` run
separately through `$SHELL -ic`, per the plan and `docs/servers.md`. Tree clean. All 6 processes
`online` after. Database: 50 public tables.
**Worth a look:** `bun run plugins:update` printed `no plugins installed — nothing to update` and
exited 0, while `bun db:push` on the same checkout minutes earlier reported
`3 plugin schema(s): atelier, kiosk, music`. Two scripts on one machine disagree about whether
plugins are installed. Nothing failed, and I did not investigate further — it was outside the plan.
Note also that `kiosk` appears on no other host.
## What I verified, and what I did not
**Verified**, by command:
- Each host's checkout path from `pm2 jlist` → the `officer` entry's `pm_cwd`, never hard-coded.
- Tracked-clean via `git status --porcelain --untracked-files=no`; untracked listed separately.
- `HEAD` before and after, by `git rev-parse`, equal to the tag on all five.
- Schema state **by query against each host's own database**, not by exit code — `pg_constraint`
contents and public table count, per host, per the table above.
- Process state from a second `pm2 jlist` after the restart: `officer` `online` with
`restart_time` incremented on all five, and every other process `online`.
- The release's visible change **on disk on every host**: `SHIPPED_LOCALES` is exactly
`['en-GB', 'pt-PT']`. Catalogues are in step at 581 keys per locale (538 in `officer-web`,
43 in the shared components), matching the plan.
**Not verified:**
- **The language picker was not opened in a browser on any host.** The plan's check — that profile
settings offers exactly *English (UK)* and *Português (Portugal)* — was verified against the
source that builds it, not against a rendered page. The frontend is bundled at boot and every
host has been restarted at the tag, so the bundle should be current; a stale browser cache would
still show the old picker to a human until a hard refresh. Someone should click it once.
- No application-level smoke test beyond process health. `POST /api/sub-agents/spawn` was not
called on any host.
+11 -2
View File
@@ -131,7 +131,7 @@ provisioning script that adds a user to a privileged group is granting more than
Additive only. Nothing reads it.
### Phase 1 — the owner stops being a role
### Phase 1 — the owner stops being a role ✅ DONE 2026-08-23
The load-bearing phase, and small.
@@ -140,7 +140,16 @@ The load-bearing phase, and small.
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.
4. ~~Remove the `users` CHECK that pins user 1 to `Super Admin`~~ — **KEPT, overruling this plan.** It is
redundant for the question it was written for: `isOwner` is an id comparison and never reads that column.
But it still forbids user 1 being set to `Member` while remaining the owner, which is a state with no
benefit and a confusing display. A constraint that costs nothing and forbids a nonsense state is worth
keeping after its original reason has gone.
**One asymmetry introduced deliberately.** An existing account can now be promoted to `Super Admin`;
creating one directly as `Super Admin` is still refused by `create-user.ts`. Promotion happens to an account
somebody has looked at, from a screen showing who they are. Creation is a form filled in once, and the role
that provisions sudo should not be a dropdown default reached by accident. Create, then promote.
Reviewable on its own, and nothing downstream can start until ownership is an identity.
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
import { Untranslated } from '@/lib/Untranslated';
import { Untranslated } from '@/components/Untranslated';
type GoogleOAuthSettings = {
clientId: string;
+31 -17
View File
@@ -62,8 +62,11 @@ check(
'ck_server_integrations_provider',
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
sql`${table.provider} IN (${sql.join(SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)), sql`, `)})`,
)
sql`${table.provider} IN (${sql.join(
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
sql`, `,
)})`,
);
```
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
@@ -77,7 +80,7 @@ drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back fro
them against a schema that declares exactly the same name, columns and order, decides they differ, and
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
table is a data-risk statement, so push stopped on an interactive *"do you want to truncate?"* prompt
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
that could never be resolved by answering it.
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
@@ -87,7 +90,7 @@ stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
requires a unique *constraint* there. Nothing here has a composite foreign-key target; check before
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
adding one.
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
@@ -99,22 +102,33 @@ longer version and re-creates the constraint on every push.
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
table, data unaffected. Naming it explicitly does not help. Leave it.
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
**Four foreign keys are over 63 characters and churn the same way**, found 2026-08-19 while pushing
`chat_notification_prefs`. These are the case the paragraph above says to name explicitly, and none of
them is:
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
follow.
command_hq_session_tags_session_row_id_command_hq_sessions_id_fk 64
command_hq_watcher_fires_session_row_id_command_hq_sessions_id_fk 65
bitcoin_wallet_frozen_utxos_wallet_id_bitcoin_wallet_wallets_id_fk 66
bitcoin_wallet_chain_cache_wallet_id_bitcoin_wallet_wallets_id_fk 65
The six, and where each lives:
The tell is in the plan itself: the `DROP` names them at 63 characters and the `ADD` at their full
length, which is Postgres's truncation and drizzle's copy disagreeing. Eight statements per push, no
prompt, data unaffected — deferred rather than fixed (see `TODO.md`), because it is noise rather than
a fault. Two live in `officer_db/src/command-hq/schema.ts`; the other two are the bitcoin-wallet
plugin's, in its own repository.
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
the three-argument one, because there was nowhere else to put the declaration.
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
rather than the truncation one, and there is no index form to escape to because a primary key must be a
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
is a far more useful baseline than ten, because anything else really is your change.
**Rules that still apply:**
+11 -7
View File
@@ -85,14 +85,18 @@ export async function getUserByUsername(username: string): Promise<UserSelect |
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
// sidecar refusing to start beats it silently running as the wrong person.
//
// `order by id` is not cosmetic. Without it, two rows holding the role would make "who owns this
// server" whatever Postgres happened to return first — and that answer feeds the agent sidecar's
// identity, the vault and origin scoping. The write paths refuse to create a second Super Admin
// (create-user.ts and updateUserRoleHandler), so this should never have a choice to make; ordering is
// what makes the outcome deterministic if one ever gets in by another route, and id 1 is the bootstrap
// account the CHECK constraint already pins.
// The owner is `OWNER_USER_ID`, so there is nothing to disambiguate and no ordering to get right.
//
// This used to select on `role = 'Super Admin'` with `order by id` as a tiebreak, because two rows could
// hold the role and "who owns this server" would otherwise be whatever Postgres returned first — an answer
// that feeds the agent sidecar's identity, the vault and origin scoping. The write paths refused a second
// Super Admin to keep it single.
//
// Since 2026-08-23 ownership is an identity, so several accounts may hold `Super Admin` and none of them
// becomes the owner by doing so. The tiebreak, the refusals and the CHECK that pinned user 1's role all
// existed to protect an answer that is now a primary key.
export async function getOwnerUser(): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).orderBy(users.id).limit(1);
const [user] = await db.select().from(users).where(eq(users.id, OWNER_USER_ID)).limit(1);
return user;
}
@@ -93,6 +93,15 @@ export const users = pgTable(
// "some row must be Super Admin" — that would need to see other rows. So this pins the bootstrap
// account and nothing more; promoting or demoting anyone else is still free.
//
// KEPT after ownership stopped being a role, 2026-08-23, and `docs/roles-and-teams.md` phase 1 said to
// remove it as redundant. It is redundant for the question it was written for — `isOwner` is now an id
// comparison and does not read this column at all — but it still earns its place for a different one:
// it stops user 1 being set to `Member` while remaining the owner, which is a state with no benefit and
// a confusing display. A constraint that costs nothing and forbids a nonsense state is worth keeping
// even after the reason it was added has gone.
//
// What it must NOT be mistaken for is the thing that makes user 1 the owner. It does not, any more.
//
// It cannot stop a DELETE either. deleteUser() in queries/auth.ts refuses id 1 for that half.
//
// sql.raw for the literal — an interpolated JS string binds as `$1`, and Postgres will not accept a
@@ -8,6 +8,7 @@ import {
text,
timestamp,
uniqueIndex,
foreignKey,
} from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
@@ -145,14 +146,23 @@ export const commandHqWatcherFires = pgTable(
'command_hq_watcher_fires',
{
id: serial('id').primaryKey(),
sessionRowId: integer('session_row_id')
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
sessionRowId: integer('session_row_id').notNull(),
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
taskId: text('task_id').notNull(),
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt)],
(table) => [
// Named, because drizzle's generated name is 65 characters and Postgres truncates identifiers at 63 —
// so it stores the short form, drizzle keeps comparing against its own long one, and every push drops
// and re-adds an identical constraint. Documented in src/databases/CLAUDE.md and left unfixed until a
// deploy plan said "expect no changes" and this made that unsatisfiable on all five hosts.
foreignKey({
name: 'fk_command_hq_watcher_fires_session',
columns: [table.sessionRowId],
foreignColumns: [commandHqSessions.id],
}).onDelete('cascade'),
index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt),
],
);
/**
@@ -198,14 +208,20 @@ export const commandHqTags = pgTable(
export const commandHqSessionTags = pgTable(
'command_hq_session_tags',
{
sessionRowId: integer('session_row_id')
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
sessionRowId: integer('session_row_id').notNull(),
tagId: integer('tag_id')
.notNull()
.references(() => commandHqTags.id, { onDelete: 'cascade' }),
},
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
(table) => [
// 64 characters generated; see the note on `fk_command_hq_watcher_fires_session`.
foreignKey({
name: 'fk_command_hq_session_tags_session',
columns: [table.sessionRowId],
foreignColumns: [commandHqSessions.id],
}).onDelete('cascade'),
uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId),
],
);
/**
+10 -1
View File
@@ -28,7 +28,16 @@ import { toPublicUser } from './manage-users';
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
// UI is verified by definition — the owner is the verification.
/** Roles this route may assign. Never 'Super Admin' — see below. */
/**
* Roles this route may assign. Never 'Super Admin', still — and that is now an asymmetry worth naming.
*
* Since 2026-08-23 an existing account CAN be promoted to Super Admin (`updateUserRoleHandler` no longer
* refuses it), because ownership stopped being carried by the role. Creation deliberately did not follow.
*
* The two are different acts. Promotion happens to an account somebody has looked at, from a screen showing
* who they are; creation is a form filled in once, and the role that provisions an OS account with sudo is
* not a dropdown default anybody should reach by accident. Create, then promote.
*/
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
export const createUserHandler: Handler = async function (ctx) {
+13 -8
View File
@@ -77,14 +77,19 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
throw errors.FORBIDDEN('The server owner cannot be demoted.');
}
// And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
// second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
// owner by that role, so two holders make "who owns this server" a question the query plan answers.
// It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
// server over is a deliberate act, not a dropdown.
if (id !== OWNER_USER_ID && role === 'Super Admin') {
throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
}
/*
A second Super Admin is allowed since 2026-08-23, and the refusal that used to live here is gone.
It existed because `getOwnerUser()` resolved the owner BY that role, so two holders made "who owns this
server" a question the query plan answered — and that answer decided the agent sidecar's identity, vault
access and which origin is privileged. Ownership is now `OWNER_USER_ID`, so the role no longer carries
it and several accounts may hold it.
What the role does carry is real and worth saying out loud in the UI rather than here: it provisions an
OS account with sudo. That puts the holder outside Officer's permission system entirely and cannot be
undone by revoking the role afterwards. Granting it is handing over the machine — which is the operator's
decision to make, and no longer one this endpoint refuses on their behalf.
*/
const existing = await getUserById(id);
if (!existing) throw errors.NOT_FOUND('User not found');
+24 -4
View File
@@ -1,4 +1,4 @@
import { getUserById } from 'officerdb';
import { getUserById, OWNER_USER_ID } from 'officerdb';
// Identifies the platform owner. The user's `role` column IS the answer — there is no second mechanism
// and no environment variable.
@@ -13,10 +13,30 @@ import { getUserById } from 'officerdb';
// are editable, and a cache with no invalidation contract is a staleness bug waiting for whoever builds
// the role UI. This is one primary-key lookup on a request that has already verified a JWT.
export async function isSuperAdmin(payload: { id?: number } | null | undefined): Promise<boolean> {
if (!payload?.id) return false;
return isOwner(payload);
}
/**
* The owner is an IDENTITY, not a role. `OWNER_USER_ID` and nothing else.
*
* This asked `user.role === 'Super Admin'` until 2026-08-23, which was correct while roles were a hardcoded
* TypeScript union and nothing could write a new one. The moment roles became a table it became a
* privilege-escalation path: "add a role" and "become the owner" are the same feature, reachable from
* whatever UI manages roles.
*
* So the question moved to one nothing in the product can answer for itself. `Super Admin` is still a role
* and several accounts may hold it — it provisions an OS account with sudo, which is its own decision — but
* holding it is not being the owner and no role grants that.
*
* Kept as a database read rather than a bare comparison, deliberately. A deleted or blocked account must not
* still be the owner because its id happens to match, and this is one primary-key lookup on a request that
* has already verified a JWT.
*/
export async function isOwner(payload: { id?: number } | null | undefined): Promise<boolean> {
if (payload?.id !== OWNER_USER_ID) return false;
try {
const user = await getUserById(payload.id);
return user?.role === 'Super Admin';
const user = await getUserById(OWNER_USER_ID);
return !!user;
} catch {
// A transient database error must never promote anyone. Deny, and let the next call retry.
return false;
@@ -2,15 +2,22 @@ import type { ReactNode } from 'react';
type UntranslatedProps = {
children: ReactNode;
/** Why this text is not localized. Required so the decision is legible at the call site. */
reason: 'foreign-ui-label';
/**
* Why this text is not localized. Required so the decision is legible at the call site.
*
* `foreign-ui-label` — quoted from another product's interface ("click **Enable**"). It has to match
* what the reader sees on THEIR screen, and that product localizes by its own account language.
* `product-name` — the name of a product or feature: Tmux, Neovim, Claude Code, Officer. The doc's
* never-translate list covers these directly.
*/
reason: 'foreign-ui-label' | 'product-name';
className?: string;
};
/**
* Text that is deliberately NOT localized, marked as such where it is written.
*
* The only reason so far is `foreign-ui-label`: a label quoted from another product's interface, which
* The first reason was `foreign-ui-label`: a label quoted from another product's interface, which
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
* what the reader sees on Google's screen, and Google localises by their Google account language rather
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
+5
View File
@@ -2,6 +2,7 @@ import { createContext, useMemo } from 'react';
import type { ReactNode } from 'react';
import type { I18nContextValue, InterpolationParams, TranslationMap } from './types';
import { interpolate, resolveKey } from './resolve';
import { setActiveI18n } from './standalone';
export const I18nContext = createContext<I18nContextValue>({
t: (key: string) => key,
@@ -17,6 +18,10 @@ type I18nProviderProps = {
};
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
// Mirrored for the one caller that cannot use a hook — see standalone.ts. Set during render rather
// than in an effect so it is never a render behind what this provider is handing out.
setActiveI18n({ translations, locale, fallbackLocale });
const value = useMemo<I18nContextValue>(() => {
const t = (key: string, params?: InterpolationParams) => {
const count = typeof params?.count === 'number' ? params.count : undefined;
+2
View File
@@ -3,5 +3,7 @@ export { useTranslation } from './useTranslation';
export { useFormat } from './useFormat';
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
export { findMissingKeys } from './parity';
// Deliberately NOT re-exporting translateStandalone — see standalone.ts on why it is awkward to reach.
export { activeLocale } from './standalone';
export type { MissingKey } from './parity';
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
+31
View File
@@ -0,0 +1,31 @@
import type { InterpolationParams, TranslationMap } from './types';
import { interpolate, resolveKey } from './resolve';
/**
* The active locale, readable OUTSIDE a React render.
*
* There is exactly one caller and it is not a shortcut: a panel's name in the app registry is
* module-level data, and the framework invokes its thunk from a render where no hook of ours is
* reachable — `workspaces` has no dependencies at all, deliberately, not even this package.
*
* NOT a general escape hatch. Anything inside a component uses `useTranslation`, which is reactive.
* This is not reactive: writing here re-renders nothing. It is only correct where the caller is
* already re-rendering for another reason, which is why `translateStandalone` is deliberately awkward
* to reach and documented rather than exported from the package barrel.
*/
type ActiveI18n = { translations: TranslationMap; locale: string; fallbackLocale: string };
let active: ActiveI18n = { translations: {}, locale: 'en', fallbackLocale: 'en' };
/** Called by `I18nProvider` on every render, so this never lags the provider it mirrors. */
export const setActiveI18n = (next: ActiveI18n): void => {
active = next;
};
export const translateStandalone = (key: string, params?: InterpolationParams): string => {
const count = typeof params?.count === 'number' ? params.count : undefined;
return interpolate(resolveKey(active.translations, active.locale, key, active.fallbackLocale, count), params);
};
/** The locale the provider currently holds. Exposed so a caller can key a render on it. */
export const activeLocale = (): string => active.locale;
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router';
import { Loader2, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore';
import { useTranslation } from '../../locales';
// One entry: what it is, how to install it, and what to do about it once installed.
//
@@ -41,6 +42,7 @@ function Field({ field, value, onChange }: { field: ConfigField; value: string;
}
export const AppStoreDetail = () => {
const { t } = useTranslation();
const [params] = useSearchParams();
const id = params.get('selected');
const { items, install, setEnabled, uninstall } = useAppStore();
@@ -51,7 +53,11 @@ export const AppStoreDetail = () => {
const [log, setLog] = useState<string[]>([]);
if (!item) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select an app</div>;
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t('appStore.selectApp')}
</div>
);
}
// `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" —
@@ -82,13 +88,13 @@ export const AppStoreDetail = () => {
person. `completeAt` links to the page that mints the token. */}
{item.install.status === 'blocked' && item.install.lastError && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<div className="font-medium">Needs you</div>
<div className="font-medium">{t('appStore.needsYou')}</div>
<p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
</div>
)}
{item.install.status === 'failed' && item.install.lastError && (
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm">
<div className="font-medium">Install failed</div>
<div className="font-medium">{t('appStore.installFailed')}</div>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
</div>
)}
@@ -97,7 +103,7 @@ export const AppStoreDetail = () => {
<div className="flex flex-col gap-3">
<div className="text-sm text-muted-foreground">
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '}
{item.install.enabled ? 'enabled' : 'disabled'}
{item.install.enabled ? t('appStore.enabled') : t('appStore.disabled')}
{item.processStatus && ` · process ${item.processStatus}`}
</div>
<div className="flex gap-2">
@@ -106,17 +112,14 @@ export const AppStoreDetail = () => {
disabled={setEnabled.isPending}
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })}
>
{item.install.enabled ? 'Disable' : 'Enable'}
{item.install.enabled ? t('appStore.disable') : t('appStore.enable')}
</Button>
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
Uninstall
{t('appStore.uninstall')}
</Button>
</div>
{/* Said plainly, because it is the question anyone hesitates over before clicking. */}
<p className="text-xs text-muted-foreground">
Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data,
configuration and this app’s tables are kept either way.
</p>
<p className="text-xs text-muted-foreground">{t('appStore.removalNote')}</p>
</div>
) : (
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it.
@@ -1,6 +1,7 @@
import { Link, useSearchParams } from 'react-router';
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
import { useAppStore, type StoreItem } from './useAppStore';
import { useTranslation } from '../../locales';
// The list of everything installable, with what has happened to each.
//
@@ -10,26 +11,31 @@ import { useAppStore, type StoreItem } from './useAppStore';
/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
function StatusBadge({ item }: { item: StoreItem }) {
const { t } = useTranslation();
const { status, enabled } = item.install;
if (status === 'not-installed') return null;
if (status === 'installing') {
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label="installing" />;
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label={t('appStore.installing')} />;
}
if (status === 'failed') return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label="failed" />;
if (status === 'failed')
return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label={t('appStore.failed')} />;
// Blocked is not a failure: everything worked and it is waiting for something only a person can give.
if (status === 'blocked') return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label="needs you" />;
if (!enabled) return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label="disabled" />;
if (status === 'blocked')
return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label={t('appStore.needsYouShort')} />;
if (!enabled)
return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label={t('appStore.disabled')} />;
// Installed and enabled, but the process is not running — the case worth surfacing rather than
// showing a tick that lies.
if (item.processStatus && item.processStatus !== 'online') {
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />;
}
return <Check className="h-3.5 w-3.5 text-success" aria-label="installed" />;
return <Check className="h-3.5 w-3.5 text-success" aria-label={t('appStore.installedIcon')} />;
}
export const AppStoreList = () => {
const { t } = useTranslation();
const { items, isLoading, error } = useAppStore();
const [params] = useSearchParams();
const selected = params.get('selected');
@@ -37,14 +43,14 @@ export const AppStoreList = () => {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('appStore.loading')}
</div>
);
}
if (error) {
// Distinguished from "nothing installable", which would otherwise look identical and is not a
// problem the user can act on.
return <div className="p-4 text-sm text-destructive">Could not load the app store.</div>;
return <div className="p-4 text-sm text-destructive">{t('appStore.loadFailed')}</div>;
}
const installed = items.filter((i) => i.install.status !== 'not-installed');
@@ -66,13 +72,17 @@ export const AppStoreList = () => {
<div className="flex h-full flex-col overflow-y-auto p-2">
{installed.length > 0 && (
<>
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Installed</div>
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('appStore.installed')}
</div>
{installed.map((item) => (
<Row key={item.id} item={item} />
))}
</>
)}
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</div>
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('appStore.available')}
</div>
{available.map((item) => (
<Row key={item.id} item={item} />
))}
@@ -2,6 +2,7 @@ import { Store } from 'lucide-react';
import type { AppRegistryMeta } from '../../AppRegistry';
import { AppStoreList } from './AppStoreList';
import { AppStoreDetail } from './AppStoreDetail';
import { panelName } from '../../locales';
export { AppStoreList } from './AppStoreList';
export { AppStoreDetail } from './AppStoreDetail';
@@ -12,6 +13,18 @@ export type { StoreItem, InstallMode, ConfigField } from './useAppStore';
// side by side. `availableOnPanel: false` keeps them off the generic panel picker — they only make
// sense together, on /app-store.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'app-store-list', name: 'App store', icon: Store, component: AppStoreList, availableOnPanel: false },
{ key: 'app-store-detail', name: 'App detail', icon: Store, component: AppStoreDetail, availableOnPanel: false },
{
key: 'app-store-list',
name: panelName('panelName.appStore'),
icon: Store,
component: AppStoreList,
availableOnPanel: false,
},
{
key: 'app-store-detail',
name: panelName('panelName.appDetail'),
icon: Store,
component: AppStoreDetail,
availableOnPanel: false,
},
];
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
import { MessageSquare } from 'lucide-react';
import { ChatPanelWrapper } from './ChatPanelWrapper';
import { ChatHeader } from './ChatHeader';
import { panelName } from '../../locales';
export { MessageList } from './components/MessageList';
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
@@ -26,7 +27,7 @@ export * from './types';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/chat',
name: 'Chat',
name: panelName('panelName.chat'),
icon: MessageSquare,
component: ChatPanelWrapper,
header: ChatHeader,
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
import { MessageSquare, List } from 'lucide-react';
import { SessionList } from './SessionList';
import { ChatDetailPanel } from './ChatDetailPanel';
import { panelName } from '../../locales';
export { SessionList };
export { ChatDetailPanel };
@@ -11,14 +12,14 @@ export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'chat-session-list',
name: 'Sessions',
name: panelName('panelName.chatSessions'),
icon: List,
component: SessionList,
availableOnPanel: false,
},
{
key: 'chat-detail',
name: 'Chat',
name: panelName('panelName.chat'),
icon: MessageSquare,
component: ChatDetailPanel,
availableOnPanel: false,
@@ -13,6 +13,7 @@ import { useFilesRefresh } from '../../channels';
import { useWorkspace } from '../../components/Workspace';
import { useEditorTabs } from './use-editor-tabs';
import { configureMonacoTypescript } from './monaco-setup';
import { useTranslation } from '../../locales';
type CodeEditorViewProps = {
className?: string;
@@ -103,6 +104,7 @@ export const CodeEditorView = ({
urlState,
panelId,
}: CodeEditorViewProps) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
const { restored, remember } = useEditorTabs(panelId, workspace);
const {
@@ -138,7 +140,7 @@ export const CodeEditorView = ({
const res = await readFile(path);
openFile(path, name, res.content);
} catch {
toast.error('Failed to read file');
toast.error(t('codeEditor.readFailed'));
}
};
@@ -159,7 +161,7 @@ export const CodeEditorView = ({
.then((res) => alive && openFile(path, path.split('/').pop() ?? path, res.content))
.catch(() => {
unreadable.current.add(path);
if (alive) toast.error('Failed to read file');
if (alive) toast.error(t('codeEditor.readFailed'));
});
return () => {
alive = false;
@@ -273,9 +275,9 @@ export const CodeEditorView = ({
try {
await writeFile(activeFile.path, activeFile.content);
markSaved(activeFile.path, activeFile.content);
toast.success('File saved');
toast.success(t('codeEditor.saved'));
} catch {
toast.error('Failed to save file');
toast.error(t('codeEditor.saveFailed'));
}
};
@@ -322,10 +324,10 @@ export const CodeEditorView = ({
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider flex items-center justify-between gap-2"
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
>
<span>Explorer</span>
<span>{t('codeEditor.explorer')}</span>
<button
onClick={() => setManualRefresh((n) => n + 1)}
title="Refresh the tree and reload unmodified open files"
title={t('codeEditor.refresh')}
className="p-0.5 rounded hover:bg-white/10 transition-colors cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5" />
@@ -383,7 +385,7 @@ export const CodeEditorView = ({
/>
) : (
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
Open a file from the explorer to start editing
{t('codeEditor.openAFile')}
</div>
)}
</div>
@@ -1,12 +1,14 @@
import { Code } from 'lucide-react';
import { useWorkspace } from '../../components/Workspace';
import { useTranslation } from '../../locales';
export const CodeEditorHeader = () => {
const { t } = useTranslation();
const { cwd } = useWorkspace();
return (
<>
<Code className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Code Editor</span>
<span className="text-xs font-medium shrink-0">{t('codeEditor.title')}</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
@@ -2,6 +2,7 @@ import { Link } from 'react-router';
import { X, FileX } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import type { OpenFile } from './useEditorState';
import { useTranslation } from '../../locales';
type EditorTabsProps = {
files: OpenFile[];
@@ -19,6 +20,7 @@ const FileIcon = ({ name }: { name: string }) => {
};
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, searchForFile }: EditorTabsProps) => {
const { t } = useTranslation();
if (files.length === 0) return null;
const isDark = theme !== 'vs';
@@ -75,7 +77,7 @@ export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, search
)}
<button
onClick={() => onClose(file.path)}
aria-label={`Close ${file.name}`}
aria-label={t('codeEditor.closeFile', { name: file.name })}
className="p-0.5 rounded cursor-pointer transition-colors hover:bg-white/10"
style={{ color: inactiveColor }}
>
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { useTranslation } from '../../locales';
/**
* `searchForFile` turns file rows into real links when the editor owns the address bar. Folder rows stay
@@ -148,6 +149,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
};
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => {
const { t } = useTranslation();
const { listDir } = useFilesAPI(root);
const [entries, setEntries] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -187,11 +189,11 @@ export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshTok
}
if (loading && entries === null) {
return <div className="p-2 text-sm text-[#888]">Loading...</div>;
return <div className="p-2 text-sm text-[#888]">{t('codeEditor.loading')}</div>;
}
if (!entries || entries.length === 0) {
return <div className="p-2 text-sm text-[#888]">No files</div>;
return <div className="p-2 text-sm text-[#888]">{t('codeEditor.noFiles')}</div>;
}
return (
@@ -2,13 +2,14 @@ import type { AppRegistryMeta } from '../../AppRegistry';
import { Code } from 'lucide-react';
import { CodeEditorPanelWrapper } from './CodeEditorPanelWrapper';
import { CodeEditorHeader } from './CodeEditorHeader';
import { panelName } from '../../locales';
export { CodeEditorView } from './CodeEditor';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/code-editor',
name: 'Code Editor',
name: panelName('panelName.codeEditor'),
icon: Code,
component: CodeEditorPanelWrapper,
header: CodeEditorHeader,
@@ -15,25 +15,26 @@ export type {
CommandHqTurn,
CommandHqFire,
} from './types';
import { panelName } from '../../locales';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'command-hq-categories',
name: 'Categories',
name: panelName('panelName.categories'),
icon: Radar,
component: CommandHqCategories,
availableOnPanel: false,
},
{
key: 'command-hq-grid',
name: 'Commanders',
name: panelName('panelName.commanders'),
icon: LayoutGrid,
component: CommandHqGrid,
availableOnPanel: false,
},
{
key: 'command-hq-detail',
name: 'Commander',
name: panelName('panelName.commander'),
icon: MessageSquare,
component: CommandHqDetail,
availableOnPanel: false,
@@ -26,8 +26,10 @@ import {
} from './constants';
import { useNewDashboardDraft } from './useNewDashboardDraft';
import { cwdToPath } from '../../components/Workspace';
import { useTranslation } from '../../locales';
export const DashboardListApp = () => {
const { t, tUi } = useTranslation();
const location = useLocation();
const client = useClient();
const queryClient = useQueryClient();
@@ -105,7 +107,7 @@ export const DashboardListApp = () => {
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
>
<LayoutGrid className="h-4 w-4" />
Dashboards
{t('dashboards.title')}
</Link>
<button
type="button"
@@ -113,7 +115,7 @@ export const DashboardListApp = () => {
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-white transition-all cursor-pointer"
>
<Plus className="h-4 w-4 shrink-0" />
New Dashboard
{t('dashboards.new')}
</button>
</div>
<div className="relative px-3 pt-2">
@@ -122,7 +124,7 @@ export const DashboardListApp = () => {
type="text"
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search dashboards"
placeholder={t('dashboards.search')}
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40"
/>
</div>
@@ -182,15 +184,15 @@ export const DashboardListApp = () => {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete dashboard</AlertDialogTitle>
<AlertDialogTitle>{t('dashboards.delete')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
{t('dashboards.confirmDelete')} <strong>{deleting?.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
Delete
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -19,21 +19,23 @@ import {
NEW_DASH_DESC_KEY,
NEW_DASH_TEMPLATE_KEY,
} from './constants';
import type { PanelTranslationKey } from '../../locales';
import { panelName, useTranslation } from '../../locales';
// --- Layout Templates ---
type LayoutTemplate = {
name: string;
nameKey: PanelTranslationKey;
layout: () => LayoutNode;
};
const templates: LayoutTemplate[] = [
{
name: 'Single',
nameKey: 'dashboards.templates.single',
layout: () => ({ type: 'panel', id: uid(), appType: null }),
},
{
name: '2 Columns',
nameKey: 'dashboards.templates.twoColumns',
layout: () => ({
type: 'group',
id: uid(),
@@ -45,7 +47,7 @@ const templates: LayoutTemplate[] = [
}),
},
{
name: 'Main + Side',
nameKey: 'dashboards.templates.mainSide',
layout: () => ({
type: 'group',
id: uid(),
@@ -68,7 +70,7 @@ const templates: LayoutTemplate[] = [
}),
},
{
name: 'Sidebar',
nameKey: 'dashboards.templates.sidebar',
layout: () => ({
type: 'group',
id: uid(),
@@ -80,7 +82,7 @@ const templates: LayoutTemplate[] = [
}),
},
{
name: '2x2 Grid',
nameKey: 'dashboards.templates.grid',
layout: () => ({
type: 'group',
id: uid(),
@@ -114,7 +116,7 @@ const templates: LayoutTemplate[] = [
}),
},
{
name: 'Cols + Bottom',
nameKey: 'dashboards.templates.colsBottom',
layout: () => ({
type: 'group',
id: uid(),
@@ -157,6 +159,7 @@ const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
};
const TemplateCell = ({ index }: { index: number }) => {
const { t } = useTranslation();
const tpl = templates[index]!;
const [selected, setSelected] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
const node = tpl.layout();
@@ -174,7 +177,9 @@ const TemplateCell = ({ index }: { index: number }) => {
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
<ThumbnailNode node={node} />
</div>
<span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>{tpl.name}</span>
<span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>
{t(tpl.nameKey)}
</span>
</button>
</div>
);
@@ -219,7 +224,7 @@ const tplPanelLayout: LayoutNode = {
const tplRegistry = Object.fromEntries(
templates.map((tpl, i) => [
`tpl-${i}`,
{ name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> },
{ name: panelName(tpl.nameKey), icon: Layout, component: () => <TemplateCell index={i} /> },
]),
);
@@ -230,6 +235,7 @@ const TemplatePanel = () => (
// --- Panel: Name ---
const NamePanel = () => {
const { t } = useTranslation();
const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
const [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
@@ -239,13 +245,13 @@ const NamePanel = () => {
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<Type className="h-3 w-3" />
Name
{t('dashboards.name')}
</label>
<input
type="text"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="My Dashboard"
placeholder={t('dashboards.namePlaceholder')}
autoFocus
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
@@ -253,12 +259,12 @@ const NamePanel = () => {
<div className="flex flex-1 min-h-0 flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<FileText className="h-3 w-3" />
Description
{t('dashboards.description')}
</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="What is this dashboard for?"
placeholder={t('dashboards.descriptionPlaceholder')}
className="flex-1 min-h-0 resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
</div>
@@ -270,6 +276,7 @@ const NamePanel = () => {
// --- Panel: Create ---
const CreatePanel = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const client = useClient();
@@ -401,7 +408,7 @@ const CreatePanel = () => {
<Button asChild variant="outline" className="cursor-pointer">
<Link to={`/dashboards/${editingId}`}>
<ArrowRight className="h-4 w-4 mr-1" />
Open Dashboard
{t('dashboards.open')}
</Link>
</Button>
)}
@@ -453,13 +460,14 @@ const newDashLayout: LayoutNode = {
};
const NewDashboardForm = () => {
const { t } = useTranslation();
const { registry } = useAppRegistry();
const newDashRegistry = {
...registry,
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
'file-browser-cwd': { name: panelName('panelName.fileBrowser'), icon: FolderOpen, component: CwdFileBrowser },
'new-ws-name': { name: t('dashboards.name'), icon: Type, component: NamePanel },
'new-ws-template': { name: t('dashboards.template'), icon: Layout, component: TemplatePanel },
'new-ws-create': { name: t('dashboards.create'), icon: Rocket, component: CreatePanel },
};
return <WorkspaceLayout layout={newDashLayout} onLayoutChange={() => {}} registry={newDashRegistry} noHeader />;
@@ -470,6 +478,7 @@ const NewDashboardForm = () => {
// Only reached when nothing is selected AND nothing is being created or edited — DashboardPreview checks
// those first now, so this component no longer needs its own creating/editing branch.
const DashboardPreviewEmpty = () => {
const { t } = useTranslation();
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
@@ -488,10 +497,10 @@ const DashboardPreviewEmpty = () => {
<div className="h-full w-full">
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
<LayoutGrid className="h-10 w-10 text-white/80" />
<p className="text-sm text-white/80">Select a dashboard or create a new one</p>
<p className="text-sm text-white/80">{t('dashboards.selectOne')}</p>
<Button onClick={handleCreate} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
<Plus className="h-4 w-4 mr-1" />
New Dashboard
{t('dashboards.new')}
</Button>
</div>
</div>
@@ -2,23 +2,30 @@ import type { AppRegistryMeta } from '../../AppRegistry';
import { LayoutGrid } from 'lucide-react';
import { DashboardListApp } from './DashboardListApp';
import { DashboardPreview } from './DashboardPreview';
import { panelName } from '../../locales';
export { DashboardListApp };
export { DashboardPreview };
export { CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './constants';
export {
CREATING_DASHBOARD_KEY,
EDITING_DASHBOARD_KEY,
NEW_DASH_NAME_KEY,
NEW_DASH_DESC_KEY,
NEW_DASH_TEMPLATE_KEY,
} from './constants';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'dashboard-list',
name: 'Dashboards',
name: panelName('panelName.dashboards'),
icon: LayoutGrid,
component: DashboardListApp
component: DashboardListApp,
},
{
key: 'dashboard-preview',
name: 'Dashboard Preview',
name: panelName('panelName.dashboardPreview'),
icon: LayoutGrid,
component: DashboardPreview,
availableOnPanel: false
availableOnPanel: false,
},
];
@@ -1,6 +1,7 @@
import type { CalendarEvent } from './shared';
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
import { useCalendarEvents, useSelectedCollection } from './useDavData';
import { useTranslation } from '../../locales';
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
@@ -26,12 +27,13 @@ const formatTime = (ev: CalendarEvent) => {
};
export const CalendarView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
if (error) return <Placeholder text={`Could not read ${selected.displayName}: ${String(error)}`} />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading events…" />;
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
@@ -4,6 +4,7 @@ import { Link, useSearchParams } from 'react-router';
import { CalendarDays, Contact as ContactIcon } from 'lucide-react';
import { davCollectionPath, COLLECTION_PARAM } from './shared';
import { useDavCollections } from './useDavData';
import { useTranslation } from '../../locales';
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
//
@@ -22,6 +23,7 @@ type Props = {
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
const { t } = useTranslation();
const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM);
@@ -69,19 +71,19 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
);
})}
{!isLoading && collections.length === 0 && (
<p className="px-3 py-2 text-xs text-muted-foreground">
Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.
</p>
<p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
)}
</nav>
</div>
);
};
export const CalendarNav = () => (
<CollectionNav kind="calendar" base="/calendar" title="Calendar" icon={CalendarDays} />
);
export const CalendarNav = () => {
const { t } = useTranslation();
return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
};
export const ContactsNav = () => (
<CollectionNav kind="addressbook" base="/contacts" title="Contacts" icon={ContactIcon} />
);
export const ContactsNav = () => {
const { t } = useTranslation();
return <CollectionNav kind="addressbook" base="/contacts" title={t('dav.contacts')} icon={ContactIcon} />;
};
@@ -1,5 +1,6 @@
import { Building2, Mail, Phone, User } from 'lucide-react';
import { useDavContacts, useSelectedCollection } from './useDavData';
import { useTranslation } from '../../locales';
// Right panel of /contacts: the selected address book as cards, sorted by name.
//
@@ -16,12 +17,13 @@ const initials = (name: string) =>
.join('') || '?';
export const ContactsView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading address books…" />;
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
if (error) return <Placeholder text={`Could not read ${selected.displayName}: ${String(error)}`} />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading contacts…" />;
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
@@ -3,6 +3,7 @@ import { CalendarDays, Contact as ContactIcon, PanelLeft } from 'lucide-react';
import { CalendarNav, ContactsNav } from './CollectionNav';
import { CalendarView } from './CalendarView';
import { ContactsView } from './ContactsView';
import { panelName } from '../../locales';
// One app folder for both /calendar and /contacts: they are the same DAV sidecar, the same collection
// list and the same selection rule, differing only in which kind of collection they show.
@@ -10,8 +11,32 @@ import { ContactsView } from './ContactsView';
export { CalendarNav, ContactsNav, CalendarView, ContactsView };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'calendar-nav', name: 'Calendar', icon: PanelLeft, component: CalendarNav, availableOnPanel: false },
{ key: 'calendar-view', name: 'Calendar', icon: CalendarDays, component: CalendarView, availableOnPanel: false },
{ key: 'contacts-nav', name: 'Contacts', icon: PanelLeft, component: ContactsNav, availableOnPanel: false },
{ key: 'contacts-view', name: 'Contacts', icon: ContactIcon, component: ContactsView, availableOnPanel: false },
{
key: 'calendar-nav',
name: panelName('panelName.calendar'),
icon: PanelLeft,
component: CalendarNav,
availableOnPanel: false,
},
{
key: 'calendar-view',
name: panelName('panelName.calendar'),
icon: CalendarDays,
component: CalendarView,
availableOnPanel: false,
},
{
key: 'contacts-nav',
name: panelName('panelName.contacts'),
icon: PanelLeft,
component: ContactsNav,
availableOnPanel: false,
},
{
key: 'contacts-view',
name: panelName('panelName.contacts'),
icon: ContactIcon,
component: ContactsView,
availableOnPanel: false,
},
];
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
import { FolderOpen } from 'lucide-react';
import { FileBrowserPanelWrapper } from './FileBrowserApp';
import { FileBrowserWidget } from './FileBrowserWidget';
import { panelName } from '../../locales';
export { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
export { useTasks, type TaskSummary } from './useTasks';
@@ -14,13 +15,13 @@ export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/file-browser',
name: 'File Browser',
name: panelName('panelName.fileBrowser'),
icon: FolderOpen,
component: FileBrowserPanelWrapper,
},
{
key: 'officerdev/file-browser-widget',
name: 'File Browser',
name: panelName('panelName.fileBrowser'),
icon: FolderOpen,
component: FileBrowserWidget,
availableOnPanel: false,
@@ -12,8 +12,10 @@ import { TextRenderer } from './renderers/TextRenderer';
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
import { EditorRenderer } from './renderers/EditorRenderer';
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
import { useTranslation } from '../../locales';
export const FileViewerBody = () => {
const { t } = useTranslation();
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent, handleDownload } =
useFileViewer();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -49,7 +51,7 @@ export const FileViewerBody = () => {
className="inline-flex items-center gap-1.5 rounded-md border border-duck-dark/20 px-3 py-1.5 text-xs text-duck-dark transition-colors hover:bg-duck-dark/5 cursor-pointer"
>
<Download className="h-3.5 w-3.5" />
Download instead
{t('fileViewer.downloadInstead')}
</button>
</div>
) : fileType === 'pdf' ? (
@@ -57,7 +59,7 @@ export const FileViewerBody = () => {
) : fileType === 'archive' ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
<FolderArchive className="h-10 w-10" />
<span className="text-sm">Archive file</span>
<span className="text-sm">{t('fileViewer.archiveFile')}</span>
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
</div>
) : fileType === 'image' ? (
@@ -17,8 +17,10 @@ import {
import { getIcon } from 'material-file-icons';
import { getLang } from './file-types';
import { useFileViewer } from './FileViewerContext';
import { useTranslation } from '../../locales';
export const FileViewerHeader = () => {
const { t } = useTranslation();
const {
fileName,
fileType,
@@ -81,7 +83,7 @@ export const FileViewerHeader = () => {
onClick={handleReadAloud}
disabled={ttsLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Read Aloud"
title={t('fileViewer.readAloud')}
>
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
</button>
@@ -91,7 +93,7 @@ export const FileViewerHeader = () => {
onClick={handleOcr}
disabled={ocrLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract Text (OCR)"
title={t('fileViewer.ocr')}
>
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
</button>
@@ -101,7 +103,7 @@ export const FileViewerHeader = () => {
onClick={handleTranscribe}
disabled={transcribeLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Transcribe"
title={t('fileViewer.transcribe')}
>
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
</button>
@@ -111,7 +113,7 @@ export const FileViewerHeader = () => {
onClick={handleExtractAudio}
disabled={extractAudioLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract Audio"
title={t('fileViewer.extractAudio')}
>
{extractAudioLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -125,7 +127,7 @@ export const FileViewerHeader = () => {
onClick={handleExtract}
disabled={extractLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Extract"
title={t('fileViewer.extract')}
>
{extractLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -139,7 +141,7 @@ export const FileViewerHeader = () => {
onClick={handleSaveResult}
disabled={saveResultLoading}
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
title="Save next to original"
title={t('fileViewer.saveNextTo')}
>
{saveResultLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
</button>
@@ -148,7 +150,7 @@ export const FileViewerHeader = () => {
<button
onClick={handleDownload}
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
title="Download"
title={t('fileViewer.download')}
>
<Download className="h-3.5 w-3.5" />
</button>
@@ -5,6 +5,7 @@ import { useFilesRefresh } from '../../channels';
import { toast } from 'sonner';
import { getFileType } from './file-types';
import { FileViewerContext } from './FileViewerContext';
import { useTranslation } from '../../locales';
type FileViewerProviderProps = {
filePath: string;
@@ -48,6 +49,7 @@ export const FileViewerProvider = ({
autoPlay = false,
children,
}: FileViewerProviderProps) => {
const { t } = useTranslation();
const [content, setContent] = useState<string | null>(directContent ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -104,7 +106,7 @@ export const FileViewerProvider = ({
bumpFilesRefresh();
onReplaceView?.(filePath, root, audioPath, audioRoot);
} catch {
toast.error('Failed to generate speech audio');
toast.error(t('fileViewer.speechFailed'));
} finally {
setTtsLoading(false);
}
@@ -117,7 +119,7 @@ export const FileViewerProvider = ({
bumpFilesRefresh();
onReplaceView?.(filePath, root, ocrPath, ocrRoot);
} catch {
toast.error('Failed to extract text from image');
toast.error(t('fileViewer.ocrFailed'));
} finally {
setOcrLoading(false);
}
@@ -130,7 +132,7 @@ export const FileViewerProvider = ({
bumpFilesRefresh();
onReplaceView?.(filePath, root, transcriptionPath, transcriptionRoot);
} catch {
toast.error('Failed to transcribe audio');
toast.error(t('fileViewer.transcribeFailed'));
} finally {
setTranscribeLoading(false);
}
@@ -142,7 +144,7 @@ export const FileViewerProvider = ({
const { audioPath, audioRoot } = await files.extractAudio(filePath);
onOpenFile?.(audioPath, audioRoot);
} catch {
toast.error('Failed to extract audio from video');
toast.error(t('fileViewer.extractAudioFailed'));
} finally {
setExtractAudioLoading(false);
}
@@ -157,9 +159,9 @@ export const FileViewerProvider = ({
const { savedPath } = await files.saveResult(filePath);
const savedName = savedPath.split('/').pop() ?? savedPath;
bumpFilesRefresh();
toast.success(`Saved as "${savedName}"`);
toast.success(t('fileViewer.savedAs', { name: savedName }));
} catch {
toast.error('Failed to save file');
toast.error(t('fileViewer.saveFailed'));
} finally {
setSaveResultLoading(false);
}
@@ -171,9 +173,9 @@ export const FileViewerProvider = ({
try {
const { extractedPath } = await files.extract(filePath);
const folderName = extractedPath.split('/').pop() ?? extractedPath;
toast.success(`Extracted to "${folderName}"`);
toast.success(t('fileViewer.extractedTo', { name: folderName }));
} catch {
toast.error('Failed to extract archive');
toast.error(t('fileViewer.extractFailed'));
} finally {
setExtractLoading(false);
}
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Loader2, Music, Play, Pause, Volume2, VolumeX } from 'lucide-react';
import { formatTime } from '../file-types';
import { useSeekBar, SeekBar } from './SeekBar';
import { useTranslation } from '../../../locales';
type AudioRendererProps = {
src: string;
@@ -10,6 +11,7 @@ type AudioRendererProps = {
};
export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRendererProps) => {
const { t } = useTranslation();
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrent] = useState(0);
@@ -80,7 +82,7 @@ export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRenderer
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-destructive">Failed to load audio file</span>
<span className="text-sm text-destructive">{t('fileViewer.audioFailed')}</span>
</div>
);
}
@@ -4,6 +4,7 @@ import type { editor as MonacoEditor } from 'monaco-editor';
import { toast } from 'sonner';
import { getLanguage } from '../../CodeEditor/language-map';
import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { useTranslation } from '../../../locales';
type EditorRendererProps = {
content: string;
@@ -14,6 +15,7 @@ type EditorRendererProps = {
};
export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: EditorRendererProps) => {
const { t } = useTranslation();
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
const { writeFile } = useFilesAPI(root);
@@ -24,9 +26,9 @@ export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: Ed
try {
await writeFile(filePath, newContent);
onSave?.(newContent);
toast.success('File saved');
toast.success(t('fileViewer.saved'));
} catch {
toast.error('Failed to save file');
toast.error(t('fileViewer.saveFailed'));
}
}, [filePath, writeFile, onSave]);
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { Loader2, ZoomIn, ZoomOut, RotateCw } from 'lucide-react';
import { useTranslation } from '../../../locales';
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
const MIN_ZOOM = ZOOM_STEPS[0]!;
@@ -11,6 +12,7 @@ type ImageRendererProps = {
};
export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
@@ -78,7 +80,7 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-destructive">Failed to load image</span>
<span className="text-sm text-destructive">{t('fileViewer.imageFailed')}</span>
</div>
);
}
@@ -3,6 +3,7 @@ import { JsonEditor } from '@visual-json/react';
import type { JsonValue } from '@visual-json/core';
import { toast } from 'sonner';
import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { useTranslation } from '../../../locales';
type JsonEditorRendererProps = {
content: string;
@@ -12,6 +13,7 @@ type JsonEditorRendererProps = {
};
export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEditorRendererProps) => {
const { t } = useTranslation();
const { writeFile } = useFilesAPI(root);
const valueRef = useRef<JsonValue>(JSON.parse(content));
@@ -24,9 +26,9 @@ export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEdit
try {
await writeFile(filePath, newContent);
onSave?.(newContent);
toast.success('File saved');
toast.success(t('fileViewer.saved'));
} catch {
toast.error('Failed to save file');
toast.error(t('fileViewer.saveFailed'));
}
}, [filePath, writeFile, onSave]);
@@ -40,12 +42,7 @@ export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEdit
}
}}
>
<JsonEditor
defaultValue={valueRef.current}
onChange={handleChange}
height="100%"
width="100%"
/>
<JsonEditor defaultValue={valueRef.current} onChange={handleChange} height="100%" width="100%" />
</div>
);
};
@@ -1,3 +1,6 @@
export const PdfRenderer = ({ src }: { src: string }) => (
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
);
import { useTranslation } from '../../../locales';
export const PdfRenderer = ({ src }: { src: string }) => {
const { t } = useTranslation();
return <iframe src={src} className="w-full h-full border-0" title={t('fileViewer.pdfViewer')} />;
};
@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react';
import { ArrowUp } from 'lucide-react';
import { useTranslation } from '../../../locales';
type ScrollToTopButtonProps = {
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
useEffect(() => {
@@ -22,7 +24,7 @@ export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) =
<button
onClick={() => scrollContainer.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="sticky bottom-4 float-right mr-4 z-10 p-2.5 rounded-full bg-duck-teal text-white shadow-lg hover:bg-duck-teal/90 active:scale-95 transition-all cursor-pointer"
title="Scroll to top"
title={t('fileViewer.scrollTop')}
>
<ArrowUp className="h-4 w-4" />
</button>
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX, Captions, Languages } from 'lucide-react';
import { getExt, formatTime, getRawUrl, getSubtitlesUrl, getSubtitleVttUrl, getAudioTracksUrl } from '../file-types';
import { useSeekBar, SeekBar } from './SeekBar';
import { useTranslation } from '../../../locales';
type VideoRendererProps = {
src: string;
@@ -14,6 +15,7 @@ type SubtitleTrack = { id: number; lang: string; label: string };
type AudioOption = { id: number; label: string };
export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererProps) => {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
@@ -105,9 +107,14 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
.then((list: Array<{ id: number; lang: string; title: string }>) => {
if (cancelled || !Array.isArray(list)) return;
setSubtitles(
list.map((t) => {
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
return { id: t.id, lang: t.lang || 'und', label: t.title || realLang || `Track ${t.id + 1}` };
list.map((track) => {
const realLang =
track.lang && track.lang !== 'und' && track.lang !== 'unknown' ? track.lang.toUpperCase() : '';
return {
id: track.id,
lang: track.lang || 'und',
label: track.title || realLang || t('fileViewer.track', { n: track.id + 1 }),
};
}),
);
})
@@ -126,9 +133,10 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
.then((list: Array<{ id: number; lang: string; title: string }>) => {
if (cancelled || !Array.isArray(list)) return;
setAudioTracks(
list.map((t) => {
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
return { id: t.id, label: t.title || realLang || `Track ${t.id + 1}` };
list.map((track) => {
const realLang =
track.lang && track.lang !== 'und' && track.lang !== 'unknown' ? track.lang.toUpperCase() : '';
return { id: track.id, label: track.title || realLang || t('fileViewer.track', { n: track.id + 1 }) };
}),
);
})
@@ -213,7 +221,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-destructive">Failed to load video</span>
<span className="text-sm text-destructive">{t('fileViewer.videoFailed')}</span>
</div>
);
}
@@ -252,7 +260,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
<button
onClick={() => setShowAudioMenu((s) => !s)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/50 backdrop-blur-sm text-xs text-white/90 hover:bg-black/70 transition-colors cursor-pointer"
title="Audio track"
title={t('fileViewer.audioTrack')}
>
<Languages className="h-3.5 w-3.5" />
<span className="max-w-[140px] truncate">{audioTracks[activeAudio]?.label ?? 'Audio'}</span>
@@ -337,8 +345,8 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
<button
onClick={() => setShowSubMenu((s) => !s)}
className={`p-1 transition-colors cursor-pointer ${activeSub !== null ? 'text-duck-teal' : 'text-white/70 hover:text-white'}`}
title="Subtitles"
aria-label="Subtitles"
title={t('fileViewer.subtitles')}
aria-label={t('fileViewer.subtitles')}
>
<Captions className="h-4 w-4" />
</button>
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Bomb, Loader2, Play, RefreshCw } from "lucide-react";
import { useClient } from "hooks/useClient";
import { RfbClient } from "./rfb";
import { useCallback, useEffect, useRef, useState } from 'react';
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import type { RfbError } from './rfb';
import { RfbClient } from './rfb';
import { useTranslation } from '../../locales';
type PanelT = ReturnType<typeof useTranslation>['t'];
type SessionStatus = {
installed: boolean;
@@ -16,7 +20,26 @@ const VISIBLE = { width: 164, height: 226 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
/** Explicit map from RfbError to literal keys — never a key assembled from `error.code`. */
const describeRfbError = (error: RfbError, t: PanelT): string => {
switch (error.code) {
case 'connectionFailed':
return t('minesweeper.error.connectionFailed');
case 'refused':
return t('minesweeper.error.refused');
case 'authUnsupported':
return t('minesweeper.error.authUnsupported');
case 'handshakeRejected':
return t('minesweeper.error.handshakeRejected');
case 'unknownMessage':
return t('minesweeper.error.unknownMessage', { type: error.type });
case 'unknownEncoding':
return t('minesweeper.error.unknownEncoding', { encoding: error.encoding });
}
};
export const MinesweeperPanel = () => {
const { t } = useTranslation();
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
@@ -45,9 +68,9 @@ export const MinesweeperPanel = () => {
const refresh = useCallback(async () => {
try {
adopt(await clientRef.current.get<SessionStatus>("/minesweeper/status"));
adopt(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
} catch {
setError("Could not reach the server.");
setError('Could not reach the server.');
}
}, [adopt]);
@@ -59,9 +82,9 @@ export const MinesweeperPanel = () => {
setBusy(true);
setError(null);
try {
adopt(await clientRef.current.post<SessionStatus>("/minesweeper/start", {}));
adopt(await clientRef.current.post<SessionStatus>('/minesweeper/start', {}));
} catch {
setError("The session would not start. Is wine-minesweeper installed on the server?");
setError('The session would not start. Is wine-minesweeper installed on the server?');
} finally {
setBusy(false);
}
@@ -73,7 +96,7 @@ export const MinesweeperPanel = () => {
const canvas = canvasRef.current;
const offscreen = offscreenRef.current;
if (!canvas || !offscreen) return;
const context = canvas.getContext("2d");
const context = canvas.getContext('2d');
if (!context) return;
const { width, height } = geometryRef.current;
if (canvas.width !== width || canvas.height !== height) {
@@ -86,15 +109,15 @@ export const MinesweeperPanel = () => {
useEffect(() => {
if (!status?.running) return;
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
const token = localStorage.getItem("BEARER_TOKEN") ?? sessionStorage.getItem("BEARER_TOKEN") ?? "";
const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN') ?? '';
const url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
const rfb = new RfbClient(url, {
onReady: (width, height) => {
const offscreen = document.createElement("canvas");
const offscreen = document.createElement('canvas');
offscreen.width = width;
offscreen.height = height;
offscreenRef.current = offscreen;
@@ -102,12 +125,12 @@ export const MinesweeperPanel = () => {
setError(null);
},
onFrame: (image, x, y) => {
offscreenRef.current?.getContext("2d")?.putImageData(image, x, y);
offscreenRef.current?.getContext('2d')?.putImageData(image, x, y);
present();
},
onCopyRect: (from, to) => {
const offscreen = offscreenRef.current;
const context = offscreen?.getContext("2d");
const context = offscreen?.getContext('2d');
if (!offscreen || !context) return;
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
present();
@@ -120,7 +143,7 @@ export const MinesweeperPanel = () => {
}
rfb.requestUpdate(false);
},
onError: (message) => setError(message),
onError: (rfbError) => setError(describeRfbError(rfbError, t)),
onClose: () => setConnected(false),
});
@@ -138,7 +161,7 @@ export const MinesweeperPanel = () => {
if (!connected) return;
const timer = setInterval(async () => {
try {
const next = await clientRef.current.get<SessionStatus>("/minesweeper/status");
const next = await clientRef.current.get<SessionStatus>('/minesweeper/status');
if (!next.window) return;
const { width, height } = next.window;
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
@@ -159,14 +182,14 @@ export const MinesweeperPanel = () => {
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
};
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: "down" | "up" | "move") => {
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
const rfb = rfbRef.current;
if (!rfb) return;
ev.preventDefault();
if (kind !== "move") {
if (kind !== 'move') {
const bit = BUTTON_MASK[ev.button] ?? 0;
buttonsRef.current = kind === "down" ? buttonsRef.current | bit : buttonsRef.current & ~bit;
buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit;
}
const { x, y } = toFramebuffer(ev);
rfb.pointer(x, y, buttonsRef.current);
@@ -176,9 +199,11 @@ export const MinesweeperPanel = () => {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
<p className="text-sm text-muted-foreground">{t('minesweeper.notInstalled')}</p>
{/* The package name sits at the end so the sentence is one key rather than two around an
inline element — see docs/localization.md on the fragmentation that produces. */}
<p className="max-w-sm text-center text-xs text-muted-foreground">
It needs <code>wine-minesweeper</code> beside the platform, with wine and a 32-bit prefix.
{t('minesweeper.needs')} <code>wine-minesweeper</code>
</p>
</Centred>
);
@@ -188,7 +213,7 @@ export const MinesweeperPanel = () => {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
<p className="text-sm text-muted-foreground">{t('minesweeper.notRunning')}</p>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<button
type="button"
@@ -197,7 +222,7 @@ export const MinesweeperPanel = () => {
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
{busy ? "Starting" : "Start it"}
{busy ? 'Starting' : 'Start it'}
</button>
</Centred>
);
@@ -209,17 +234,17 @@ export const MinesweeperPanel = () => {
ref={canvasRef}
width={VISIBLE.width}
height={VISIBLE.height}
onMouseDown={(ev) => onPointer(ev, "down")}
onMouseUp={(ev) => onPointer(ev, "up")}
onMouseMove={(ev) => onPointer(ev, "move")}
onMouseDown={(ev) => onPointer(ev, 'down')}
onMouseUp={(ev) => onPointer(ev, 'up')}
onMouseMove={(ev) => onPointer(ev, 'move')}
onContextMenu={(ev) => ev.preventDefault()}
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
// photograph of a screen rather than the screen.
className="rounded-sm shadow-lg [image-rendering:pixelated]"
style={{ cursor: connected ? "default" : "progress" }}
style={{ cursor: connected ? 'default' : 'progress' }}
/>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{connected ? "connected" : "connecting…"}</span>
<span>{connected ? 'connected' : 'connecting…'}</span>
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
<RefreshCw className="size-3" /> refresh
</button>
@@ -26,10 +26,26 @@ export type RfbEvents = {
onFrame: (image: ImageData, x: number, y: number) => void;
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
onResize: (width: number, height: number) => void;
onError: (message: string) => void;
onError: (error: RfbError) => void;
onClose: () => void;
};
/**
* What went wrong, as a code rather than a sentence.
*
* A protocol client has no business holding user-facing prose: it cannot reach a React hook, and the
* words belong to whoever is rendering them. The panel maps each code to a catalog key — an explicit
* map from a closed union to literal keys, which is what the naming convention asks for instead of
* building a key at runtime.
*/
export type RfbError =
| { code: 'connectionFailed' }
| { code: 'refused' }
| { code: 'authUnsupported' }
| { code: 'handshakeRejected' }
| { code: 'unknownMessage'; type: number }
| { code: 'unknownEncoding'; encoding: number };
const ENCODING_RAW = 0;
const ENCODING_COPY_RECT = 1;
const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
@@ -54,7 +70,7 @@ export class RfbClient {
this.closed = true;
this.events.onClose();
};
this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.');
this.socket.onerror = () => this.events.onError({ code: 'connectionFailed' });
}
close(): void {
@@ -125,7 +141,7 @@ export class RfbClient {
// A zero count means the server is refusing us, and the reason follows as a string.
const body = this.take(1 + 4);
if (!body) return false;
this.events.onError('The VNC server refused the connection.');
this.events.onError({ code: 'refused' });
this.close();
return false;
}
@@ -134,7 +150,7 @@ export class RfbClient {
const types = Array.from(head.subarray(1));
if (!types.includes(1)) {
this.events.onError('The VNC server wants authentication, which this client does not implement.');
this.events.onError({ code: 'authUnsupported' });
this.close();
return false;
}
@@ -147,7 +163,7 @@ export class RfbClient {
const head = this.take(4);
if (!head) return false;
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) {
this.events.onError('The VNC server rejected the handshake.');
this.events.onError({ code: 'handshakeRejected' });
this.close();
return false;
}
@@ -231,7 +247,7 @@ export class RfbClient {
return this.take(8 + length) !== null;
}
this.events.onError(`The VNC server sent an unknown message type (${type}).`);
this.events.onError({ code: 'unknownMessage', type });
this.close();
return false;
}
@@ -251,7 +267,14 @@ export class RfbClient {
for (let i = 0; i < rectangles; i++) {
if (this.buffer.length < offset + 12) return false;
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12);
const rect = { x: view.getUint16(0), y: view.getUint16(2), w: view.getUint16(4), h: view.getUint16(6), encoding: view.getInt32(8), at: offset + 12 };
const rect = {
x: view.getUint16(0),
y: view.getUint16(2),
w: view.getUint16(4),
h: view.getUint16(6),
encoding: view.getInt32(8),
at: offset + 12,
};
offset += 12;
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4;
@@ -259,7 +282,7 @@ export class RfbClient {
else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
/* no payload — the rect's w/h ARE the new size */
} else {
this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`);
this.events.onError({ code: 'unknownEncoding', encoding: rect.encoding });
this.close();
return false;
}
@@ -2,6 +2,7 @@ import { Puzzle } from 'lucide-react';
import type { AppRegistryMeta } from '../../AppRegistry';
import { PluginsList } from './PluginsList';
import { PluginDetail } from './PluginDetail';
import { panelName } from '../../locales';
export { PluginsList } from './PluginsList';
export { PluginDetail } from './PluginDetail';
@@ -12,6 +13,18 @@ export type { PluginItem, PluginPermission } from './usePlugins';
// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
// /plugins, together.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false },
{ key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false },
{
key: 'plugins-list',
name: panelName('panelName.plugins'),
icon: Puzzle,
component: PluginsList,
availableOnPanel: false,
},
{
key: 'plugin-detail',
name: panelName('panelName.pluginDetail'),
icon: Puzzle,
component: PluginDetail,
availableOnPanel: false,
},
];
@@ -3,6 +3,7 @@ import { Globe } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { useWorkspace } from '../../components/Workspace';
import { normalizeUrl, usePreview } from './use-preview';
import { useTranslation } from '../../locales';
/**
* A live app, in a panel.
@@ -19,6 +20,7 @@ import { normalizeUrl, usePreview } from './use-preview';
* machine; pretending otherwise with an attribute that does not hold is worse than not claiming it.
*/
export const PreviewApp = ({ panelId }: { panelId: string }) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
const { url, target, setUrl, reportLocation, reloadToken } = usePreview(panelId, workspace);
const [draft, setDraft] = useState('');
@@ -34,7 +36,7 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
}}
>
<Globe className="h-6 w-6 text-duck-dark/30" />
<span className="text-sm text-duck-dark/50">Point this panel at a running app</span>
<span className="text-sm text-duck-dark/50">{t('preview.pointAtApp')}</span>
<input
autoFocus
value={draft}
@@ -47,11 +49,9 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
disabled={!normalizeUrl(draft)}
className="rounded-md bg-duck-teal px-3 py-1.5 text-sm text-duck-yellow transition-opacity hover:opacity-90 disabled:opacity-40"
>
Open
{t('preview.open')}
</button>
<p className="text-xs text-duck-dark/40">
Anything the browser can reach. It stays on this panel and survives a reload.
</p>
<p className="text-xs text-duck-dark/40">{t('preview.hint')}</p>
</form>
);
}
@@ -86,6 +86,7 @@ type DevProxyFrameProps = {
};
export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevProxyFrameProps) => {
const { t } = useTranslation();
const { get } = useClient();
const needsSession = entry.startsWith('/api/dev-proxy/');
const [ready, setReady] = useState(!needsSession);
@@ -95,7 +96,7 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
* The session cookie, and why this effect must NOT watch the URL.
*
* It used to depend on the full composed URL, which changes every time the app navigates. Each change
* re-ran this, and `setReady(false)` tears the frame down to show "Connecting…" — so a route change was
* re-ran this, and `setReady(false)` tears the frame down to show t('preview.connecting') — so a route change was
* followed, one debounce later, by the whole app reloading. Reported as "I change routes and a second
* later the page refreshes"; the second was the 800ms debounce on writing the location down.
*
@@ -128,7 +129,9 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
);
}
if (!ready) {
return <div className="flex h-full items-center justify-center text-xs text-duck-dark/40">Connecting…</div>;
return (
<div className="flex h-full items-center justify-center text-xs text-duck-dark/40">{t('preview.connecting')}</div>
);
}
// Keyed on the ENTRY url as well as the reload counter. Freezing `src` at mount stopped the app's own
// navigation from reloading the frame — and stopped a newly typed URL from loading at all, since nothing
@@ -159,6 +162,7 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
* two panels are never pointed at the same port.
*/
const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocation: (path: string) => void }) => {
const { t } = useTranslation();
const [src] = useState(initialSrc);
const frame = useRef<HTMLIFrameElement | null>(null);
@@ -174,5 +178,5 @@ const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocati
return () => window.removeEventListener('message', onMessage);
}, [onLocation]);
return <iframe ref={frame} src={src} title="Preview" className="h-full w-full border-none bg-white" />;
return <iframe ref={frame} src={src} title={t('preview.title')} className="h-full w-full border-none bg-white" />;
};
@@ -2,17 +2,19 @@ import { useEffect, useState } from 'react';
import { Globe, RefreshCw, ExternalLink, Pencil, Check, X } from 'lucide-react';
import { useWorkspace } from '../../components/Workspace';
import { normalizeUrl, usePreview } from './use-preview';
import { useTranslation } from '../../locales';
/**
* The preview panel's header: where it points, reload, open in a real tab, and change it.
*
* "Open in a new tab" is not decoration. A framed app gets PARTITIONED storage — the browser keys it by
* t('preview.openNewTab') is not decoration. A framed app gets PARTITIONED storage — the browser keys it by
* top-level site — so the login inside this panel is separate from the login in a tab, and a page that
* refuses to be framed at all (`X-Frame-Options`, CSP `frame-ancestors`) renders blank here with only a
* console error. When either happens, the tab is the answer, and it should be one click away rather than
* something you rebuild by copying the URL out.
*/
export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
const { t, tUi } = useTranslation();
const { workspace } = useWorkspace();
const { url, target, display, setUrl, reload } = usePreview(panelId, workspace);
@@ -46,13 +48,13 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
}}
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
/>
<button type="submit" title="Save" className="p-0.5 cursor-pointer shrink-0">
<button type="submit" title={t('common.save')} className="p-0.5 cursor-pointer shrink-0">
<Check className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => setEditing(false)}
title="Cancel"
title={tUi('button.cancel')}
className="p-0.5 cursor-pointer shrink-0"
>
<X className="h-3 w-3" />
@@ -64,10 +66,14 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
return (
<>
<Globe className="h-3.5 w-3.5 shrink-0 text-duck-teal" />
<span className="text-xs font-medium shrink-0">Preview</span>
<span className="text-xs font-medium shrink-0">{t('preview.title')}</span>
{url ? (
<>
<button onClick={reload} title="Reload" className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0">
<button
onClick={reload}
title={t('preview.reload')}
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
>
<RefreshCw className="h-3 w-3" />
</button>
<span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60" title={display}>
@@ -75,7 +81,7 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
</span>
<button
onClick={() => setEditing(true)}
title="Change URL"
title={t('preview.changeUrl')}
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
>
<Pencil className="h-3 w-3" />
@@ -84,7 +90,7 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
href={target}
target="_blank"
rel="noreferrer"
title="Open in a new tab"
title={t('preview.openNewTab')}
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
>
<ExternalLink className="h-3 w-3" />
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
import { Globe } from 'lucide-react';
import { PreviewApp } from './PreviewApp';
import { PreviewHeader } from './PreviewHeader';
import { panelName } from '../../locales';
export { PreviewApp } from './PreviewApp';
export { PreviewHeader } from './PreviewHeader';
@@ -10,7 +11,7 @@ export { usePreview, normalizeUrl, type PreviewPanelState } from './use-preview'
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/preview',
name: 'Preview',
name: panelName('panelName.preview'),
icon: Globe,
component: PreviewApp,
header: PreviewHeader,
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import jsQR from 'jsqr';
import { Camera, Download, RotateCcw } from 'lucide-react';
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
import { useTranslation } from '../../locales';
// Receiver: camera → jsQR → collect frames until every index is present.
//
@@ -15,6 +16,7 @@ import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
type Progress = { meta: MetaFrame; have: number };
export const QrReceiver = () => {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const chunksRef = useRef(new Map<number, Uint8Array>());
@@ -130,7 +132,7 @@ export const QrReceiver = () => {
className="flex cursor-pointer items-center gap-2 rounded-md border border-duck-dark/20 px-3 py-2 text-sm text-duck-dark/80 hover:border-duck-teal hover:text-duck-teal"
>
<Camera className="h-4 w-4" />
Start scanning
{t('qrTransfer.startScanning')}
</button>
)}
@@ -172,18 +174,14 @@ export const QrReceiver = () => {
className="flex cursor-pointer items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-teal"
>
<RotateCcw className="h-3.5 w-3.5" />
Receive another
{t('qrTransfer.receiveAnother')}
</button>
</div>
)}
{scanning && (
<button
type="button"
onClick={stop}
className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500"
>
Stop
<button type="button" onClick={stop} className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500">
{t('qrTransfer.stop')}
</button>
)}
</div>
@@ -1,14 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Upload, Play, Pause } from 'lucide-react';
import {
chunkFile,
crc32,
encodeData,
encodeMeta,
newSessionId,
DEFAULT_CHUNK_BYTES,
} from './protocol';
import { chunkFile, crc32, encodeData, encodeMeta, newSessionId, DEFAULT_CHUNK_BYTES } from './protocol';
import { useTranslation } from '../../locales';
// Sender: a file becomes a looping carousel of QR frames.
//
@@ -24,6 +18,7 @@ const META_EVERY = 12;
const SPEEDS = [4, 6, 8, 12] as const;
export const QrSender = () => {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
const [frames, setFrames] = useState<string[]>([]);
@@ -82,7 +77,7 @@ export const QrSender = () => {
<div className="flex h-full w-full flex-col items-center gap-3 overflow-y-auto p-3">
<label className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-duck-dark/25 py-3 text-sm text-duck-dark/70 hover:border-duck-teal hover:text-duck-teal">
<Upload className="h-4 w-4" />
{file ? file.name : 'Choose a file to send'}
{file ? file.name : t('qrTransfer.chooseFile')}
<input
type="file"
className="hidden"
@@ -129,8 +124,8 @@ export const QrSender = () => {
</div>
<p className="text-center text-[10px] leading-relaxed text-duck-dark/40">
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is
picked up on the next pass.
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is picked up on the
next pass.
</p>
</>
)}
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
import { QrCode, ScanLine } from 'lucide-react';
import { QrSender } from './Sender';
import { QrReceiver } from './Receiver';
import { panelName } from '../../locales';
export { QrSender } from './Sender';
export { QrReceiver } from './Receiver';
@@ -12,6 +13,6 @@ export * from './protocol';
// enough to reimplement on iOS and Android.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'qr-send', name: 'QR Send', icon: QrCode, component: QrSender },
{ key: 'qr-receive', name: 'QR Receive', icon: ScanLine, component: QrReceiver },
{ key: 'qr-send', name: panelName('panelName.qrSend'), icon: QrCode, component: QrSender },
{ key: 'qr-receive', name: panelName('panelName.qrReceive'), icon: ScanLine, component: QrReceiver },
];
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { Laptop, Send, Upload, User, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
import { useTranslation } from '../../locales';
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
const kb = (n: number): string =>
@@ -17,6 +18,7 @@ type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; na
* needed keeping in sync.
*/
export const SendOffPanel = () => {
const { t } = useTranslation();
const { devices, people, myFolder, isLoading } = useSendOffTargets();
const send = useSendOff();
const [files, setFiles] = useState<File[]>([]);
@@ -41,7 +43,7 @@ export const SendOffPanel = () => {
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
<section>
<h3 className="text-sm font-semibold text-foreground">Files</h3>
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.files')}</h3>
<input
ref={picker}
type="file"
@@ -70,7 +72,7 @@ export const SendOffPanel = () => {
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
<button
type="button"
aria-label={`Remove ${f.name}`}
aria-label={t('sendOff.removeFile', { name: f.name })}
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
>
@@ -86,9 +88,9 @@ export const SendOffPanel = () => {
</section>
<section>
<h3 className="text-sm font-semibold text-foreground">Where</h3>
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.where')}</h3>
{isLoading ? (
<p className="mt-2 text-sm text-muted-foreground">Loading…</p>
<p className="mt-2 text-sm text-muted-foreground">{t('sendOff.loading')}</p>
) : (
<div className="mt-2 flex flex-col gap-3">
<TargetGroup
@@ -124,19 +126,19 @@ export const SendOffPanel = () => {
<section className="flex flex-col gap-2">
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
<Send className="mr-2 h-4 w-4" />
{send.isPending ? 'Sending…' : target ? `Send to ${target.name}` : 'Send'}
{send.isPending ? 'Sending…' : target ? t('sendOff.sendTo', { name: target.name }) : 'Send'}
</Button>
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
revoked you are different problems with different answers. */}
{target?.kind === 'user' && myFolder && (
<p className="text-xs text-muted-foreground">
Lands in their <span className="text-foreground">Documents/SendOff/{myFolder}</span>.
{t('sendOff.landsIn')} <code className="text-foreground">Documents/SendOff/{myFolder}</code>.
</p>
)}
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
{send.data && (
<p className="text-sm text-muted-foreground">
Landed in <span className="text-foreground">{send.data.path}</span> —{' '}
{t('sendOff.landedIn')} <span className="text-foreground">{send.data.path}</span> —{' '}
{send.data.files.map((f) => f.name).join(', ')}
</p>
)}
@@ -195,15 +197,16 @@ const TargetGroup = ({ icon: Icon, heading, empty, options, selected, onSelect }
* the answer has to be visible in the same place the question was asked.
*/
const WhoMaySend = () => {
const { t } = useTranslation();
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
if (isLoading || candidates.length === 0) return null;
return (
<section className="border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground">Who may send to me</h3>
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.whoMaySend')}</h3>
<p className="mt-1 text-xs text-muted-foreground">
Files from anyone you allow land in <span className="text-foreground">Documents/SendOff/their-name</span>.
Turning someone off stops future sends and leaves what they already sent alone.
{t('sendOff.allowedLandIn')} <code className="text-foreground">Documents/SendOff/their-name</code>.{' '}
{t('sendOff.turningOff')}
</p>
<div className="mt-2 flex flex-col gap-1">
{candidates.map((p) => (
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { Cpu, MemoryStick, HardDrive, Server, Thermometer, CircuitBoard, Network, Zap } from 'lucide-react';
import { fmtBytes, fmtDuration } from './shared';
import { useTranslation } from '../../locales';
type Sensor = { name: string; label: string; celsius: number };
type Stats = {
@@ -67,6 +68,7 @@ const Card = ({
);
export const BtopView = () => {
const { t } = useTranslation();
const { get } = useClient();
const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -133,7 +135,7 @@ export const BtopView = () => {
</div>
</Card>
<Card title="Memory" icon={MemoryStick}>
<Card title={t('systemMonitor.memory')} icon={MemoryStick}>
{stats.mem ? (
<>
<div className="flex items-end justify-between">
@@ -154,7 +156,7 @@ export const BtopView = () => {
)}
</Card>
<Card title="Disks" icon={HardDrive}>
<Card title={t('systemMonitor.disks')} icon={HardDrive}>
{stats.disks.length ? (
<div className="flex flex-col gap-3">
{stats.disks.map((d) => (
@@ -195,7 +197,7 @@ export const BtopView = () => {
)}
</Card>
<Card title="Temperature" icon={Thermometer}>
<Card title={t('systemMonitor.temperature')} icon={Thermometer}>
{stats.temp?.cpuC != null ? (
<>
<div className="flex items-end justify-between">
@@ -223,7 +225,7 @@ export const BtopView = () => {
)}
</Card>
<Card title="Network" icon={Network}>
<Card title={t('systemMonitor.network')} icon={Network}>
{stats.net ? (
<>
<div className="flex items-center justify-between text-lg font-semibold text-foreground">
@@ -247,11 +249,11 @@ export const BtopView = () => {
)}
</Card>
<Card title="Power" icon={Zap}>
<Card title={t('systemMonitor.power')} icon={Zap}>
{stats.power ? (
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">CPU package</span>
<span className="text-muted-foreground">{t('systemMonitor.cpuPackage')}</span>
<span className="font-mono text-foreground">
{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}
</span>
@@ -269,16 +271,16 @@ export const BtopView = () => {
</Card>
</div>
<Card title="Top processes" icon={Server}>
<Card title={t('systemMonitor.topProcesses')} icon={Server}>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="text-xs uppercase tracking-wide text-muted-foreground">
<th className="pb-2 pr-4 font-medium">PID</th>
<th className="pb-2 pr-4 font-medium">User</th>
<th className="pb-2 pr-4 text-right font-medium">CPU%</th>
<th className="pb-2 pr-4 text-right font-medium">MEM%</th>
<th className="pb-2 font-medium">Command</th>
<th className="pb-2 pr-4 font-medium">{t('systemMonitor.user')}</th>
<th className="pb-2 pr-4 text-right font-medium">{t('systemMonitor.cpuPct')}</th>
<th className="pb-2 pr-4 text-right font-medium">{t('systemMonitor.memPct')}</th>
<th className="pb-2 font-medium">{t('systemMonitor.command')}</th>
</tr>
</thead>
<tbody>
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { LogStream } from './LogStream';
import { useTranslation } from '../../locales';
type Container = { id: string; name: string; image: string; state: string; status: string; ports: string };
@@ -8,6 +9,7 @@ const POLL_MS = 3000;
const stateColor = (s: string) => (s === 'running' ? 'bg-success' : s === 'exited' ? 'bg-destructive' : 'bg-busy');
export const DockerView = () => {
const { t } = useTranslation();
const { get } = useClient();
const [containers, setContainers] = useState<Container[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -59,7 +61,7 @@ export const DockerView = () => {
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="filter name or image…"
placeholder={t('systemMonitor.filterPlaceholder')}
className="w-56 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { fmtBytes, fmtDuration } from './shared';
import { LogStream } from './LogStream';
import type { PanelTranslationKey } from '../../locales';
import { useTranslation } from '../../locales';
type Proc = {
id: number;
@@ -25,18 +27,24 @@ const statusColor = (s: string) =>
? 'bg-destructive'
: 'bg-busy';
const COLS: { key: SortKey; label: string; align: 'left' | 'right' }[] = [
/*
Column metadata lives outside the component, so it carries catalogue KEYS and the header resolves
them. `#`, `↺` and `PID` have no key: a glyph and an acronym are the same in every locale, and a key
whose two sides can never differ is a maintenance cost bought for nothing.
*/
const COLS: { key: SortKey; labelKey?: PanelTranslationKey; label?: string; align: 'left' | 'right' }[] = [
{ key: 'id', label: '#', align: 'right' },
{ key: 'name', label: 'Name', align: 'left' },
{ key: 'status', label: 'Status', align: 'left' },
{ key: 'cpuPct', label: 'CPU%', align: 'right' },
{ key: 'memBytes', label: 'Mem', align: 'right' },
{ key: 'name', labelKey: 'systemMonitor.name', align: 'left' },
{ key: 'status', labelKey: 'systemMonitor.status', align: 'left' },
{ key: 'cpuPct', labelKey: 'systemMonitor.cpuPct', align: 'right' },
{ key: 'memBytes', labelKey: 'systemMonitor.mem', align: 'right' },
{ key: 'restarts', label: '↺', align: 'right' },
{ key: 'uptimeMs', label: 'Uptime', align: 'left' },
{ key: 'uptimeMs', labelKey: 'systemMonitor.uptime', align: 'left' },
{ key: 'pid', label: 'PID', align: 'right' },
];
export const Pm2View = () => {
const { t } = useTranslation();
const { get } = useClient();
const [procs, setProcs] = useState<Proc[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -116,7 +124,7 @@ export const Pm2View = () => {
onClick={() => toggleSort(c.key)}
className={`inline-flex items-center gap-1 hover:text-foreground ${c.align === 'right' ? 'flex-row-reverse' : ''} ${sortKey === c.key ? 'text-foreground' : ''}`}
>
{c.label}
{c.labelKey ? t(c.labelKey) : c.label}
<span className="w-2 text-[10px]">
{sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
</span>
@@ -2,10 +2,23 @@ import type { AppRegistryMeta } from '../../AppRegistry';
import { Activity, PanelLeft } from 'lucide-react';
import { ScopeList } from './ScopeList';
import { MonitorMain } from './MonitorMain';
import { panelName } from '../../locales';
export { ScopeList, MonitorMain };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'monitor-side', name: 'Scope', icon: PanelLeft, component: ScopeList, availableOnPanel: false },
{ key: 'monitor-main', name: 'System Monitor', icon: Activity, component: MonitorMain, availableOnPanel: false },
{
key: 'monitor-side',
name: panelName('panelName.scope'),
icon: PanelLeft,
component: ScopeList,
availableOnPanel: false,
},
{
key: 'monitor-main',
name: panelName('panelName.systemMonitor'),
icon: Activity,
component: MonitorMain,
availableOnPanel: false,
},
];
@@ -2,6 +2,8 @@ import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles, Circle, Loader2 }
import { useWorkspace } from '../../components/Workspace';
import { useGlobal } from 'hooks/useGlobal';
import type { TerminalConnectionState } from './Terminal';
import { useTranslation } from '../../locales';
import { Untranslated } from '@/components/Untranslated';
const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
const [state] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
@@ -18,12 +20,13 @@ const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
};
export const TerminalHeader = ({ panelId }: { panelId: string }) => {
const { t } = useTranslation();
const { cwd } = useWorkspace();
return (
<>
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
<span className="text-xs font-medium shrink-0">{t('terminal.title')}</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
@@ -31,11 +34,12 @@ export const TerminalHeader = ({ panelId }: { panelId: string }) => {
};
export const HostTerminalHeader = ({ panelId }: { panelId: string }) => {
const { t } = useTranslation();
const { cwd } = useWorkspace();
return (
<>
<Monitor className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
<span className="text-xs font-medium shrink-0">{t('terminal.title')}</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
@@ -47,7 +51,9 @@ export const TmuxHeader = ({ panelId }: { panelId: string }) => {
return (
<>
<Columns2 className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Tmux</span>
<span className="text-xs font-medium shrink-0">
<Untranslated reason="product-name">Tmux</Untranslated>
</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
@@ -59,7 +65,9 @@ export const NvimHeader = ({ panelId }: { panelId: string }) => {
return (
<>
<PenLine className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Neovim</span>
<span className="text-xs font-medium shrink-0">
<Untranslated reason="product-name">Neovim</Untranslated>
</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
@@ -71,7 +79,9 @@ export const ClaudeCodeHeader = ({ panelId }: { panelId: string }) => {
return (
<>
<Sparkles className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Claude Code</span>
<span className="text-xs font-medium shrink-0">
<Untranslated reason="product-name">Claude Code</Untranslated>
</span>
<ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { TerminalSquare, Trash2, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '../../locales';
// Every shell the pty sidecar is holding, whether or not a panel is pointing at it.
//
@@ -44,6 +45,7 @@ const since = (ts: number): string => {
};
export const RunningShells = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
@@ -69,12 +71,12 @@ export const RunningShells = () => {
<div className="flex h-full w-full flex-col overflow-hidden text-sm">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2">
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium">Running shells</span>
<span className="text-xs font-medium">{t('terminal.runningShells')}</span>
<span className="text-[10px] opacity-60">{sessions.length}</span>
<button
type="button"
onClick={() => refetch()}
title="Refresh"
title={t('terminal.refresh')}
className="ml-auto cursor-pointer opacity-60 hover:opacity-100"
>
<RefreshCw className="h-3.5 w-3.5" />
@@ -82,9 +84,9 @@ export const RunningShells = () => {
</div>
<div className="flex-1 overflow-y-auto">
{isLoading && <p className="px-3 py-3 text-xs opacity-60">Loading…</p>}
{isLoading && <p className="px-3 py-3 text-xs opacity-60">{t('terminal.loading')}</p>}
{!isLoading && sessions.length === 0 && (
<p className="px-3 py-3 text-xs opacity-60">No shells running.</p>
<p className="px-3 py-3 text-xs opacity-60">{t('terminal.noShells')}</p>
)}
{sessions.map((s) => (
<div key={s.sessionId} className="flex items-center gap-2 border-b border-white/5 px-3 py-2">
@@ -94,9 +96,7 @@ export const RunningShells = () => {
<p className="truncate text-xs font-medium">
{s.title || 'shell'}
{s.clients === 0 && (
<span className="ml-1.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-400">
orphan
</span>
<span className="ml-1.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-400">orphan</span>
)}
</p>
<p className="truncate font-mono text-[10px] opacity-50">
@@ -109,7 +109,7 @@ export const RunningShells = () => {
<button
type="button"
onClick={() => kill(s.sessionId)}
title="Kill this shell"
title={t('terminal.kill')}
className="shrink-0 cursor-pointer p-1 opacity-60 hover:text-red-400 hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -9,6 +9,7 @@ import { SearchAddon } from '@xterm/addon-search';
import '@xterm/xterm/css/xterm.css';
import { useMounted } from 'hooks/useMounted';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '../../locales';
// Scrollback the browser keeps. The sidecar keeps its own replay buffer for re-attach; this is what you
// can scroll back to within a live session, and 1000 (xterm's default) is about one long agent turn.
@@ -93,6 +94,7 @@ export const TerminalView = ({
onTitleChange,
onBell,
}: TerminalViewProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -535,7 +537,7 @@ export const TerminalView = ({
<input
autoFocus
value={searchTerm}
placeholder="Find"
placeholder={t('terminal.find')}
onChange={(ev) => {
setSearchTerm(ev.target.value);
runSearch(ev.target.value, 'next');
@@ -6,6 +6,7 @@ import { useAuth } from 'hooks/useAuth';
import { Plus, X, RefreshCw } from 'lucide-react';
import { useWidgetRegistry } from '../../WidgetRegistry';
import { WidgetPicker } from './WidgetPicker';
import { useTranslation } from '../../locales';
type Position = { x: number; y: number }; // percentages (0–100)
@@ -102,20 +103,23 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
[instance.position, panelRef],
);
const endDrag = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
dragRef.current = null;
containerRef.current?.releasePointerCapture(ev.pointerId);
},
[],
);
const endDrag = useCallback((ev: ReactPointerEvent) => {
if (!dragRef.current) return;
dragRef.current = null;
containerRef.current?.releasePointerCapture(ev.pointerId);
}, []);
const onPointerMove = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
const panelRect = panelRef.current?.getBoundingClientRect();
if (panelRect && (ev.clientX < panelRect.left || ev.clientX > panelRect.right || ev.clientY < panelRect.top || ev.clientY > panelRect.bottom)) {
if (
panelRect &&
(ev.clientX < panelRect.left ||
ev.clientX > panelRect.right ||
ev.clientY < panelRect.top ||
ev.clientY > panelRect.bottom)
) {
endDrag(ev);
return;
}
@@ -165,6 +169,7 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
// --- WidgetPanel ---
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
const { t } = useTranslation();
const [config, setConfig] = useWidgetPanelState(panelId);
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
const [showPicker, setShowPicker] = useState(false);
@@ -220,7 +225,9 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
/>
))}
<div className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}>
<div
className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}
>
{showPicker && (
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
<WidgetPicker onSelect={addWidget} />
@@ -229,7 +236,7 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
<div className="flex items-center gap-2">
<button
type="button"
title="Resync widgets (reload to pick up newly-deployed widgets)"
title={t('widgets.resync')}
className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:text-foreground cursor-pointer"
onClick={() => window.location.reload()}
>
@@ -1,11 +1,12 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { LayoutGrid } from 'lucide-react';
import { WidgetPanel } from './WidgetPanel';
import { panelName } from '../../locales';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/widgets',
name: 'Widgets',
name: panelName('panelName.widgets'),
icon: LayoutGrid,
component: WidgetPanel,
transparent: true,
@@ -1 +1,192 @@
{}
{
"common": {
"save": "Save",
"delete": "Delete"
},
"minesweeper": {
"notInstalled": "Minesweeper is not installed on this server.",
"needs": "It needs wine, a 32-bit prefix, and this package beside the platform:",
"notRunning": "The Minesweeper session is not running.",
"error": {
"connectionFailed": "The connection to the Minesweeper session failed.",
"refused": "The VNC server refused the connection.",
"authUnsupported": "The VNC server wants authentication, which this client does not implement.",
"handshakeRejected": "The VNC server rejected the handshake.",
"unknownMessage": "The VNC server sent an unknown message type ({{type}}).",
"unknownEncoding": "The VNC server used encoding {{encoding}}, which this client did not ask for."
}
},
"qrTransfer": {
"chooseFile": "Choose a file to send",
"startScanning": "Start scanning",
"receiveAnother": "Receive another",
"stop": "Stop"
},
"dav": {
"empty": "Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.",
"calendar": "Calendar",
"contacts": "Contacts",
"readFailed": "Could not read {{name}}: {{reason}}"
},
"terminal": {
"title": "Terminal",
"find": "Find",
"runningShells": "Running shells",
"loading": "Loading…",
"noShells": "No shells running.",
"refresh": "Refresh",
"kill": "Kill this shell"
},
"appStore": {
"loading": "Loading",
"loadFailed": "Could not load the app store.",
"installed": "Installed",
"available": "Available",
"needsYou": "Needs you",
"needsYouShort": "needs you",
"selectApp": "Select an app",
"installFailed": "Install failed",
"uninstall": "Uninstall",
"enable": "Enable",
"disable": "Disable",
"enabled": "enabled",
"disabled": "disabled",
"removalNote": "Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data, configuration and this app’s tables are kept either way.",
"installing": "installing",
"failed": "failed",
"installedIcon": "installed"
},
"systemMonitor": {
"cpuPackage": "CPU package",
"user": "User",
"cpuPct": "CPU%",
"memPct": "MEM%",
"command": "Command",
"memory": "Memory",
"disks": "Disks",
"temperature": "Temperature",
"network": "Network",
"power": "Power",
"topProcesses": "Top processes",
"filterPlaceholder": "filter name or image…",
"name": "Name",
"status": "Status",
"mem": "Mem",
"uptime": "Uptime"
},
"codeEditor": {
"closeFile": "Close {{name}}",
"loading": "Loading…",
"noFiles": "No files",
"explorer": "Explorer",
"openAFile": "Open a file from the explorer to start editing",
"refresh": "Refresh the tree and reload unmodified open files",
"readFailed": "Failed to read file",
"saved": "File saved",
"saveFailed": "Failed to save file",
"title": "Code Editor"
},
"widgets": {
"resync": "Resync widgets (reload to pick up newly-deployed widgets)"
},
"preview": {
"title": "Preview",
"reload": "Reload",
"changeUrl": "Change URL",
"openNewTab": "Open in a new tab",
"pointAtApp": "Point this panel at a running app",
"open": "Open",
"hint": "Anything the browser can reach. It stays on this panel and survives a reload.",
"connecting": "Connecting…"
},
"sendOff": {
"files": "Files",
"where": "Where",
"loading": "Loading…",
"landsIn": "Lands in their",
"landedIn": "Landed in",
"whoMaySend": "Who may send to me",
"allowedLandIn": "Files from anyone you allow land in",
"removeFile": "Remove {{name}}",
"sendTo": "Send to {{name}}",
"turningOff": "Turning someone off stops future sends and leaves what they already sent alone."
},
"panelName": {
"appStore": "App store",
"appDetail": "App detail",
"calendar": "Calendar",
"contacts": "Contacts",
"categories": "Categories",
"chat": "Chat",
"chatSessions": "Sessions",
"codeEditor": "Code Editor",
"commander": "Commander",
"commanders": "Commanders",
"dashboardPreview": "Dashboard Preview",
"dashboards": "Dashboards",
"fileBrowser": "File Browser",
"pluginDetail": "Plugin detail",
"plugins": "Plugins",
"preview": "Preview",
"qrReceive": "QR Receive",
"qrSend": "QR Send",
"scope": "Scope",
"systemMonitor": "System Monitor",
"widgets": "Widgets",
"terminal": "Terminal",
"hostTerminal": "Host Terminal",
"runningShells": "Running Shells",
"pm2Processes": "pm2 processes"
},
"dashboards": {
"title": "Dashboards",
"new": "New Dashboard",
"delete": "Delete dashboard",
"confirmDelete": "Are you sure you want to delete",
"search": "Search dashboards",
"name": "Name",
"description": "Description",
"open": "Open Dashboard",
"selectOne": "Select a dashboard or create a new one",
"namePlaceholder": "My Dashboard",
"descriptionPlaceholder": "What is this dashboard for?",
"template": "Template",
"create": "Create",
"templates": {
"single": "Single",
"twoColumns": "2 Columns",
"mainSide": "Main + Side",
"sidebar": "Sidebar",
"grid": "2x2 Grid",
"colsBottom": "Cols + Bottom"
}
},
"fileViewer": {
"readAloud": "Read Aloud",
"ocr": "Extract Text (OCR)",
"transcribe": "Transcribe",
"extractAudio": "Extract Audio",
"extract": "Extract",
"saveNextTo": "Save next to original",
"download": "Download",
"pdfViewer": "PDF viewer",
"videoFailed": "Failed to load video",
"imageFailed": "Failed to load image",
"audioFailed": "Failed to load audio file",
"audioTrack": "Audio track",
"subtitles": "Subtitles",
"track": "Track {{n}}",
"saved": "File saved",
"saveFailed": "Failed to save file",
"scrollTop": "Scroll to top",
"speechFailed": "Failed to generate speech audio",
"ocrFailed": "Failed to extract text from image",
"transcribeFailed": "Failed to transcribe audio",
"extractAudioFailed": "Failed to extract audio from video",
"extractFailed": "Failed to extract archive",
"savedAs": "Saved as “{{name}}”",
"extractedTo": "Extracted to “{{name}}”",
"downloadInstead": "Download instead",
"archiveFile": "Archive file"
}
}
@@ -6,3 +6,5 @@ import ptPT from './pt-PT.json';
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
export { useTranslation } from './useTranslation';
export type { PanelTranslationKey } from './useTranslation';
export { panelName } from './panelName';
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test';
import { render, screen } from '@testing-library/react';
import { I18nProvider, type TranslationMap } from 'i18n';
import { resolveEntryName } from 'workspaces/react';
import { panelName } from './panelName';
const translations: TranslationMap = {
'en-GB': { panel: { panelName: { fileBrowser: 'File Browser' } } },
'pt-PT': { panel: { panelName: { fileBrowser: 'Explorador de ficheiros' } } },
};
/** Stands in for AppPicker/PanelSlot: reads the name during ITS OWN render, exactly as they do. */
const NameProbe = ({ entry }: { entry: { name: string | (() => string) } }) => <span>{resolveEntryName(entry)}</span>;
describe('panelName', () => {
test('a plain string still works, so the 27 can migrate one at a time', () => {
render(<NameProbe entry={{ name: 'Minesweeper' }} />);
expect(screen.getByText('Minesweeper')).toBeDefined();
});
/**
* The condition the owner set: the thunk must be READ at render, so a locale change reaches it.
* Re-rendering under a different provider locale has to produce the other language — if the name
* were cached anywhere on the entry or in the module, this is the test that would fail.
*/
test('the name follows the provider locale across a re-render', () => {
const entry = { name: panelName('panelName.fileBrowser') };
const { rerender } = render(
<I18nProvider translations={translations} locale="en-GB" fallbackLocale="en-GB">
<NameProbe entry={entry} />
</I18nProvider>,
);
expect(screen.getByText('File Browser')).toBeDefined();
rerender(
<I18nProvider translations={translations} locale="pt-PT" fallbackLocale="en-GB">
<NameProbe entry={entry} />
</I18nProvider>,
);
expect(screen.getByText('Explorador de ficheiros')).toBeDefined();
});
});
@@ -0,0 +1,17 @@
import { translateStandalone } from 'i18n/standalone';
import type { PanelTranslationKey } from './useTranslation';
/**
* A panel's display name, as a thunk the framework calls during its own render.
*
* `AppRegistryEntry.name` accepts `string | (() => string)` precisely so this can be late-bound: the
* metas are module-level data, so there is no hook in scope, and `workspaces` has no dependencies to
* resolve a key with. See that type's comment for the layering argument.
*
* A panel whose name needs no translating — a product name like Minesweeper or SendOff — stays a plain
* string in its meta rather than becoming a key whose two sides can never legitimately differ.
*/
export const panelName =
(key: PanelTranslationKey): (() => string) =>
() =>
translateStandalone(`panel.${key}`);
@@ -1 +1,192 @@
{}
{
"common": {
"save": "Guardar",
"delete": "Eliminar"
},
"minesweeper": {
"notInstalled": "O Minesweeper não está instalado neste servidor.",
"needs": "Precisa do wine, de um prefixo de 32 bits e deste pacote junto à plataforma:",
"notRunning": "A sessão do Minesweeper não está em execução.",
"error": {
"connectionFailed": "A ligação à sessão do Minesweeper falhou.",
"refused": "O servidor VNC recusou a ligação.",
"authUnsupported": "O servidor VNC pede autenticação, que este cliente não implementa.",
"handshakeRejected": "O servidor VNC rejeitou o handshake.",
"unknownMessage": "O servidor VNC enviou um tipo de mensagem desconhecido ({{type}}).",
"unknownEncoding": "O servidor VNC usou a codificação {{encoding}}, que este cliente não pediu."
}
},
"qrTransfer": {
"chooseFile": "Escolha um ficheiro para enviar",
"startScanning": "Começar a digitalizar",
"receiveAnother": "Receber outro",
"stop": "Parar"
},
"dav": {
"empty": "Ainda não há nada aqui. Adicione um a partir de um cliente de telemóvel ou computador por CalDAV/CardDAV.",
"calendar": "Calendário",
"contacts": "Contactos",
"readFailed": "Não foi possível ler {{name}}: {{reason}}"
},
"terminal": {
"title": "Terminal",
"find": "Procurar",
"runningShells": "Shells em execução",
"loading": "A carregar…",
"noShells": "Nenhuma shell em execução.",
"refresh": "Atualizar",
"kill": "Terminar esta shell"
},
"appStore": {
"loading": "A carregar",
"loadFailed": "Não foi possível carregar a loja de aplicações.",
"installed": "Instaladas",
"available": "Disponíveis",
"needsYou": "Precisa de si",
"needsYouShort": "precisa de si",
"selectApp": "Selecione uma aplicação",
"installFailed": "A instalação falhou",
"uninstall": "Desinstalar",
"enable": "Ativar",
"disable": "Desativar",
"enabled": "ativada",
"disabled": "desativada",
"removalNote": "Desativar para o sidecar e o seu contentor. Desinstalar remove também os contentores — os seus dados, a configuração e as tabelas desta aplicação são mantidos em qualquer dos casos.",
"installing": "a instalar",
"failed": "falhou",
"installedIcon": "instalada"
},
"systemMonitor": {
"cpuPackage": "Pacote do CPU",
"user": "Utilizador",
"cpuPct": "CPU%",
"memPct": "MEM%",
"command": "Comando",
"memory": "Memória",
"disks": "Discos",
"temperature": "Temperatura",
"network": "Rede",
"power": "Energia",
"topProcesses": "Processos principais",
"filterPlaceholder": "filtrar por nome ou imagem…",
"name": "Nome",
"status": "Estado",
"mem": "Mem",
"uptime": "Tempo ativo"
},
"codeEditor": {
"closeFile": "Fechar {{name}}",
"loading": "A carregar…",
"noFiles": "Sem ficheiros",
"explorer": "Explorador",
"openAFile": "Abra um ficheiro no explorador para começar a editar",
"refresh": "Atualizar a árvore e recarregar os ficheiros abertos não modificados",
"readFailed": "Falha ao ler o ficheiro",
"saved": "Ficheiro guardado",
"saveFailed": "Falha ao guardar o ficheiro",
"title": "Editor de código"
},
"widgets": {
"resync": "Ressincronizar widgets (recarregue para incluir widgets recém-implementados)"
},
"preview": {
"title": "Pré-visualização",
"reload": "Recarregar",
"changeUrl": "Alterar URL",
"openNewTab": "Abrir num novo separador",
"pointAtApp": "Aponte este painel para uma aplicação em execução",
"open": "Abrir",
"hint": "Qualquer coisa que o navegador consiga alcançar. Fica neste painel e sobrevive a um recarregamento.",
"connecting": "A ligar…"
},
"sendOff": {
"files": "Ficheiros",
"where": "Onde",
"loading": "A carregar…",
"landsIn": "Aterra na pasta",
"landedIn": "Aterrou em",
"whoMaySend": "Quem me pode enviar",
"allowedLandIn": "Os ficheiros de quem autorizar aterram em",
"removeFile": "Remover {{name}}",
"sendTo": "Enviar para {{name}}",
"turningOff": "Desativar alguém impede envios futuros e deixa intacto o que já enviou."
},
"panelName": {
"appStore": "Loja de aplicações",
"appDetail": "Detalhe da aplicação",
"calendar": "Calendário",
"contacts": "Contactos",
"categories": "Categorias",
"chat": "Chat",
"chatSessions": "Sessões",
"codeEditor": "Editor de código",
"commander": "Comandante",
"commanders": "Comandantes",
"dashboardPreview": "Pré-visualização do painel",
"dashboards": "Painéis",
"fileBrowser": "Explorador de ficheiros",
"pluginDetail": "Detalhe do plugin",
"plugins": "Plugins",
"preview": "Pré-visualização",
"qrReceive": "Receber por QR",
"qrSend": "Enviar por QR",
"scope": "Âmbito",
"systemMonitor": "Monitor do sistema",
"widgets": "Widgets",
"terminal": "Terminal",
"hostTerminal": "Terminal do anfitrião",
"runningShells": "Shells em execução",
"pm2Processes": "processos pm2"
},
"dashboards": {
"title": "Painéis",
"new": "Novo painel",
"delete": "Eliminar painel",
"confirmDelete": "Tem a certeza de que quer eliminar",
"search": "Pesquisar painéis",
"name": "Nome",
"description": "Descrição",
"open": "Abrir painel",
"selectOne": "Selecione um painel ou crie um novo",
"namePlaceholder": "O meu painel",
"descriptionPlaceholder": "Para que serve este painel?",
"template": "Modelo",
"create": "Criar",
"templates": {
"single": "Único",
"twoColumns": "2 colunas",
"mainSide": "Principal + lateral",
"sidebar": "Barra lateral",
"grid": "Grelha 2x2",
"colsBottom": "Colunas + inferior"
}
},
"fileViewer": {
"readAloud": "Ler em voz alta",
"ocr": "Extrair texto (OCR)",
"transcribe": "Transcrever",
"extractAudio": "Extrair áudio",
"extract": "Extrair",
"saveNextTo": "Guardar ao lado do original",
"download": "Descarregar",
"pdfViewer": "Visualizador de PDF",
"videoFailed": "Falha ao carregar o vídeo",
"imageFailed": "Falha ao carregar a imagem",
"audioFailed": "Falha ao carregar o ficheiro de áudio",
"audioTrack": "Faixa de áudio",
"subtitles": "Legendas",
"track": "Faixa {{n}}",
"saved": "Ficheiro guardado",
"saveFailed": "Falha ao guardar o ficheiro",
"scrollTop": "Ir para o topo",
"speechFailed": "Falha ao gerar o áudio de fala",
"ocrFailed": "Falha ao extrair texto da imagem",
"transcribeFailed": "Falha ao transcrever o áudio",
"extractAudioFailed": "Falha ao extrair o áudio do vídeo",
"extractFailed": "Falha ao extrair o arquivo",
"savedAs": "Guardado como “{{name}}”",
"extractedTo": "Extraído para “{{name}}”",
"downloadInstead": "Descarregar em vez disso",
"archiveFile": "Ficheiro de arquivo"
}
}
@@ -2,8 +2,10 @@ import type { InterpolationParams, TranslationKeys } from 'i18n';
import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json';
import type uiEn from '@/components/locales/en-GB.json';
type PanelTranslationKey = TranslationKeys<typeof en>;
export type PanelTranslationKey = TranslationKeys<typeof en>;
type UiTranslationKey = TranslationKeys<typeof uiEn>;
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
// pass the same bare key the JSON file uses.
@@ -15,5 +17,9 @@ export const useTranslation = () => {
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
[baseT],
);
return { t, locale };
// `ui.*` for the words the shared components already own — "Cancel", "Close". Panel-specific strings
// stay in `panel.*`: a ui.* key may not name a feature, and a panel is a feature.
const tUi = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
return { t, tUi, locale };
};
@@ -1,4 +1,5 @@
import type { AppRegistryMap } from './types';
import { resolveEntryName } from './types';
type AppPickerProps = {
registry: AppRegistryMap;
@@ -18,7 +19,9 @@ export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
onClick={() => onSelect(key)}
>
<entry.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium whitespace-nowrap">{entry.name}</span>
{/* Resolved here, at render. Caching it would pin the name to the locale the registry was
seeded with — which is before any locale has loaded. See resolveEntryName. */}
<span className="text-xs font-medium whitespace-nowrap">{resolveEntryName(entry)}</span>
</button>
))}
</div>
@@ -2,6 +2,7 @@ import type { ComponentType } from 'react';
import { useCallback } from 'react';
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react';
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
import { resolveEntryName } from './types';
import { useWorkspace } from './WorkspaceContext';
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from '../core/layout';
import { Card } from './internal/Card';
@@ -419,7 +420,9 @@ export const PanelSlot = ({
) : entry ? (
<>
<entry.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
{/* Resolved here, at render. Caching it would pin the name to the locale the registry was
seeded with — which is before any locale has loaded. See resolveEntryName. */}
<span className="text-xs font-medium truncate flex-1">{resolveEntryName(entry)}</span>
</>
) : null;
@@ -12,6 +12,7 @@ export type { WorkspaceViewProps } from './WorkspaceView';
export { WorkspaceLayout } from './WorkspaceLayout';
export { WorkspaceRenderer } from './WorkspaceRenderer';
export { PanelSlot } from './PanelSlot';
export { resolveEntryName } from './types';
export { WorkspaceProvider, useWorkspace, inertInteraction } from './WorkspaceContext';
export type { WorkspaceContextValue } from './WorkspaceContext';
export { usePanelConfig } from './usePanelConfig';
+22 -1
View File
@@ -24,7 +24,17 @@ export type DashboardState = {
* an icon library on anyone — Officer's lucide icons satisfy it without changing.
*/
export type AppRegistryEntry = {
name: string;
/**
* The panel's display name, as a string or as a thunk that returns one.
*
* A thunk because this package has NO dependencies — not even i18n — and resolving a catalogue key
* here would invert the dependency graph: officerdev depends on this, not the other way round. So the
* caller binds the words and this only asks for them. A name that never needs translating stays a
* plain string, which is why the union keeps `string` rather than requiring a thunk everywhere.
*
* Call it during render, NEVER cache the result onto the entry — see `resolveEntryName`.
*/
name: string | (() => string);
icon: ComponentType<{ className?: string; size?: number | string }>;
component: ComponentType<{ panelId: string }>;
header?: ComponentType<{ panelId: string }>;
@@ -37,6 +47,17 @@ export type AppRegistryEntry = {
export type AppRegistryMap = Record<string, AppRegistryEntry>;
/**
* Resolve a panel name for display. Called at every render on purpose.
*
* Memoising this onto the entry, or hoisting it out of the render, would freeze the name at whatever the
* locale was when the registry was seeded — and the registry is seeded once, before the locale has even
* loaded. A locale change would then update every string in the app except the panel titles. That is the
* `useClient()` shape documented in platform/CLAUDE.md, which cost days on the Jellyfin player.
*/
export const resolveEntryName = (entry: Pick<AppRegistryEntry, 'name'>): string =>
typeof entry.name === 'function' ? entry.name() : entry.name;
/** A panel supplied by a screen rather than by the registry. Rendered with `panelId`, exactly as those are. */
export type PanelComponentEntry = {
component: ComponentType<{ panelId: string }>;
+11
View File
@@ -0,0 +1,11 @@
---
theme: agent-coordination
heading: 'Agents coordinating agents'
kind: theme
---
One agent made responsible for another's work — writing its prompts, reviewing what it pushed, deciding
what comes next — so the owner stops being the clipboard between two chats. The mechanism exists and was
used for a whole night on 2026-08-23; what is missing is the part that lets the worker believe it.
See `capabilities/affordances/officer/core/coordinating-another-agent.md` for what an agent is told today.
@@ -0,0 +1,51 @@
---
id: agent-coordination/a-peer-cannot-verify-a-delegation
title: 'A worker cannot verify that the owner delegated to a manager, so it has to ask every time'
theme: agent-coordination
status: open
created: 2026-08-23
date_source: stated
order: 1
---
**A worker agent cannot check whether the owner really delegated to a manager, so the owner has to say so
in person — once per worker, and again after every restart.**
Observed for a whole night on 2026-08-23. One session wrote the localisation phase prompts and reviewed the
work; another did it. The manager said "I have the hand-off"; the worker refused to act on it and was
**right to**, because a peer asserting that a pending instruction is approved is exactly what an agent must
not accept. That rule earns its keep precisely when the owner is asleep, which is when it came up.
## Two problems, and only one of them is about identity
**Identity is nearly free.** Session names are not stable: the manager was `platform-e2`, then
`platform-18`, and the worker `platform-59`, then `platform-72` — both renamed by a restart, in one night.
So a delegation naming a session dies with that session. Transcript ids ARE stable: `656f5c85` and
`c43d1ed0` survived two restarts each, verified. Keying on those fixes durability.
**Verifiability is the actual blocker, and swapping the identifier does nothing for it.** With a stable id
the manager can still only _claim_ delegation. The worker has no way to check, so it must still either
trust a peer's word — the thing it is told not to do — or ask the owner.
## The shape that would work
The same one SendOff's consent model already uses: **the grant is a row the platform holds, not a claim a
peer makes.**
- The owner records it once: _this manager may direct this worker, for this scope_.
- A worker receiving an instruction asks the PLATFORM whether that grant exists, rather than believing the
message. Absence denies.
- Keyed on transcript ids, so it survives a restart of either side.
- Revocable, and revocation takes effect on the next instruction rather than at the next restart.
Rough size: a table, one read endpoint, one write path, a registry entry. Half an hour of code.
## Why it was NOT built on the night it was diagnosed
Because it would not have unblocked the session that was blocked. A worker already running carries the
affordance as it was when it started, and has no instruction to consult a delegation table that did not
exist. So the fix only helps sessions begun after it lands — while the owner relaying one message costs
seconds.
Worth remembering when this is picked up: **shipping the table is the easy half.** The affordance has to
tell a worker to check it, and every already-running session will still be operating on the old rule.
@@ -0,0 +1,36 @@
---
id: agent-coordination/an-agent-cannot-learn-its-own-session-id
title: 'An agent cannot learn its own transcript id, so it cannot sign its own work'
theme: agent-coordination
status: open
created: 2026-08-23
date_source: stated
order: 2
---
**An agent has no reliable way to find out its own transcript id**, which blocks the commit convention in
`CLAUDE.md` — "an agent's commit names the session that made it."
Searched on 2026-08-23. No environment variable carries it. `~/.claude/sessions/` is keyed on process id, not
transcript. The only exposure found is indirect: a background task's output path contains it
(`/tmp/claude-1000/<project>/<transcript-id>/tasks/<id>.output`), so an agent can learn who it is by starting
a job it does not need and reading the path back. That works and it is absurd.
**The mechanism already exists for one case.** `commanderBlock` in `api/command-hq/commander-block.ts` hands
a commander its own transcript id, because a commander needs it to write its pid and spawned records. So the
platform knows the id at turn time and can already put it in a preamble — it just does that for commanders
only.
What to build: give every session its own id the same way. One line in whatever preamble a session already
receives, phrased as a fact rather than an instruction.
Two things to get right:
- **The transcript id, never the session name.** Names are renamed by restarts — two sessions were renamed
in one night on 2026-08-23 — and a commit trailer naming something that no longer exists is worse than no
trailer.
- **It unblocks more than the trailer.** The delegation problem in this theme is keyed on transcript ids too,
and a manager cannot record "I am X" in a delegation row it cannot name itself in.
Until it lands: an agent that happens to know its id adds the trailer, and one that does not leaves it out
rather than guessing.