Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Scanner: an Authorization header value like "Bearer <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
2026-08-23 04:57:48 +00:00
pastilhasandClaude Opus 5 7ce5af88aa todo: close the invisible spawned agent
The endpoint, table and ingest all existed and nothing called them, so the table
read empty and the feature looked unbuilt. Ingest on read means an empty table was
itself the symptom: nobody had looked.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three things worth knowing:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The server's own message in ServerError/CustomError stays verbatim — it is a
response, not UI copy (D1).
2026-08-23 04:10:27 +00:00
pastilhas a444280cb4 deploy report 2026.08.23.03 2026-08-23 04:02:58 +00:00
pastilhasandClaude Opus 5 c0788d15c0 release 2026.08.23.03
Roles become a table and localization phase 0 lands. Both groundwork; nothing in
the UI changes and nothing reads the new table yet.

Schema change, additive only — the roles table is new and no constraint is added
to an existing column, so it cannot fail on existing data. postdb:push seeds it.
No dependency change; the two lockfile lines are the local i18n workspace being
declared as a dependency of officerdev.

The plan names the check that matters: four rows in roles, per host, by query —
db:push can fail and exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:56:07 +00:00
pastilhasandClaude Opus 5 7704867e05 roles are a table, seeded, with nothing depending on it yet
Phase 0 of moving roles out of a TypeScript union. The four the code names become
rows, marked isSystem because each is referenced by name somewhere:
rolePermitsDevTools tests for 'Developer', bootstrap writes 'Super Admin',
users.role defaults to 'Member', and 'Admin' is what rolePermitsDevTools
deliberately excludes. Renaming or deleting one of those is a code change, so the
flag is there for a future delete path to refuse on rather than for the failure to
surface later as an account that cannot log in.

DELIBERATELY NO FOREIGN KEY on users.role. That change cannot go through db:push:
push creates the table empty and adds the constraint in the same run, so every
existing account violates it. Verified against a scratch database — push reported
the violation, created the table, skipped the constraint, and exited 0. The FK is
a separate release, once every host holds these rows.

So this phase is additive and cannot fail, which is what makes it safe to hand to
an unattended rollout across five hosts. Nothing reads the table yet; USER_ROLES
is still the type and authorize.ts is untouched.

The seed runs from postdb:push, so a host gets the rows on its next schema push
with no extra step in a deploy plan. It never overwrites: a description edited on
one server must not be reverted by the next deploy.

Verified here the way the deploy doc now requires — by query, not by exit code.
Four rows present, seed idempotent on a second run, and a second push is a no-op,
which is the proof that the schema and the database agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:52:38 +00:00
pastilhasandClaude Opus 5 04a4ab33dd deploy: db:push can fail and exit 0, so check the database instead
Verified against a scratch database on 2026-08-23. A push whose foreign key
violated existing rows printed the Postgres error, created the new table, left the
constraint uncreated, and returned zero. `bun db:push` wraps it, so a plan step
that reads `bun db:push` can report success over a half-applied schema.

The rollout already survived this twice only because the agent decided on its own
to verify per host by query rather than trusting the exit code. That instinct is
now the instruction.

Also documents the shape that cannot go through push at all — a new table plus a
foreign key onto it from an existing column, where the seed has to happen between
the two halves — and what direct SQL is allowed to look like when a plan calls for
it: the Drizzle schema stays the source of truth, the SQL comes from the plan
rather than from the agent, it is idempotent, push is run afterwards and expected
to be a no-op as proof the two agree, and every host is verified by query.

And one refusal: never `--force`. Its job is answering the destructive prompts
unattended, which on five hosts holding real data is not a convenience.

This is deploy documentation, not commander doctrine — a developer running
something similar should be able to reach the same conclusions, and the commander
architecture does not need to carry it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:49:48 +00:00
pastilhas b640ec486d i18n: default-language picker offers only shipped locales; widen stored bare codes on read 2026-08-23 03:43:35 +00:00
pastilhas 0bd8c6c16a i18n: officer-web catalog goes regional (en-GB, pt-PT), bridge deep-merges app/panel/ui and sets dir 2026-08-23 03:43:01 +00:00
pastilhas 6ac9187c52 i18n: scaffold panel.* and ui.* locale catalogs, wire panelCatalogs into officerdev's barrel 2026-08-23 03:42:53 +00:00
pastilhas dcd11a3b17 i18n: plural selection via Intl.PluralRules, dev-mode miss warning, useFormat, key-parity helper 2026-08-23 03:42:46 +00:00
pastilhasandClaude Opus 5 3dd37d053b localization: every locale ships a complete catalog, no regional overlays
D2 had pt-BR inherit from pt so a base carried the shared bulk and a regional file
carried the differences. Conventional, and rejected by the owner for a product
reason that outranks the engineering one.

The failure mode of an overlay is a missing European Portuguese key falling
through to Brazilian — and the two differ most in exactly the technical vocabulary
this UI is full of. The platform sells into European Portuguese first, so that
failure is a reader getting a neighbouring variant's words on whichever strings
nobody remembered to override, silently.

So every locale is a whole file, translated, or it does not ship. The drift
objection is answered by promoting the key-parity test from advisory to
load-bearing: a locale missing a key `en` has fails the build, so an incomplete
catalog cannot ship at all. Completeness is enforced rather than papered over.

It is also less code — resolveKey looks in one catalog and then English, with no
chain to walk and no merge order — and it removes the question of which file a
given string lives in.

Consequence for the work: a new locale is a full machine-translated pass reviewed
on its own, not a diff against a base. pt-PT and pt-BR get one each, separately,
because the whole point is that neither borrows from the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:21:21 +00:00
pastilhas 64894ad96e deploy report 2026.08.23.02 2026-08-23 02:59:30 +00:00
pastilhasandClaude Opus 5 0a8fe1c2c8 release 2026.08.23.02
SendOff, and the deploy procedure's own corrections from the last rollout.

Schema change — sendoff_devices and sendoff_senders are new, so bun db:push is a
step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:52:30 +00:00
pastilhasandClaude Opus 5 2137d06498 the folder name is served, not derived three times
Mobile asked for `/targets` to carry each person's display name as the folder will
be named, so a picker can honestly say where files will appear. Right request: the
writer already computes that exact string to create a directory, and any client
computing its own would eventually disagree with it — which means telling somebody
the wrong place to look.

So `displayNameFor` is the one definition and the writer calls it too. `/targets`
gains `displayName` per person, and `myFolder` for the whole response: files land
in the RECIPIENT's tree under the SENDER's name, so that is one value rather than
a field on each row. The two are easy to confuse and the distinction matters — a
picker showing `displayName` as the destination folder would name the wrong one.

The web panel now uses both instead of its own copy of the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:50:23 +00:00
pastilhasandClaude Opus 5 ca7d3c657b a send form for SendOff, and no receiving one
/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:47:47 +00:00
pastilhasandClaude Fable 5 b22e9d86c0 COMMS: mobile adopts all three sendoff deviations, one request for /targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 03:45:04 +01:00
pastilhasandClaude Opus 5 2095b6a717 COMMS: SendOff backend is in, and it differs from the draft in two places
Opens the channel for mobile. The endpoints as built rather than as specced, both
deviations with the reasoning, and the parts that need no app build at all.

The two that matter: device registration moved to /api/sendoff/devices, because
/api/devices collided with the push-token registry and those are different
populations; and toUser is an account id rather than a username, because
users.name is nullable and a username is not a reliable address.

Both are cheap to change here and expensive to guess at, which the note says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:41:28 +00:00
pastilhasandClaude Opus 5 4ea25d76c8 todo: the SendOff size limit should be per user, from settings
512 MB was chosen as enough-for-now on the day the feature landed, and it applies
to every account equally. The owner asked for it per user before the code was an
hour old.

Notes the adjacent question while it is in view: a size limit bounds one send, and
nothing bounds what a sender accumulates in somebody's tree over a month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:34:15 +00:00
pastilhasandClaude Opus 5 b94a5e90b8 SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push.
No staging area, no transfer table, no ack, nothing to expire. Every account
already has a home, a file browser over it and a Files app on the phone, so a
delivered file is an ordinary file from the moment it arrives.

`Documents/SendOff/<machine>/` for something you sent yourself, and
`Documents/SendOff/<sender>/` for another account's files. The home is the one
that account actually browses — the owner's real login home, a member's managed
home — because the two are different directories and writing to the wrong one
makes a delivery invisible rather than wrong.

CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant
answers "may this role send files", and the person who should decide whether
somebody may write into your home is you. Absence denies, and a sender who was
not allowed does not see the recipient in their picker at all — the endpoint
answers 404 for "no such account" and for "did not allow you" alike, so it cannot
be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving
still needs their say-so.

The sender's folder name comes from the token and never from the payload, so it
cannot lie about who put a file there. Names are sanitised to one path segment and
filenames to their basename — verified: `../../../../etc` becomes a literal
folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside
the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the
sender cannot see the directory and would have no way to know.

The push carries the folder name and a file count, and the catalogue renders
"3 file(s) from andre" on the device. That relaxes "content does not travel" by a
folder name and an integer — the owner's call, on the grounds that the file never
leaves the tailnet and a notification nobody can act on is not worth sending. A
filename would be content; whose folder it is, is addressing.

`kind: 'app'` rather than `confined` even though it writes into a home: the path
is one fixed shape chosen by the server from the caller's token, so there is
nothing to traverse and nothing to read back. Requiring a Linux user would mean an
account without one could not be sent a file, which is most of the point.

Backend and verified end to end. The web send form is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:32:09 +00:00
pastilhasandClaude Opus 5 510fa3f524 deploy the tag, and stop spending a minute on a host that is known to be down
Two things the 2026.08.23.01 rollout showed.

`edge-pertento-bridge` is down and staying down for some days, and the deploy
agent had no way to know: it spent four attempts and up to sixty seconds apiece
before reporting it unreachable. Correct behaviour against a list that had not
been told, so `docs/servers.md` now has a Deliberately skipped section, with the
host's row kept verbatim inside it so restoring it is one move. A host there is
named as skipped in the report and costs nothing else.

And the procedure said `git pull --ff-only`, which follows `master` rather than
the tag being deployed. A docs commit landed mid-rollout, so every host came up on
`e6f1388a` — one past `deploy/2026.08.23.01` — and "deployed the tag" was true of
none of them. Harmless this time, and it need not have been: it is now fetch plus
`merge --ff-only <tag>`. A host that is AHEAD of the tag is reported, never reset.

Also lands the report itself, which the agent wrote and could not push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:12:45 +00:00
pastilhas b5d9f591ee deploy report 2026.08.23.01 — four hosts, two unreachable
edge-pertento-bridge never completes an SSH banner exchange; production-pertento-bridge
presented a changed host key and was left alone. Neither was pulled or restarted.
2026-08-23 02:07:10 +00:00
pastilhasandClaude Fable 5 e6f1388aa6 plan: localizing the web UI, and the scanner that sizes it
not built. an execution plan written to be handed to an agent who did
not have the conversation, so it carries the reasons and not just the
steps.

what it found: there is already a hand-rolled i18n package with typed
keys, two catalogs and 46 keys — and exactly three components using it.
against 884 distinct hardcoded strings across 436 files. so the system
is not missing, it is 5% deployed, and that gap is the job.

the decisions worth arguing with are D1 and D6. D1: the server should
never emit user-facing prose, it returns a stable code and the UI owns
every word — with the trap that a code must not leak more than the prose
did, or a deliberately vague 403 becomes an enumeration oracle. D6:
catalogs per workspace rather than one file, because one 900-key en.json
is a merge conflict on every parallel edit.

the scanner reproduces the numbers in the doc and doubles as the guard
once an area reaches zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:57:22 +00:00
pastilhasandClaude Opus 5 dee0b89bd7 release 2026.08.23.01
The commander creation flow rebuilt end to end, the delete path that actually
removes one, /minesweeper, and docs:check.

Schema change — command_hq_spawned is new and command_hq_sessions gains name, so
bun db:push is a step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:56:03 +00:00
pastilhasandClaude Opus 5 aa35428dde usage limits are read live, and a cached reading belongs to one account
`limitsCache` was keyed on nothing. Switching account changes which credential
`readOauthLogin` returns, and the cached entry was served anyway — so the panel
showed the previous account's percentages under the new account's name for a
minute, and on a failed probe for as long as the probe kept failing. Reported
maxed out on one account, switched, and the numbers did not move.

So the limits have no TTL now: every request probes. They are per-account and they
are the number somebody opens the panel to read, so a stale one is worse than a
slow one.

The store stays, for the failure path only, and keyed on the account it was
measured for. A flaky request degrades to that account's own last reading rather
than to a blank panel or — much worse — to somebody else's numbers. A reading that
cannot be attributed gets no fallback at all rather than a shared one.

The client's five-minute stale window went with it: that query is only the instant
first paint, and holding it meant painting the old account's stored row until the
refresh landed.

The transcript scan keeps its cache. It reads ~90MB and its answer is the same
whichever account is signed in — it counts what this machine wrote, not what an
account is allowed — so a switch does not invalidate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:54:05 +00:00
pastilhasandClaude Opus 5 854f957058 a recorded pid means armed, whatever route started the loop
`watcher_armed` is set on one path only — the background-job dialog's
hand-back-and-re-arm. A commander that starts its loop as an ordinary tracked
background job records its pid correctly and still reports unarmed, so Command HQ
shows nothing to stop. That is the one thing the column exists to make possible.

The pid file is the fact and the flag is a claim, so the fact wins: armed if the
row says so OR a recorded pid is alive. Verified against a live commander that
had armed itself and read as unarmed.

Deleting it already worked — that path reads the record rather than the flag —
so this closes the gap between what the platform could do and what it admitted to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:44:06 +00:00
pastilhasandClaude Opus 5 edeb434233 the opening depends on what it found, and it never starts its own work
"Reply with two sentences and one question" was fitted to one directory — the one
that already had the whole procedure written down. Summarising is right there and
useless in an empty folder, where the commander has nothing to summarise and has
to ask what the job even is. So the brief asks it to decide which case it is in
and gives it both openings, and drops the sentence count for "whatever the answer
honestly needs and no more".

It also states the boundary that both comparison runs crossed: you watch for the
thing and respond to it, you never cause it. Both offered to tag fifty commits
and roll them out — and the tag is the trigger, so that is a watcher starting its
own work and then watching for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:22:44 +00:00
pastilhasandClaude Fable 5 8b4a5b3b0b docs: SendOff — files land in the user's tree, a push says where
Spec from the mobile side, v2. One endpoint (POST /api/sendoff/send — the one
sanctioned door into another user's filesystem, sender identity from the token),
a push category whose route deep-links the Files apps, and a minimal devices
registry for the send-to pickers. The mobile half is already shipped against
this and degrades silently until it exists.

Lives here rather than in COMMS because it is a spec to implement, not a
message: the repo is where it can be corrected as the implementation teaches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:02:38 +01:00
pastilhasandClaude Opus 5 730aeab6c5 the finished message wins over the buffer that was streaming it
A reply rendered as "ipped." — first two characters gone — while the durable log
held it complete. Nothing was lost; the client discarded it.

The streaming buffer is an accumulation of deltas and is only as good as the
socket. The `assistant:text` event that ends the stream carries the whole message
and arrives right after. Committing the buffer and ignoring that text means a
single dropped frame is permanent, even though a correct copy came in immediately
behind it.

Losing a frame is not exotic: restarting the server mid-turn does it, which is how
this was found — three times tonight, by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:54:32 +00:00
pastilhasandClaude Opus 5 2651c45733 put the rules where they are guaranteed to arrive
Three intakes in a row opened with a five-bullet audit, each one different, and
every rule written to stop that lived in the Commanders skill. The skill was
never read: zero Skill calls, zero mentions, in the whole session. What tells a
commander to read it is the preamble, and the preamble cannot exist on the first
turn — no row and no transcript id yet — which is the one turn this is about.

So turn one has been the model's default behaviour throughout. That explains the
length and it explains the variance, which is the more useful half: same prompt,
same directory, wildly different openings, because nothing in the prompt said
what the opening should be.

The brief is the prompt, so it always arrives. It now carries the constraint —
two sentences and one question, no headings or bullets or paths or hashes, no
"Oriented", no claiming to have verified — and tells it to read the skill for
everything else. The constraint is on what it says, not on what it does; reading
as long as it likes was never the problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:47:50 +00:00
pastilhasandClaude Opus 5 91bc4ac74f the platform keeps the commander's name
The name only ever lived in the transcript's summary record, which the harness
cannot write until the first turn ends. So the list showed the opening brief
until then, while the header beside it showed the name from local state, and
every fix for that was a cache-invalidation trick timed against somebody else's
write. Three attempts, all of them fixing the symptom.

`command_hq_sessions.name` is written when the commander is filed, before the
first turn produces anything, and the list and detail routes prefer it. Nullable,
so rows filed before the column fall back to the transcript. The rename still
happens — it is what titles the conversation everywhere OUTSIDE Command HQ — it
just is not the source of truth any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:46:17 +00:00
pastilhasandClaude Opus 5 a51a969cd0 a file with no turns in it is not a conversation
Four "(untitled) · 0 msgs" rows accumulated in the sessions list from one evening
of commander testing. They are transcripts containing only harness metadata —
last-prompt, ai-title, atis-latch — written after a session was deleted or before
it ever sent anything. Filtered at the list, not deleted on disk: the files belong
to the harness, and a cleanup racing it would be the same class of bug that
produced them.

Which is also the cause of two of the four. The delete path resolves officer's
session key from two in-memory maps, and both answer only for a session younger
than the process. After a restart neither does, so no kill is sent, nothing is
awaited, and the transcript is removed while the harness is still writing — which
writes it back. Same failure as the events cleanup, same durable answer: the event
rows carry both ids, so ask them when the maps cannot say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:28:56 +00:00
pastilhasandClaude Opus 5 5c3ffa7a06 the watcher loop lives next to the claim it depends on
prompts/deploy-commander.md treats the poll loop as something an agent rewrites
from scratch on every arming. That is also how the same defect keeps arriving:
the loop left at data/deploy-watch.sh gated on "$v.done" — a file written only
on SUCCESS — and never called deploy-claim.sh at all. A failing release
therefore spawned a fresh paid agent every poll. Measured against that script
with a stub agent: 10 spawns in 20 seconds at a 2s poll, which is 30 an hour at
its real one, all night.

That is exactly the storm deploy-claim.sh exists to make impossible,
reintroduced in the one place the claim cannot defend — the caller that decides
whether to call it. So the loop moves into the repository beside it.

The baseline is the claim, not the success: a release that failed is handled,
and handled means a human looks at it. Arming today fires on nothing, because
all six existing releases carry a .done and deploy-claim.sh honours one.

The claim is taken before the spawn, which means the child has to be told it
holds one. It is about to read the procedure, whose claim instruction is
addressed to whoever reads it — an obedient child claims for itself, gets exit
1, and correctly refuses the deploy it was started to perform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:20:56 +00:00
pastilhasandClaude Opus 5 1155449e4b a transcript with nothing in it can say nothing
"Send a message to start" is right on the chat screen and wrong under a setup
card that already says what to do — it contradicts the Start button above it and
takes 200px of height doing so, which is what squeezed the new-commander pane
into a scroller a third of the panel tall.

`emptyState` is optional and three-valued on purpose: undefined keeps the
default, a node replaces it, null shows nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:16:28 +00:00
pastilhasandClaude Opus 5 a2ddf258b6 "started" is a latch, not a message count
The pre-start pane keyed off `messages.length === 0`, which stands in for "not
started yet" right up to the moment it matters. Lose the socket mid-turn and the
messages come back empty, so the setup card and its Start button reappear on a
commander that is already running — and pressing Start sends the opening brief a
second time.

That happened tonight, after a restart I ran while a commander was live: the turn
died, the pane reset, Send re-sent the brief, and the commander was asked to
introduce itself twice. The owner deleted it and started over, which was the right
call and should not have been necessary.

So it latches. Once anything says this conversation exists — a message, a
transcript id, a turn in flight — it stays started. Deliberately not derived from
live state, since surviving the moment that state goes empty is the entire job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:11:04 +00:00
pastilhasandClaude Opus 5 5c802b07b5 a command dropped for want of a sidecar has to say so
`sendFire` looked up a sidecar, and if none was registered it did nothing.
No log, no error, no return value — the command simply stopped existing. That
window is real and entirely predictable: every `pm2 restart officer` has one
while the sidecars reconnect, and somebody clicking a button has no idea they
are in it.

The cost landed hardest on answering a question. `answerClaudeQuestion` in the
agent documents this exact failure in a careful comment and cannot report it,
because the command never arrives to be reported. So the question card kept
spinning, which from the person's side is indistinguishable from a slow agent,
and the only move left is to send another message — the worst possible thing to
do to a waiting tool call. Observed tonight, on a restart I ran myself while a
commander was mid-question.

So `sendFire` returns whether anyone took the command and warns when nobody did,
and the two paths where a person is actively waiting — a question answer and a
plan decision — tell them instead of leaving the card to spin.

The other five callers are unchanged and now leave a log line, which is the part
that was missing when this was diagnosed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:06:30 +00:00
pastilhasandClaude Opus 5 383df51a99 the new-commander pane scrolls, because the panel it lives in zooms
Setup card, start button and guide were three `shrink-0` siblings, so they took
whatever height they wanted and nothing could scroll past them. The guide carried
its own `overflow-y-auto`, which never once engaged — `shrink-0` had already given
it its full natural height, so there was nothing to overflow.

Fine at 100%, and the panel system is zoomable. At 120% the setup ran off the
bottom of the panel with no way to reach it, which is the zoom somebody uses
precisely because they want to read it.

So the scroller moves up to where the height is actually constrained, and the
composer keeps `shrink-0` before the first message so it stays pinned at the
bottom whatever the zoom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:02:25 +00:00
pastilhasandClaude Opus 5 3551a3af71 give the commander the name it was given
Named "Platform Deploys" and started in the platform checkout, the second intake
asked whether we were working on the urgent routing item or on commanders. The
name is the one thing the owner says before anything else — the whole reason they
type it into an empty field — and it went nowhere near the model. It was used to
title the transcript and for nothing else.

So the opening brief now carries it, and says what it is: not decoration, the
subject, chosen before anything else was said. Reading a directory with a subject
in hand is a different exercise from reading it cold, which is what the first two
attempts did.

Also invalidates the per-commander query alongside the grid when a turn ends. The
first pass at that fixed the list pill and left the header beside it showing the
opening brief, so one commander displayed two different wrong names depending on
which panel you looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:53:58 +00:00
pastilhasandClaude Opus 5 038251f9c9 refresh the commander grid when its first turn ends
The name is written server-side into the transcript's summary record on the first
`result` — it cannot land earlier, because the transcript does not exist until
then. Nothing told the client, so the list kept the title it fetched beforehand.

For a self-started commander that title is the opening brief, so the pill read
"I have given you a name and a working directory…" next to a header showing the
real name from local state. Two names for one commander, both correct about
different moments.

The falling edge of `isGenerating` rather than a poll or a shorter staleTime:
it is the one moment the server is known to have written something the list is
displaying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:44:09 +00:00
pastilhasandClaude Opus 5 4f590f8879 pin both id spaces with a test that fails against the old code
Verified by reverting the fix and watching it go red on exactly the case the live
delete hit — the post-restart one, where no caller can supply a session key. A
regression test never seen to fail proves nothing.

It touches the real database, which the rest of the suite avoids on purpose, and
that is the point: this defect was never in a decision, it was in which column a
`delete` matched. A test built on a fake store would have passed against the
broken version and the fixed one alike.

Contained by construction — every id is a synthetic uuid in a block no harness
produces, nothing reads live rows, nothing deletes by anything but those ids, and
each test removes what it wrote.

The third case is the one that will earn its keep later: it asserts a delete
leaves a NEIGHBOURING session's events alone. Resolving a key from row payloads is
exactly the kind of query that starts matching too much when somebody widens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:19:07 +00:00
pastilhasandClaude Opus 5 c04fa15eb5 ask the events table which key a transcript belongs to, instead of a live map
The id-space fix worked only for conversations younger than the process. The
caller resolves a session key from an in-memory map filled as sessions announce
themselves, so anything created before the last restart resolves to null and the
key list arrives empty — which is exactly what the first live test did. Everything
else cascaded; the events stayed, silently, for the one reason the code could not
report.

The answer was already in the rows. Every event carries `claudeSessionId` in its
payload: durable, restart-proof, and the one place the two id spaces are written
down beside each other. One event carrying that id resolves the key for all of
that session's rows — including the majority that carry no id of their own.

`sessionKeys` stays as a fast path for a live session and is now a hint rather
than the answer. A cleanup that only works for conversations younger than the
process is not a cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:16:42 +00:00
pastilhasandClaude Opus 5 2aef23c8ba a commander starts its own conversation, instead of being handed one
Starting a commander meant writing a good opening prompt, which is the hard part
of the whole thing — so in practice somebody else wrote it and pasted it in. That
is not a flow, it is a person acting as the intake form.

Now the two decisions that cannot be guessed stay with the owner (a name and a
working directory) and the rest is a button. It sends a brief the MODEL reads —
"I have not told you what I want yet, orient yourself and ask me one question" —
while the transcript shows "Set me up.", which is what a person would have typed.
That split is `effectivePrompt` vs `displayText` and the chat path already had it.

What to ask, in what order, and to read the directory BEFORE asking anything, all
live in the Commanders skill under "When you started yourself". Deliberately not
here: capabilities are re-read from disk every run, so rewording a question costs
nothing, while the same words in commander-block.ts cost a restart each time. The
block carries a pointer and no doctrine.

Orienting first is what makes this work on a real request. Told "automate the
platform deploys", a commander that has read the directory finds `deploy/`, the
previous releases and the deploy skill, and asks whether that is still how you
want it — instead of inventing a scheme next to the one already there.

`sendPrompt` gained an options object rather than a tenth positional parameter.
Nine is already past where a call site reads as a row of undefineds.

The composer stays enabled beside the button: typing your own opener still works,
it just is not the only way in any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:04:25 +00:00
pastilhasandClaude Opus 5 eefcb06e35 stop the harness before deleting its transcript, and wait for it to actually stop
Deleting a commander removed its transcript and the `claude` CLI wrote it
straight back. Verified rather than inferred: the file returned with 22 lines,
every one timestamped after the delete, and the deleted commander was sitting in
/chat as "(untitled)" — no longer in `command_hq_sessions`, so nothing filtered
it out. Delete a commander, get an untitled chat back.

Two causes, and the first one hid the second.

The kill never happened. `killClaudeSession` was imported straight from
`claude-manager` into a route that runs in `officer`, while the session map lives
in `officer-claude-code`. It read an empty map in the wrong process and returned
false every time. `claude:kill` and `killClaude` already existed for this; the
route just wasn't using them.

And killing is not being dead. `abort()` returns while the CLI is still
unwinding, and officer does not own that file — the CLI does. So a session now
carries an `exited` promise resolved by the same exit hook that already reaps it,
`claude:kill` replies only once that resolves, and both delete routes await the
reply before touching the filesystem.

Bounded at 5s and never fatal. A child that will not die delays a delete by
seconds instead of hanging it, and a sidecar that is down leaves the caller with
exactly the race it already had.

`markExited` fires before `dropDeadSession`'s identity guard, deliberately: a
waiter has to be released even when a newer session has taken its key, or it
blocks for the full timeout on a process that died on schedule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:55:29 +00:00
pastilhasandClaude Opus 5 8ac3ca7fe5 forgetting a conversation needs both of its ids, not one twice
`chat_session_events` is keyed on officer's SESSION KEY. The other three tables
are keyed on the harness's TRANSCRIPT ID. Both are uuids for the same
conversation, and `forgetChatSessions` took one list and passed it to all four —
so the events delete matched nothing. Not sometimes: by construction, every time.
A deleted conversation left its entire durable output behind.

It survived two verifications because counting events BY TRANSCRIPT ID also
returns zero by construction, and zero read as "cleaned". What settled it was
reading an orphaned event instead of counting it — the payload carries both ids
side by side and says outright which key the row is filed under.

So the parameter is now named for its id space. That is the whole defence and it
is the only one available: nothing else distinguishes the two, since both are
uuids of the same shape, and a positional `string[]` cannot be got wrong loudly.

Empty `sessionKeys` is legitimate and common — a session spawned by `claude -p`
outside officer's websocket never had a key and never wrote an event, which is
exactly a commander's children.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:50:29 +00:00
pastilhasandClaude Fable 5 03e7ca39c3 minesweeper: narrow send(), and tell the ws mirror about the provider
two failures another session found in a shared checkout, both mine.

send() took a plain Uint8Array, whose default buffer type is
ArrayBufferLike and may be a SharedArrayBuffer — WebSocket.send takes
only BufferSource. narrowed to Uint8Array<ArrayBuffer>, which every
caller already builds and which the terminal relay already does.

registry.test.ts keeps REAL_WS as a hand-written mirror of server.tsx's
handlers map, and its own comment says that drift is what it catches. it
caught mine: the route and the registry entry landed, the mirror did not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:22:23 +00:00
pastilhasandClaude Fable 5 3254925506 remove mint-tmp.ts, committed by accident
a throwaway that mints an owner jwt for end-to-end testing. its cleanup
never ran because the command that created it timed out, and the next
`git add -A` swept it in. no secret in the file — it reads the signing
key at runtime — but a token minter does not belong in the tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:16:01 +00:00
pastilhasandClaude Fable 5 a3fd3ade01 minesweeper: hold the client in a ref, not in a dependency list
confirmed playable after this.

useClient() rebuilds its verbs on every render, so `refresh` — which
listed `client` — got a new identity every render, its mount effect
re-ran every render, and each run set state and caused another render.
an endless fetch loop that starved the socket, so onReady never ran and
the panel sat at 'connecting…' forever.

the backend was never at fault: the relay completed a full rfb handshake
through wss://platform.officer.dev with a real token before this was
found. ruling the server out is what left only the client.

same trap and same fix as the jellyfin playback reports, which CLAUDE.md
already warned about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:15:44 +00:00
pastilhasandClaude Opus 5 3837af657d live sessions default to agents only, and you ask by name for a commander
`?kind=` was opt-in, so a client that said nothing got everything — which is the
wrong default for the one caller that cannot cope. The phone swipes between live
chats one at a time, has no room for tabs and no way to skip a row, so a commander
in that list is a commander you are stuck on. Defaulting to `agent` means
forgetting the parameter can only ever under-report.

The two callers that want both now say `kind=all` explicitly, and that is a fair
price: they are few, they are on screens built to show both kinds, and they are
the ones positioned to notice when the answer is wrong. `counts` still describes
every live session whatever the filter, so a filtered caller renders both tab
numbers from one request rather than two.

Also corrects useLiveSessions' comment, which said `isCommander` was derived from
the cwd. It stopped being true when a commander gained the right to choose its own
working directory — it comes from the `command_hq_sessions` row, and the directory
answers nothing.

Unrelated but caught by docs:check on the way past: the registry gained a
nineteenth entry (minesweeper, execution) and CLAUDE.md still said eighteen, which
also left the generated platform skill two lines stale. Both corrected; the check
is green again. The minesweeper feature itself is half-landed and not mine —
`rfb.ts` fails typecheck and its registry test fails on a websocket that is not
served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:11:12 +00:00
pastilhasandClaude Opus 5 749c80c877 the server says which live sessions are commanders, and can return just one kind
/chat/live already classified every row from `command_hq_sessions`; what it did
not do was filter, so each client repeated that itself. Fine for the web list,
which shows both tabs at once. Wrong for the iPhone app, which swipes between
live chats, has no room for tabs, and must simply never be handed a commander.

`?kind=agent|commander` does it server-side. Counts always describe the whole
set regardless of the filter — the web tabs show both numbers side by side and
both must be right whichever tab is open, so a filter that also hid the other
count would force a second request to render one screen. An unrecognised kind is
a 400 rather than "everything": a mistyped `?kind=agents` silently returning
commanders is the precise swipe-order bug this exists to prevent, so the plural
forms are accepted too, since the client's own type is already plural.

And /chat/sessions no longer tags rows it is about to delete. It called
listCommandHqSessionIds twice — once to mark commanders, once to remove exactly
those rows four lines later — so isCommander was false on every row that
survived. A field that could only ever lie, plus a second identical query per
request to produce it. A list that excludes commanders has nothing to say about
which of its rows is one; withCommanderFlag had no other caller and is gone.

Not restarted: the shared checkout currently fails typecheck on another agent's
untracked Minesweeper work, and a restart is when half-applied work ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:01:28 +00:00
pastilhasandClaude Fable 5 af918251d3 /minesweeper — a wine session streamed to the browser over RFB
a websocket byte relay onto x11vnc's tcp socket, the same shape as the
terminal's relay onto the pty sidecar. this is the piece plugins/desktop
could not have: a plugin cannot claim a websocket provider.

the rfb client is ours rather than vendored novnc — raw and copyrect,
one pixel format we dictate so a rectangle memcpys into ImageData. we
control the server, so the other eight encodings are surface we would
carry and never run.

execution, so owner-only: wine runs as the owner, which makes the game's
menus file dialogs over the owner's home.

two bugs worth keeping. the token key is BEARER_TOKEN, not token, so the
upgrade 401'd before any rfb byte moved. and the /status health check
opened a tcp connection to prove liveness — libvncserver blocks its
accept loop peeking for a websocket handshake, so a polled health check
filled the backlog and locked out the real viewer. a liveness check must
not be indistinguishable from a client; it reads /proc/net/tcp now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:39:35 +00:00
pastilhasandClaude Opus 5 ae27478a27 the platform keeps its own transcript-id mapping, because the sidecar's is invisible to it
The commander block still was not arriving. Not the delivery mechanism this time — the LOOKUP. Building the
block needs the transcript id, the platform had only officer's session key, and it asked the sidecar's
`state.ts` for the mapping. That map lives in the sidecar's memory; the platform process has its own empty
copy of the module, and re-reading the state file from here does not find a live session either. Verified
both ways before changing anything.

So the platform keeps its own map, filled from the `session:claude` message it already handles — the one
moment the mapping is announced. It is not derivable from anything: it is stated once, mid-turn, and
whoever needs it either records it as it passes or does without.

The delete route had the identical problem for the same reason and now asks the platform's map first, keeping
the sidecar lookup as a fallback for a session that predates this process.

Third distinct bug today from these two ids, and they are all the same shape: a lookup keyed on one id space
against data held in the other, failing silently because both are UUIDs and neither side can tell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:32:25 +00:00
pastilhasandClaude Opus 5 0bfad0e592 deleting a commander ends its session, not just its loop
Its own test caught this too. Delete removed the row and the transcript, and the transcript was BACK a minute
later — because the commander's `claude` process was still alive and rewrote it. That happened twice tonight
and both times it read as the delete having failed.

Killing the loop was never enough: the loop is a grandchild of the session, so ending it leaves the session
running, still holding the conversation open and still able to write.

So the session is ended first, with `killClaudeSession` — which already existed and does the whole teardown
(abort, close input, settle pending plans and questions). It takes officer's session key while this route
holds a transcript id, so the map is read backwards through `findSessionKeyByClaudeSession`, which exists for
the same reason a refreshed browser holds only the uuid.

Ending the session takes its children with it; the pid-based stop still runs afterwards, catching anything
that outlived its parent. The response reports `sessionKilled` beside `processesStopped` so the two are
distinguishable in a report rather than one number standing for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:25:39 +00:00
pastilhasandClaude Opus 5 aad382f4be a commander is told where to record itself on a TURN, because the preamble never reaches it
Its own first test caught this: the commander armed a perfectly good watcher, established a baseline
correctly, and wrote no loop-pid — so deleting it would have left the loop running.

The block was composed into the system preamble, which `createSession` builds ONCE, on a conversation's
first turn. For a commander that is exactly the turn where the block cannot exist: no command_hq_sessions
row, no transcript id, so `commanderBlock` returns null. Every later turn reuses the session and never
rebuilds the preamble. So a commander never received it at all — the "first turn gets no block" limitation I
closed with a rule was really "no turn ever gets it".

It rides `effectivePrompt` now: the string the model sees, as against `displayText` which the transcript
shows. Same seam the background-job dialog already uses for its hidden preamble, so nothing new — the agent
gets the paths without them appearing in the conversation. Once per session, on the first turn where the
block resolves, and it stays in the conversation's history from then on.

`briefedSessions` is in memory, so a restart re-briefs. That is the safe direction: a few hundred tokens on
one turn against a commander that was never told and therefore cannot be stopped.

The preamble path is removed rather than left dormant — a dead branch that reads as the mechanism is how this
went unnoticed in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:19:28 +00:00
pastilhasandClaude Opus 5 fc8ce04f2b the commander's name shows the moment it is typed, not after a refresh
Naming works — confirmed on a live commander, which now carries `{"type":"summary","summary":"Inbox
Summarizer"}` in its transcript. The instrumentation showed exactly the predicted shape: two attempts 2.8
seconds apart, the first on `session:claude` before the transcript file existed and the second on `result`,
which is the one that landed. The retry was the fix; the guess about WHY was right and the evidence was worth
having rather than assuming.

What was left was cosmetic and looked like the same bug: the pane still read "Untitled commander" until a
refresh. It could not have read anything else — the rename lands at the end of the first turn, and this bar
has no fetched title because a brand-new commander has nothing to resume. So it showed the one thing on
screen nobody had chosen, while the chosen name sat in a field two inches above it.

It takes the name from state now, which is where it already was.

Instrumentation removed. It earned its place: three attempts at this bug were reasoned and two of them were
wrong, and the log settled it in one run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:01:33 +00:00
pastilhasandClaude Opus 5 cb51f35cad deleting a conversation forgets what the platform stored about it
Found by a sanity check after two commanders were deleted through the UI: the Command HQ tables cascaded
perfectly and four OTHER tables kept a row each, for ever. 24 orphaned rows in chat_session_models, 26 in
chat_session_events, against 14 live transcripts.

Nothing cascades because there is nothing to cascade FROM. All four are keyed on a transcript id, and a
transcript is a file the harness writes — there is no row for a conversation, so the database cannot help and
the delete path has to be explicit. It was not.

Three of the four are inert: a model, a bell preference, a durable copy of turn output nothing will ask for
again. `nudges` is not — it is a SCHEDULE, read by the tick in nudger.ts. A nudge belonging to a deleted
conversation is a timer that keeps firing at something that no longer exists. That is what turned this from
untidy into worth doing now.

deleteClaudeSession returns the IDS it removed rather than a boolean, because a `/clear` chain is several
transcripts presenting as one conversation and forgetting the head while orphaning its parts is the same bug
at a smaller scale.

THE TRAP THAT CHANGE INTRODUCED, caught before shipping: every call site said `if (deleteClaudeSession(...))`
and an empty array is TRUTHY in JavaScript, so a delete that found nothing would have reported success. The
typecheck was clean — `string[]` is a perfectly good truthy expression. All three sites test `.length` now.
Fifth silently-wrong thing today that a typecheck could not see.

Cleared the existing orphans with the same function the delete path now uses, after checking the one thing
that would have made it wrong: whether any of them belonged to a member, whose transcripts live in a home
this scan does not read. There is one account on this machine, so they were all the owner's.

Two chat_session_events rows appeared again immediately after, and are not a leak: the durable log is written
independently of the transcript — that is what makes turn output survive officer being down — so a session
that has just begun legitimately has events before a transcript exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:54:23 +00:00
pastilhasandClaude Opus 5 a6e7f64ef2 todo: a commander should suggest the cheapest model for each half of itself
Wanted 2026-08-22, for later. Before arming anything, a commander weighs how hard the job actually is and
proposes which model each half should run on.

TWO models, which is what makes it more than a generic "use something cheaper" note. A commander's own turns
are conversation plus one piece of bash; the agent its loop spawns does the real work and its difficulty is
set by the job. The deploy commander is the worked example — its own turns were unremarkable on Sonnet while
the spawned agent judged six hosts, parsed a plan, decided what to skip and wrote an honest report.

Suggested, never imposed: the failure mode of choosing too small is not a slower answer, it is a watcher
firing on the wrong condition or an agent half-finishing a deploy.

Recorded with the reason it is not urgent: nothing measures whether the choice was right. A spawned agent's
cost is not captured at all yet — the column exists and is always null — so a commander suggesting Haiku has
no way to learn it was wrong. This wants the cost work first, or at least the knowledge that until then it is
a judgement with no feedback loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:39:26 +00:00
pastilhasandClaude Opus 5 f065e2c0f8 the disabled composer says why, instead of inviting you to type
Caught in a screenshot before any testing: the new-commander composer was correctly disabled and still read
"Type a message...". `placeholder` was declared TWICE on the same textarea — the reason I added, and the
original below it — and JSX takes the last one. So the gate worked and was completely invisible, which is
the worst version: an input that refuses you without saying what it wants.

One placeholder now, carrying the reason when disabled and the usual text otherwise.

Fourth prop-shaped mistake today, and a new variant: not a prop that does not exist, but a real prop
silently overwritten by a second copy of itself. A typecheck cannot see either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:35:42 +00:00
pastilhasandClaude Opus 5 1c47d0bba7 a commander is named and given a directory before it can be spoken to
The third of the three, and the one the owner will actually see. The new-commander card now collects both
decisions above the guide, and the composer refuses input until they are made — with the reason in the
placeholder rather than a validation message after the fact.

Both are chosen first because neither is cheap afterwards. The directory decides where every relative path in
every turn resolves and which project group holds the transcripts, so changing it later strands a
conversation away from its own history. The name has to reach the transcript the moment it exists, because
Claude Code names a new session from its first prompt and the platform's rename only wins by getting there
first.

The directory is a BUTTON, not a text field: it is an absolute server path, and typing one is how a commander
ends up running somewhere that does not exist. `DirPickerModal` already existed for /chat and browses the
account's own tree.

CAUGHT BEFORE SHIPPING, and only by checking: I first passed `cwd` and `commanderName` as `useChat` options
and `disabled` as an EmbeddableChat prop. None of the three existed. It typechecked anyway, because object
SPREADS bypass excess-property checking — `...(cwd ? { cwd } : {})` is invisible to it. That is the fourth
time today a silently-ignored prop has cost something, so the props are real now:

- `commanderName` is a `useChat` option on the send payload, and rides EVERY turn rather than only the first.
  The server applies it once; sending it again costs bytes. Sending it once would lose it if that turn failed
  and was retried — and a commander named after its second prompt is exactly what this prevents.
- `disabled` is a real EmbeddableChat prop carrying a reason, threaded to the textarea's NATIVE disabled —
  which also removes it from the tab order and blocks a paste, neither of which a guard on send would do.
- the directory goes to EmbeddableChat's existing `cwd` prop, because that is what feeds `sendPrompt`'s
  `cwdParam`. Two values chosen in one place travelling by two paths, noted where somebody would otherwise
  have to discover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:11:12 +00:00
pastilhasandClaude Opus 5 2bd8701b15 a commander runs where the owner chose, and Command HQ stops scanning one folder
Two of the three things the identity change unblocked.

THE WORKING DIRECTORY. `resolveChatCwd` forced `getCommandersDir(email)` and ignored `msg.cwd` entirely —
every commander shared one empty directory whose only purpose was to BE a cwd, because the cwd was how a
commander was recognised. It honours the choice now, resolved through the same `resolveCwd(home, …)`
containment the ordinary chat branch uses, so "client-chosen" still cannot mean "anywhere outside the
account's tree". No fallback to the old shared folder: the UI will require a directory, and an absent one
falls back to the account's home, which is the honest last resort rather than a silent special case.

THE LIST. Command HQ found its commanders by scanning that one directory, which would now find only the ones
that happen to still live there and silently hide every other. It reads its rows and loads each transcript by
id — `loadClaudeSessionSummaryById`, added here — a handful of lookups rather than a directory walk. A row
whose transcript is nowhere is treated as absent exactly as a missing file was.

`POST /purge` had the same scan and it was the dangerous one: unfiling every commander that chose a different
directory, from a housekeeping route, when the rows are what identity now depends on.

Three smaller corrections that came with it:

- rename and delete passed the commanders directory as their cwd argument. It is documented as a HINT that
  falls back to locating the transcript anywhere, so they worked — but passing a path we know is usually
  wrong reads as intent later. They pass `who.home` now.
- `POST /command-hq/commanders/new` returned `cwd: getCommandersDir(...)` regardless of where the commander
  actually ran, and now accepts `cwd` and `name` like the UI will, and reports what it was given.
- the router's "Where commanders live" section said every commander shares one directory and that listing is
  therefore a scan. Both sentences were the design being replaced.

`loadClaudeSessionSummaryById` takes its mtime from `listTranscriptsAs` rather than a bare stat: the file may
belong to a member, and reading a member's tree needs the as-user path. That is one scoped directory listing
per commander — the same call the old whole-folder scan made, once per row instead of once for all, which is
the price of letting each choose where it lives.

Verified on live data: the existing commander still resolves, with its title and its real updatedAt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:06:19 +00:00
pastilhasandClaude Opus 5 d08ae41f1f a commander can be named before it says anything
Claude Code will not accept a name for a new session — the harness names its own from the first prompt — so
the platform renames the transcript the moment it exists. The owner names the commander up front, beside
choosing its working directory, and never sees the rename happen.

It works because of what rename actually writes. `renameClaudeSession` stores a `summary` record, which is
what sets `hasExplicitTitle`, and an explicit title is never overwritten — not by the harness's `ai-title`,
not by the first-message fallback. So the chosen name wins permanently rather than racing the titling. That
property already existed and was documented; nothing needed adding to make it hold.

Applied SERVER-side, in `fileAsCommander`, on the `session:claude` that first carries a transcript id. Not by
the client, because it must not depend on a browser still being open: the name is chosen before the first
message, and losing it to a closed tab would leave a commander called after whatever it happened to say
first.

`chatIdentity` is exported and reused rather than reconstructed. It decides the home and the OS user, and a
rename writes a file as that user — a second copy of that resolution is the kind of thing that works until a
member uses it.

The wire field is carried on the turn rather than sent afterwards, for the same reason the plan-mode flag is:
a brand-new conversation has no live session to configure and no stored row to update, so its first message
is the first moment anything can be acted on.

UI comes with the directory picker — both are chosen in the same place, and neither is optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:54:11 +00:00
pastilhasandClaude Fable 5 a356656a60 hold bun at the 1.3 series until 1.4 has been watched
bun 1.4.0 was published 2026-08-20 and https://bun.sh/install serves
whatever is newest, so machine-setup provisioning a box today would put a
two-day-old release under the platform and every pm2 app except
officer-pty. The owner asked for a temporary hold on 1.3.

The SERIES is pinned, not a patch — BUN_SERIES=1.3, resolved to the newest
1.3.x at install time so bug fixes inside the series still land, and no
1.3.14 to rot into "the version we happened to pick" the way setup_22.x
did for Node. The version reaches the installer as its documented first
positional argument (a release tag), and install_bun refuses to run with
an empty one: taking whatever bun.sh serves is the single outcome the pin
exists to prevent, so no bun at all is the better failure. Loud, and
officer-setup's pre-flight already stops on a missing bun.

Resolved from the npm registry rather than GitHub's releases API, which
is the obvious source and the wrong one — 60 unauthenticated requests an
hour per IP, and it returned 504 twice while this was written. An empty
answer installs nothing, so where it comes from is load-bearing.

The section now reads four states instead of two. "Installed" was one
answer covering a machine inside the pin, one behind it, and one past it;
past it is the case worth catching, since that is what bun upgrade leaves
and nothing else reports it. Above and below get different prose and both
offer to move; nothing is corrected silently.

What a setup script cannot cover: bun upgrade takes no version argument —
latest stable or --canary, verified against its own --help — so it always
jumps past the pin without saying so. Hence the doctor check, which reads
engines.bun rather than repeating the number, because a second hardcoded
copy is a second thing to drift. bun ignores engines (verified: an install
declaring 1.9.x ran clean under 1.3.14), so the field is the declaration
and doctor is the enforcement. Confirmed bun install --dry-run still
resolves frozen with the field present and bun.lock unchanged.

This host is already on 1.3.14 and inside the pin, so nothing moved here.
Also fixes three more stale process counts in the same block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:45:26 +00:00
pastilhasandClaude Opus 5 1c7619e184 the permalink goes through the router, so the open chat learns its own name
Third attempt at this bug, and the first two were fixes to the wrong component. The /chat screen renders
ChatDetailPanel, not ChatHeader — so invalidating the sessions list and correcting which id the panel records
were both real improvements to a surface that is not the one showing "New chat".

The actual cause is one line. The permalink was written with `window.history.replaceState`, which changes the
address bar and tells react-router nothing. On that screen BOTH the title and the rename control come from
`useParams().sessionId` — see the note in DetailBar about renaming the chain's head rather than the tail — so
the router kept reporting no session, the title stayed "New chat", and rename stayed absent. Navigating away
and back forced the router to re-read the location, which is why that appeared to be the cure and why the fix
looked like it belonged in a list.

Now a `navigate(..., { replace: true })`. Replace rather than push, so Back still leaves the conversation
instead of stepping through the same one under a succession of names.

Worth recording for the next one: three separate "the UI did not update" bugs today were all a URL or an id
not reaching the thing that reads it — two of them the officer-key/transcript-id pair, this one the router.
`useQueryState` still writes `history.replaceState` directly; same trap, different hook, left alone rather
than widened into this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:44:11 +00:00
pastilhasandClaude Opus 5 3f1ba38f19 the chat panel's header names a new conversation too
The list learned the name and the panel header did not, which narrowed it to one lookup: the panel records
its session id, and ChatHeader resolves the title by finding that id in the sessions LIST.

The panel was recording `chat.sessionId` — OFFICER's session key — and the list is keyed on Claude's
TRANSCRIPT id. Two id spaces on either side of one `find`, so it never matched: a new chat's header read
"New chat" for the whole life of the conversation and offered no rename, because rename is a per-row action
on a list the session was not found in. Selecting the conversation from the list fixed it, which is exactly
why navigating away and back looked like the cure.

Same trap as the Command HQ pane on 2026-08-21, third time this pair has caused a bug: officer's handle and
the harness's transcript id are not interchangeable, and every place that keys one lookup on the other fails
silently rather than loudly.

The "only a REAL id, never null" rule is unchanged and still needed — both ids are null for a moment while a
resumed session connects, and writing that null would turn "this panel has never chosen" into "the user asked
for a new chat", so the next reload would open blank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:35:09 +00:00
pastilhasandClaude Fable 5 577ebde902 the services section counts its processes instead of saying six
CORE_PROCESSES grew to seven when caldav and notify became core on
2026-08-19, and five hardcoded "six"/"6 processes" claims around it did
not move. Derived from the array now, so the next core process cannot
re-create the drift. Same class of rot in preflight's tool_why, which
still said bun runs "nineteen of the twenty processes" — a count from
the pre-plugin era, replaced with a count-free sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:30:37 +00:00
pastilhasandClaude Opus 5 59f600d34d a new chat gets its name without a refresh — this time with the import
Re-lands f9a36996, which I reverted because it broke chat rendering outright:
`ReferenceError: useQueryClient is not defined`.

The cause was mine and it is embarrassing in a specific way. The edit that added the import searched for
`import { useCallback, useEffect, useMemo, useRef, useState } from 'react'` and the file's first line is
`import { useState, useEffect, useRef, useCallback } from 'react'` — different order, and useMemo is not in
it. So the search matched nothing, and I had used replace WITHOUT an assertion, so nothing said so. That is
the identical failure I caught and wrote up hours earlier in the CommandHQ layout edit, where two edits
silently did nothing for the same reason.

Every edit in this commit asserts. Which then caught a second, subtler fault: `commitStreaming()` appears
ELEVEN times, so anchoring the turn-end call on it put announceSession inside `assistant:text` rather than
`result`, with mangled indentation. An assert proves an anchor EXISTS; it does not prove it is unique. The
result handler is now anchored on a three-line sequence that occurs once, and the count is asserted rather
than assumed.

Verified in the BUILT BUNDLE rather than by typecheck, because a ReferenceError is a bundle fact: the name
appears zero times as a free identifier (the bundler resolved and renamed it), and both invalidation keys are
present — CLAUDE_SESSIONS went from one occurrence to two, the second being this.

Worth recording about the tooling, since I doubted it: tsgo DOES catch this class of error. I probed it three
times with a deliberate type error in this exact file and it reported every time. So the check is sound and
the miss was mine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:26:15 +00:00
pastilhas cbedfed2ba Revert "a new chat gets its name without a refresh, and can be renamed"
This reverts commit f9a3699603.
2026-08-22 18:18:54 +00:00
pastilhasandClaude Opus 5 f9a3699603 a new chat gets its name without a refresh, and can be renamed
Start a chat and the header said "New chat" until you navigated away and back, and the rename control was
missing entirely. Both halves came from one cause, and it was not the titling.

The header resolves its own title by finding the session in the cached LIST —
`sessions.find((s) => s.id === sessionId)` — and a newborn conversation is not in that list. It has
staleTime 30s and nothing invalidated it, so `current` was undefined: the label fell through to "New chat",
and rename was absent because rename is a per-row action on a list the session was not in yet. Navigating
away and back refetched it, which is exactly what made this look like a rendering bug.

Verified first that the titling itself is fine and needs no change. Claude Code writes an `ai-title` entry
early — line 8 in a real transcript, after the user message and BEFORE the first assistant line — and
Officer's own `title: summaryTitle || firstUserText || '(untitled)'` means a session is never nameless once
its transcript has a user message. The server could always answer; nothing asked it.

So the conversation announces itself. On `session:claude` — the START of the turn, when the transcript
becomes real on disk — and again at the end of every turn, because the harness may have bettered the name
while it ran. The second call sits OUTSIDE the id check deliberately: that is true whether or not a
particular `result` happened to carry the id.

Cost is one small request per turn per open chat, which is the honest price of a list that is right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:15:20 +00:00
pastilhasandClaude Opus 5 75affec091 a commander is identified by its row, not by where it lives
Groundwork for choosing a commander's working directory. Identity was the CWD — every commander shared one
directory, so the directory was the answer and it needed no query. The moment a commander can choose its own,
that test is wrong in BOTH directions: a commander in the platform checkout reads as an ordinary chat, and an
ordinary chat in the old shared directory reads as a commander.

So identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
The isolation the shared directory provided is no longer carrying anything — the surfaces that needed it now
filter explicitly, which is what made this possible.

Four surfaces moved, and the shape of the change is the same in each: the transcript scanner stops inferring
it and the ROUTE decorates from the rows.

- `listClaudeSessions` says nothing about commanders any more. It is synchronous over a directory scan and
  identity now needs a database read, so `withCommanderFlag` applies it at the caller.
- the sessions list, `/chat/live` and `GET /chat/sessions/:id` each read `listCommandHqSessionIds` once per
  request. A set membership test per row rather than a lookup per row: commanders are few, and the
  alternative turns one list into fifty queries.
- `commanderBlock` resolves the row instead of parsing the account out of the path. It needs the email for
  the record paths, so that is a second read — once per session spawn, beside the affordances and the skills
  index, which are read there for the same reason.

`commanderBlock` had to be hoisted out of `createSession`, which is synchronous, into `spawnClaudeStreaming`,
which is async and already holds the account. Passing it in as a parameter rather than making the whole
session constructor async.

`isCommandersDir` survives but is documented as no longer being the identity test — it remains only for the
places that legitimately care about that specific path: creating it for a new OS account, and as the
historical home of commanders created before this.

A commander mid-first-turn has no row and so reports false for one turn. That is the same turn it must not
arm anything on, and it corrects itself the moment it is filed.

Verified against real data: a filed commander renders the block, an unknown id and a null id both return
null, and the live gate still answers 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:07:00 +00:00
pastilhasandClaude Fable 5 de9d229cfa todo: review the secret store built overnight
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:02:55 +00:00
pastilhasandClaude Opus 5 cabc4825ef a commander never arms on its first turn, and the missing paths are how it knows
The first-turn gap is closed by a rule rather than a mechanism, and the two halves interlock instead of
conflicting.

A commander cannot be told its record paths on the very first turn: the transcript id is minted as that turn
begins, so there is no id to key a path on. That was written up as a limitation. It is not one, because
arming on the first turn is not permissible anyway — the first exchange is for agreeing what to watch and
what to do about it, and a watcher armed before that agreement is armed on a guess. A wrong condition fails
silently in both directions: never fires, or fires constantly, discovered days later.

So the absence became the signal, and the block says so: "If you were given no paths above, you ARE on your
first turn: that is what their absence means." An agent with no paths is on the turn it must not arm on, and
an agent that armed anyway would be unstoppable and undeletable — the same rule reached from the other side.

The rule is in both places on purpose. The block, always loaded, carries the instruction and the interlock in
four lines. The Commanders skill carries why, at length, for the agent that is about to arm one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:54:47 +00:00
pastilhasandClaude Opus 5 87ff6ed326 a commander is told where to record itself, however its loop gets started
The record instruction lived in one place: the preamble the background-job dialog composes. Fine while the
dialog was the only way to arm a commander, and wrong for the flow being built — the commander arming itself
from the conversation, with the owner never touching the launcher. An agent starting its own background job
never sees that preamble, so it would record nothing, and delete would quietly go back to leaving orphaned
loops.

So the paths are compiled into a commander's session preamble, beside the affordances and the skills index.

THE SPLIT, which is the part worth keeping: the METHOD lives in the Commanders skill — read on demand, as
long as it needs to be — and the PATHS are compiled in always, because they are a fact about this session
that the agent cannot derive. That is exactly the line between a skill and an always-loaded block, and it
falls in the same place here as everywhere else.

The account is derived from the CWD rather than passed in, because the caller does not have it: the sidecar
knows a member's osUser and home, and an owner's turn carries no email at all. A commander's cwd is
DATA_PATH/<email>/commanders, so the email is in the path — and it is verified by reconstructing the
directory from it, so a coincidentally-shaped path cannot pass.

Keyed on the cwd rather than a database lookup, for the same reason every other surface uses
isCommandersDir: a session must not be a commander in one place and not another. It also works before the
row exists, which a lookup would not — and the first turn is when the row does not exist yet.

Stated in the file rather than left to be found: a brand-new commander's FIRST turn gets no block, because
the transcript id does not exist until that turn begins. In practice the first turn is the conversation about
what to watch and the loop starts on a later one, but a commander that armed itself on its opening message
would not have been told where to record.

Verified on the three cases: a real commanders cwd renders the block with both paths, an ordinary chat gets
null, and a session with no id yet gets null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:47:55 +00:00
pastilhasandClaude Opus 5 6d6e8a841e spawned children are database rows, linked to their commander
The file was doing a job it is not good at. A newline-delimited list appended by a shell cannot express a
link, cannot be queried, cannot be updated in place, and cannot hold a fact that arrives later — and a
child's cost arrives later, from the child, long after the loop wrote its id.

So the file becomes an INBOX and command_hq_spawned becomes the record.

The constraint that shapes it: the loop is bash and cannot reach Postgres. Giving it a credential so it could
would put an authenticated writer inside a background job, which is worse than the problem. So the loop
appends with a shell redirect and the platform ingests — idempotently, on the unique index, which is what
makes ingest-on-READ safe. A cron for this would be a moving part that can stop without anybody noticing;
the read is the only moment the answer is wanted.

What the rows buy that the file cannot:

- the link, ENFORCED. onDelete cascade means deleting a commander cannot leave an orphaned child row, which
  is the reason this became important.
- queries. Cost per commander, how many agents a trigger spawned, which are still running.
- somewhere for finishedAt, totalUsd and outcome, which the loop cannot know when it appends.

totalUsd is NULLABLE and null means unknown, never zero. The children endpoint returns `unknownCost` as a
count beside the total, because a commander whose children's costs are unknown must not display a confident
total that happens to be too low — that understatement is the whole reason this exists.

Delete ingests before reading, so a child appended since the last read is still deleted: the file is allowed
to be lossy, the rows are not, but a row never ingested does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:26:55 +00:00
pastilhasandClaude Opus 5 b7112f6e5d a commander records what it is and what it spawned, so deleting one means it
Without this record a commander cannot be stopped or fully deleted, and the delete route's comment claimed
otherwise: "the running background process (if any) dies on its own". It does not. Nothing in that route
touched a process, so a deleted commander left its loop running — still polling, still able to fire, still
able to spawn a deploying agent, with no chip to stop it from and no row left to disarm.

THE RECORD, and why the loop has to write it. Three ways to recover the link were tried and none works: the
PROCESS holds nothing (no open transcript descriptor, nothing identifying in its environment — tested); the
TRANSCRIPT is indistinguishable, because queue-operation/ai-title/last-prompt look like platform markers and
are the harness's own, appearing in every session equally; CONTENT matching finds ordinary chats that quote a
procedure as readily as the agent following it. So the loop records, because it is the only party that knows.

Two files under DATA_PATH/<email>/commander-state/<session>/: `loop-pid` and `spawned`. The reader is
deliberately tolerant — a truncated write or a stray line is dropped, not fatal, because a record that failed
closed would make a commander undeletable over one bad line.

STOPPING needs no new mechanism, only the pid: descendantPids + SIGTERM from the loop takes the loop and any
agent under it, which is exactly what that helper was written for. A commander with no recorded pid stops
nothing and SAYS so — the honest answer for one armed before this existed.

DELETE now cascades in a deliberate order: stop the processes, then the children's transcripts, then the
commander. Stopping first because a live agent writing to a transcript being deleted is a race, and because
the loop could otherwise spawn a replacement between the last two steps. It returns counts rather than a bare
ok — this is the one irreversible action here.

The children's transcripts are deleted with the EXISTING deleteClaudeSession: its cwd argument is documented
as "a hint, not an authority", so it locates a child that ran in the platform checkout rather than the
commanders directory. No new function.

THE CHAIN'S WEAK LINK was that the loop cannot know which commander it belongs to. The paths are now handed
to it in the background-job prompt, fetched from a new endpoint because they are rooted in DATA_PATH — a
server fact. Paths rather than an upload endpoint on purpose: a shell redirect needs no credential, and a
background job has no business holding one.

The prompt rule is numbered properly rather than landing on 7.5, and the output rule shifts down one when it
is present. Verified both shapes produce consecutive numbers.

The manager now exposes claudeSessionId, because a commander is keyed on the TRANSCRIPT id everywhere and
officer's own session handle is not interchangeable — that mismatch is what once rendered a working commander
as an empty pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:18:30 +00:00
pastilhasandClaude Opus 5 ee9106ef54 the new-commander card gets a slot for example prompts
Requested, and deliberately not written: an example opening prompt is a promise about what the flow accepts,
and the flow is being rebuilt. Written now, all three would need rewriting — and a wrong example is worse
than none, because a user copies it and blames themselves when it does not work.

The note records what they should be when they are written: two or three differing in SHAPE rather than
subject — one where the trigger is obvious, one that needs a conversation to pin down, and one that is
plainly not a commander at all, so the boundary is visible from the examples rather than only from prose.

Marked provisional, so it renders with the draft badge until somebody writes it. That is what the flag is
for: this ships on every deploy whether the copy is ready or not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:53:39 +00:00
pastilhasandClaude Opus 5 e4f1b75f9b a platform skill the agent can open, generated so it cannot rot
A fresh agent answering "what is a commander, how do I start one" has nothing to answer from, and invents.
The mechanism to fix that already existed and had one user: skills/load.ts compiles an INDEX into every
session — name, when to reach for it, path — and the body costs nothing until the agent opens it. The exact
inverse of an affordance, which sends its body always and its description never.

So no affordance was added, and that is the correction worth recording: an affordance saying "there is a
platform skill" would be a second copy of the index entry, paid for on every turn to say what is already
there. Affordances are facts about the CLIENT; this is reference material.

The platform skill is GENERATED, because a hand-written one rots and we measured it — Spotlight's settings
list drifted in two days, CLAUDE.md claimed 27 permissions against 18 and twenty PM2 entries against
thirteen. A wrong skill is worse than a wrong doc: an agent quotes it with confidence and the user cannot
tell.

Generated: every permission with its kind, label, description, API prefixes, sockets, screens, self-service
routes and read-only-writes; and the keyboard shortcuts. Prose, kept beside the generator so the two cannot
separate: what the platform is, what each KIND means for a user, and where to look next.

It also says what it does NOT cover — installed plugins vary per machine so a generated list would be wrong
on every other server, and request shapes are not in the registry — plus a closing instruction not to guess,
on the grounds that a question the registry cannot answer is one this file cannot either.

docs:check regenerates it into memory and fails if the committed file differs. Verified both directions:
appending one line made it fail naming the file, and regenerating made it pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:48:31 +00:00
pastilhasandClaude Opus 5 4a206086af docs:check — the documentation's checkable claims, against the code
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
invisible. Every piece of drift found today was mechanical — a count, a route name, a process list — so it is
checkable, and a check that exits non-zero cannot be quietly not done.

It found two things on its first run, both wrong for over a week:

  platform/CLAUDE.md    "27 entries as of 2026-08-13"    the registry holds 18
  officer-dev/CLAUDE.md "Officer is twenty PM2 entries"  there are 13

The second was worse than a wrong number. It listed eleven processes that do not run here — vault, slskd,
headscale, transmission, invoiceshelf, wallet, photos, jellyfin, gitea, music, vnc — while omitting six that
do, and named two mybiblepal entries as sharing the daemon when nothing does. Accurate on 2026-08-07, and
nobody re-derives a list of processes while editing the sentence around it. Both are corrected, with what
they used to claim recorded, because the interesting part is how a maintained list decays rather than that it
did.

Three checks: the permission registry count, the PM2 entry count, and every settings route Spotlight offers
resolving to a section some screen declares. That last one is the class the counts cannot catch — a name, not
a number — and it is the drift that shipped a dead link to /settings/users.

THE RULE FOR ADDING ONE, written into the file: a check compares a documented claim against a value DERIVED
AT RUN TIME, never against a second hard-coded copy. A check with the answer written into it is one more
place to drift, and it drifts in the same edit that misses the doc.

What it deliberately cannot do is read prose and judge it. It looks only at claims with a machine-checkable
counterpart — a small fraction of the documentation, and the fraction that rots fastest.

The settings check parses source rather than importing it, and says why: those sections are object literals
inside components, so importing them drags React and a DOM into a CLI.

`skipped:` lines are printed rather than swallowed — a machine without pm2 cannot check that count, and
saying so beats reporting a pass it did not perform.

Recorded as required before tagging in CLAUDE.md, and in prompts/deploy-commander.md as explicitly NOT the
rollout agent's job: it executes a plan, and a prose edit between two hosts is not that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:42:15 +00:00
pastilhasandClaude Opus 5 29d998b6c3 a new commander pane explains itself, in skeleton
Starting a commander currently means knowing to open the background-job dialog and arm a watcher from it.
That is not discoverable, and it reads as configuration when the real answer is "talk to it" — so the dialog
stops being the way in, and the pane explains the way in instead.

EmbeddableChat's own empty state is "Send a message to start". True, and it tells a first-time user nothing
about what to send.

The copy is NOT written, by agreement: the instructions have to describe a flow that does not exist yet, and
writing them first produces text that is wrong in a way nobody notices. What is here is the structure, plus
the parts already true whatever the flow becomes — what a commander is, and that it begins as an ordinary
conversation.

Sections carry a `provisional` flag that renders a visible "draft" marker. That is the load-bearing detail:
this ships on every deploy whether the copy is ready or not, so a half-written guide must not be able to
look finished. Clearing the flag is the last step of writing a section, not a formatting choice.

Data rather than JSX, so filling it in later is editing one array instead of hunting through markup — the
same shape as the shortcuts registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:30:11 +00:00
pastilhas 228f2f4638 deploy report 2026.08.22.02 2026-08-22 16:20:11 +00:00
pastilhasandClaude Opus 5 68d60e409b 2026.08.22.02 — theme favourites, ctrl+? shortcuts, command hq in two columns
Schema change this time: theme_favorites is new, and without the push the theme dropdown fails on a missing
relation the first time it reads a starred list. The plan names db:push as a step rather than leaving it to
be inferred from a diff.

Two things the plan states that a reader could not infer. Command HQ MIGRATES a saved layout on load, so
anyone who has opened that screen sees their arrangement replaced once — deliberate, and the screen is
locked so the only loss is column sizes. And the email plugin's multi-account work arrives through
plugins:update from another repository, affecting only hosts where that plugin is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:10:51 +00:00
pastilhasandClaude Opus 5 b1dfa3f7bb spotlight on shift+space, overriding both of its stock behaviours
Fifth setting in three days: ctrl-space → shift-space → alt-space → ctrl-space → shift-space. Ctrl-space was
tried twice on the theory that preventDefault would beat Brave's address bar. It does not — that binding is
at the browser level and the key never reaches the handler. Recorded in the file so it is not tried a third
time.

Shift-space overrides two stock behaviours deliberately, and the owner's reasoning is the right one: it types
a space in a text field, and it scrolls a page UP when nothing has focus. Neither is worth a launcher.
Page-up-by-shift-space is a fossil, and typing a space is what the unmodified spacebar is for.

The cost is stated once in the file rather than argued again: typing in CAPS by holding shift instead of
using caps lock puts a shift-space between every word, which opens the launcher mid-sentence. If that turns
out to bite, the fix is a text-field guard — which this had for one day, and which is exactly what made the
launcher unopenable from the composer, where it is most wanted. Trading that back would be trading down.

Every other modifier must be absent: ctrl+shift+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix. Verified disjoint in the built bundle, both conditions
quoted from it rather than from the source.

Five moves in three days is the argument for making chords configurable rather than for choosing better ones,
which is why the ctrl+? registry names the implementing file for each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:05:59 +00:00
pastilhasandClaude Opus 5 f586d748d6 ctrl+space opens spotlight, and ctrl+? tells you everything else
Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space →
ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction —
alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and
would rather move the app than the window manager.

ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made
before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than
rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches
the handler and nothing in this repository can help — the fix would be in Brave.

`!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the
two conditions are disjoint.

CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls
declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose
help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and
the value is in state/shortcuts.ts.

The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own
conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the
pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now
would rewrite three handlers for nothing visible.

Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by
name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the
UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the
theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:57:42 +00:00
pastilhasandClaude Opus 5 bcdc769083 commanders are one per line, not a grid
sm:grid-cols-2 xl:grid-cols-3 was reasonable when this panel owned 34% of the screen. It owns part of a 24%
column now, so two columns of card inside it left each one too narrow for its title — every card truncated
and the cost line wrapped.

A grid of one. The card is untouched: same rounded pill, same border and hover, same p-4, same gap-3 between
them. It simply stops being asked to share a row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:41:27 +00:00
pastilhasandClaude Opus 5 430f058a6d command hq: two columns, with the navigation stacked
It was 16 / 34 / 50 — a category column, a commander column, and the transcript. So half the screen was
navigation for a list holding a handful of commanders (one, on this machine) while the only thing anybody
reads got the other half.

Now the two navigation panels are stacked into one 24% column — commanders on top, categories beneath — and
the transcript takes 76%.

Categories BELOW the commanders, not above: the commanders are what you came for and the categories are how
you narrow them. Above, the filter would push the list down the screen and be read first; below, the list is
at the top where a list belongs, and the categories can grow their own scrollbar without taking room from it.

The categories panel lost seventy pixels of branding — a Radar tile, "Command HQ", "Watchers &
long-running jobs" — which made sense when it owned a column and does not now that it sits at the foot of
one. The screen's own header says Command HQ two inches above it.

`All commanders` and `Uncategorised` moved INTO the scroll box with the categories. They are the same kind of
thing, a filter over the list, and splitting them into a separate nav block above made the second look like a
different mechanism. The heading and the + button stay pinned; only the list scrolls — the same rule as the
theme dropdown.

MIGRATION, and it is the part that could have gone wrong. This key is persisted per account, so the default
alone moves nobody who has opened the screen. No appType was retired either, so `hasAppType` cannot see it
the way the /chat migration could — the test is structural instead: a horizontal root with three children is
the old shape. It terminates because the replacement has two, and replacing outright is safe on a `locked`
screen where the only user contribution is column sizes. Same reasoning, and same trap, as
ChatHistory/index.tsx.

Two of my edits silently did nothing first, because I searched for `officerdb` where the module is
`officerdev` and used replace without an assertion. Both are asserted now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:38:41 +00:00
pastilhasandClaude Opus 5 9b459b79dd poll gitea locally — the public door is rate-limited and gitea is on this machine
The deploy watcher kept logging `Connection closed by 77.42.39.155 port 22` followed by "make sure you have
the correct access rights", which reads as a credentials problem and is not one. `origin` pointed at
gitea.officer.dev, which resolves to the public address and arrives through Nginx Proxy Manager — where
every connection appears to come from one container IP, internet bots get that IP penalised by
srclimit_penalise, and SSH is refused for long stretches.

Gitea is a plugin ON THIS MACHINE, listening on 100.64.0.13:10023. The watcher was leaving the box and
coming back in through a proxy to read a repository sitting on its own disk.

origin now points at the local address; the public URL is kept as `gitea-public` so nothing is lost. No file
was edited to do it, which matters — the watcher is a running bash script and editing one in place is how a
loop starts executing half of two versions. Changing the remote's URL takes effect on the next tick with
nothing restarted.

Loopback would be better still and is not used: 127.0.0.1 fails host key verification because it has no
known_hosts entry, while the tailnet address is already trusted.

The cost of not knowing this, recorded in the prompt: a tag pushed at 21:47 on 2026-08-21 went unnoticed
until 23:07. Eighty minutes, forty-odd ticks, every one of them correctly logged and retried. The loop was
never at fault — it was asking through a door that is sometimes shut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:27:20 +00:00
pastilhasandClaude Fable 5 ab77c66f04 todo: configurable shortcuts, a shortcut modal, and the omarchy menu revamp
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:24:26 +00:00
pastilhasandClaude Opus 5 dd6135cac1 favourite a theme from anywhere, and the browser stops fighting the menu
Three reported faults and two additions, all from one keystroke's worth of use.

THE MENU OPENED BEHIND THE OVERLAY. The theme pill is a Radix DropdownMenuTrigger, and a focused trigger
opens on Space — so ctrl+shift+space opened the browser AND the dropdown behind it. preventDefault could
never have fixed it: React attaches its handlers at the root, which bubbles before window, so the trigger
saw the key first whatever this listener did with it. The listener now runs in the CAPTURE phase and stops
the event dead.

THE VIM KEYS DIED WHEN OPENED FROM AN INPUT. Same shape as the Spotlight bug earlier tonight: the guard
that refuses to steal keys from a text field was still true after the overlay opened, because focus had
never left the field. Opening now blurs, and the guard no longer applies once the list is open — there is no
input in it, so there is nothing left for it to protect. Only the GLOBAL bindings defer to a text field now,
which is the case it was written for.

f STARS WHAT IS FOCUSED, inside the list, so a browse and a shortlist are one pass rather than two.

ctrl+shift+f STARS THE CURRENT THEME FROM ANYWHERE, and deliberately without the typing guard the arrows
beside it need: word selection is what ctrl+shift+←/→ collides with in a text field, and ctrl+shift+f
collides with nothing — no character, no browser binding. Guarding it would only break it in the composer,
which is where you are when a theme starts annoying you.

The header pill now shows a star when what you are wearing is one of yours — only when true, because a
hollow star on every theme is chrome that says nothing two hundred times.

The row star in the overlay is a span with role="button", not a button: it sits inside the row's own
<button>, and nesting one is invalid HTML that browsers resolve by dropping one of them.

useThemeKeys takes the toggle as a callback rather than reaching for it. Favourites are per account and live
behind an API, which is app knowledge; this hook owns keys and selection and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:15:36 +00:00
pastilhasandClaude Opus 5 bd15a186b5 themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording.

ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together
in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it".
Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which
is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the
ctrl+shift+space browser, or one teaches an order the other contradicts.

FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a
DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would
lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are
directories discovered at request time — so a star can outlive its theme and the reader intersects with the
live registry rather than trusting the table.

Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer
"what else is there". Filtering to favourites only is a separate, explicit toggle.

THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line
telling you the shortcuts exist was visible for half a second and then gone under two hundred entries —
which is the same as not having written it. Header and footer are now siblings of a scroll box instead of
passengers in it, and the header names alt+space too.

THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the
sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500,
which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account`
permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as
a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it
away from, and caught that by reading rather than by testing.

Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200
so the login screen keeps its colours.

10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in
the original survives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:51:53 +00:00
pastilhasandClaude Opus 5 a15fadbddc 27 tests on the file standing between a member and a shell
authorize.ts had none, and its own header is what says why that mattered: every deny path in the platform
ends there, and the failure mode of a permissive default is not a bug report, it is someone else's session.

It had none for a structural reason rather than an oversight. Every path needed two database calls, and the
only file here that mocks officerdb leaks its stub into whatever runs after it — so the obvious approach
would have traded one untested file for a flaky suite.

So the decisions are split from the fetching. resolveEffectivePermissions, decideApiRequest and
decideWsProvider are pure functions of their arguments; the async wrappers keep the role cache, the
fail-closed catches and the shape callers use. No behaviour change, and the live gate still answers 401 on
file-browser, users and tasks.

REAL registry keys throughout, deliberately: a suite built on invented permissions keeps passing after a
rename, which is the failure an authorization table can least afford. If a key here disappears this file
goes red.

Pinned beyond the happy paths — the owner carries no grants to consult; core is write unconditionally; a
confined grant is dropped without a Linux user and all three drop together; execution and admin grants in
the table are ignored even though no CHECK stops the row existing; an unknown key is ignored without
disturbing what is legitimately held; self-service is exact in method AND path; a socket needs no level; an
unauthenticated caller is refused every door.

Two gaps are stated in the file rather than left to be found: ownerOnly has no declaring permission yet, so
testing it would test the fixtures; and the grant cache's contract is a claim about call sites, not logic.

Found while writing them: readOnlyWrites paths are relative to the permission's api prefix, and getting that
wrong fails as "no permission covers this path" rather than as a level error — a misleading message for a
misleading path, now commented at the test that hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:42:56 +00:00
pastilhasandClaude Opus 5 7e054ddf11 deploy report 2026.08.22.01 — 6 of 6 hosts, officer-dev skipped
Rolled deploy/2026.08.22.01 to alpha, fofos, macbookpro, edge-pertento-bridge,
production-pertento-bridge and deadpool-rezio-bridge. Every host fast-forwarded
98177c3b -> 78db9c8c, ran plugins:update, restarted officer, and came back online.

No bun install and no db:push, as the plan required.

officer-dev skipped as the commander's own host, and already on this code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:30:15 +00:00
pastilhasandClaude Opus 5 0c2ff88c88 a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat
rather than navigating to Command HQ — right, and it cost the composer its commander controls, because
`isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander,
rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a
watcher wants, and the one the dialog makes default inside a commander.

GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same
isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation.

The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed.
The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route
to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only
would have broken the brand-new composer, which is where the whole thing starts.

Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and
isCommander=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:29:35 +00:00
pastilhasandClaude Opus 5 c982353381 the boot check now reads the sockets that are served, not a list beside them
assertPermissionTotality exists to refuse a surface nobody has gated, and it was reading the wrong list:
Object.keys(handlers) — the map of implementations — while Bun served a hand-written route table twenty
lines away. The comment beside it said neither could be a stale copy of the other. That was false, and it
had a live instance: /api/cliamp/ws and /api/cliamp/audio/ws stayed in the route table for two days after
their handlers and registry claims were commented out. Not exploitable — isWsProviderAllowed finds no
permission and 403s a member, while the owner upgrades onto a dead socket — but precisely the drift the
guard is for, invisible to the guard.

Now there is one declaration. WS_ROUTES maps path → provider, the route table is generated from it, and the
check reads it. A socket route cannot exist without being checked.

Two smaller guards arrived with it. A route naming a provider with no handler refuses to boot, with the path
in the message, instead of 500-ing on first connect. And the provider union that was inline in upgradeWs is
named, so the route table typechecks against what that function can actually serve — which caught my first
attempt, where WS_ROUTES was Record<string, string>.

sidecar and dev-proxy are listed explicitly as served-but-absent-from-WS_ROUTES, because they upgrade
through their own handlers rather than upgradeWs. Named so the absence cannot read as an oversight.

Half the TODO was stale and it is recorded as such rather than quietly closed: the eight red tests it cites
have been green since cliamp was deleted on 2026-08-15. Deleting cliamp removed the symptom and left the
shape, which is the more interesting half.

Verified against the real prefix lists rather than by reasoning: the served set passes, one unclaimed
provider is refused by name, all five generated routes answer 401, and the server boots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:25:48 +00:00
243 changed files with 12168 additions and 1806 deletions
+32 -1
View File
@@ -113,6 +113,19 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`.
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
watched. Two places carry that number and they move together: `BUN_SERIES` in
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono
@@ -194,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). 27 entries as of 2026-08-13.
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
@@ -224,9 +237,20 @@ bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
bun setup # runs scripts/install.sh — blank machine to running platform
```
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
place to drift, and it drifts in the same edit that misses the doc.
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
written had been wrong for over a week.
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
@@ -453,6 +477,13 @@ explaining why it was safe.
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
+23
View File
@@ -0,0 +1,23 @@
# SendOff — mobile's answer
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
Good catch flagging the case explicitly; the app did build that path itself and would have opened
an empty sibling folder.
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
being siblings called "devices" would have bitten us first.
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
carries ids — one address format, no translation layer.
One request for `/targets` when the send screen gets built here: include each person's display name
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
so the picker can honestly say where the files will appear on the other side.
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
iOS now so the corrected paths are on the device.
+40
View File
@@ -0,0 +1,40 @@
# SendOff — `/targets` now serves the folder name
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
Request granted, and you were right to ask for it served rather than derived. Two fields:
```
GET /api/sendoff/targets
→ {
devices: [{ id, name, platform, lastSeenAt }],
people: [{ id, email, name, displayName }], ← displayName is new
myFolder: "Pastilhas" ← this is new
}
```
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
needs to change, it changes in one place and both sides move together.
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
that showed `displayName` as the destination folder would be naming the wrong directory.
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
what you see is what appears on disk.
## Also since the last note
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
asked for, and it is there now rather than when you get to it.
+141
View File
@@ -0,0 +1,141 @@
# SendOff — the backend is in. What mobile needs to change.
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
draft; this file is what actually shipped, and it differs in two places.
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
exercised against the live server before this note was written. The web send form is **not** built yet;
that is on this side and does not block anything below.
## The convention on disk
```
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
<home>/Documents/SendOff/<sender>/ — files another account sent you
```
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
relative to the right one.
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
sender's name alone.
## Two changes from the draft spec
**1. Device registration moved under the feature.**
```
was: POST /api/devices/register { id, name, platform }
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
```
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
`DELETE /api/sendoff/devices/:id`.
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
top-level noun collides with the **push token** registry that already exists at
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
an afternoon.
**2. `toUser` is an account id, not a username.**
```
was: meta { "toUser": "<username>" }
now: form field toUser=<account id, integer>
```
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
either; it is a small change here and a smaller one than guessing.
## The send endpoint
```
POST /api/sendoff/send
multipart/form-data:
file — repeatable, any number of parts
machine — a name of your own } exactly one of these
toUser — an account id }
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
```
Notes that matter for a client:
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
A file over the limit is a `400` naming the file.
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
they were about to destroy something. Show the returned name, not the local one.
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
one refused you. Do not present the two differently, because they are not distinguishable.
## Who may send to whom
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
handshake: I add you, and only then do you see me as a target.
```
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
POST /api/sendoff/senders { userId }
DELETE /api/sendoff/senders/:userId
```
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
their say-so, so nobody lands files in the owner's tree uninvited.
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
## The push
```
code: officer/core:sendoff.landed
id: the folder name — a username, or one of your own machine names
n: how many files landed
```
The catalogue entry renders `{n} file(s) from {id}` and its route is:
```
officer://files?path=Documents/SendOff/{id}
```
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
from the server — which is the whole point of that design. What the app does need is to keep parsing
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would
be content; whose folder it is, is addressing. If a future notification wants more than that, it is a
conversation rather than a patch.
## What is NOT built
- **The web send form.** This side, in progress. It changes no API.
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
blocks it — `POST /sendoff/send` takes multipart from any client.
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
month.
## If something here is wrong for mobile
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
ends up carrying a translation layer nobody meant to write.
+38 -38
View File
@@ -20,39 +20,39 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**87 open, 22 closed**, across 29 themes.
**90 open, 25 closed**, across 29 themes.
| Theme | Open | Closed |
| ------------------------------------------------------------------------------------------ | ---- | ------ |
| [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 4 | 1 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 18 | 3 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
| Theme | Open | Closed |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 3 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 3 | 2 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 16 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
## Records — no items, kept for the reasoning
@@ -63,13 +63,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten
- `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md)
- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md)
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
+2
View File
@@ -210,6 +210,7 @@
"dotenv": "^16.4.7",
"embla-carousel-react": "^8.5.1",
"hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.4",
@@ -271,6 +272,7 @@
"version": "0.0.1",
"dependencies": {
"hooks": "workspace:*",
"i18n": "workspace:*",
"state": "workspace:*",
"widgets": "workspace:*",
},
+104
View File
@@ -0,0 +1,104 @@
# 2026.08.22.01 — deploy report
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
No host failed, no process came back `errored`.
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
## Per host
### `alpha` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
`~/dockers/officer.dev/platform` was not touched).
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
photos, soulseek, transmission, vault.
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
- All 19 processes `online` afterwards.
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
captured an explicit `0`.
### `fofos` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
touch it, and it was left alone. No tracked change.
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
fix of 2026-08-21 is holding.
### `macbookpro` — succeeded
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
interactive shell, exactly as the plan and `docs/servers.md` require.
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
### `edge-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
### `production-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
and the command still exited 0 — recorded so the next reader does not chase it.
- `pm2 restart officer`: 9 → 10, `online`.
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
All `online`, none touched.
### `deadpool-rezio-bridge` — succeeded
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
the others'. Not an error.
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
### `officer-dev` — SKIPPED, and current
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
another agent working in this same tree during the rollout. Its work was left alone; only this report was
staged, by path.
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
at `78db9c8c`, the tag — but the next release will include them.
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
## Verification
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
## What was not done
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
happy with the rest. It needs no pull.
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
files arrived, not that each parses.
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
+57
View File
@@ -0,0 +1,57 @@
# 2026.08.22.02
Theme favourites, a shortcuts modal on `Ctrl+?`, Command HQ rebuilt into two columns, 27 tests on the
authorization resolver, and the email plugin's multi-account backend.
**SCHEMA CHANGE.** `theme_favorites` is new, so `bun db:push` is a step. Without it, opening the theme
dropdown fails on a missing relation the first time it reads a starred list.
**No dependency change** — nothing in `deploy/2026.08.22.01..HEAD` touches `package.json` dependencies or
`bun.lock`. Do not run `bun install`.
## What is in it
- **Theme favourites**, per account, in `theme_favorites`. Star from the dropdown, from the browser
(`f`), or from anywhere (`Ctrl+Shift+F`). The lists rotate to the theme you are wearing rather than
sorting it to the top, so neighbours survive.
- **`Ctrl+?`** renders every shell shortcut from a registry — `state/shortcuts.ts` — ported in shape from
the owner's `vimazing`.
- **Spotlight is on `Shift+Space`.** It was `alt-space` in the last release. `Ctrl+Space` cannot work:
Brave claims it at the browser level and the key never reaches the page.
- **Command HQ is two columns** — navigation stacked into one 24% rail, transcript at 76%. **It migrates a
saved layout on load**, so anyone who has opened that screen will see their arrangement replaced once.
That is deliberate; the screen is `locked` and the only user contribution was column sizes.
- **27 tests on `permissions/authorize.ts`**, which had none. Its decisions are now pure functions, split
from the database calls. No behaviour change.
- **`assertPermissionTotality` reads the sockets that are served**, generated from one `WS_ROUTES`
declaration, instead of a parallel list of handlers.
- **The email plugin's multi-account backend**, via `plugins:update` — `?account=<id>` names a mailbox,
`?account=all` spans them, absent is unchanged. Only hosts with that plugin installed are affected.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
`docs/servers.md`.
## `officer-dev` — skip it, and it is already current
The commander runs there and skips its own host. That host was restarted repeatedly through the day as this
work landed, so it is running this tree rather than lagging. Report it as skipped and current.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved; nothing `errored`.
`theme_favorites` exists — `to_regclass('theme_favorites')` is not null. This is the one step whose absence
shows up as a broken dropdown rather than a failed boot, so verify it directly where `psql` is available and
say where it is not.
`scripts/deploy-claim.sh state 2026.08.22.02` says `done`.
+105
View File
@@ -0,0 +1,105 @@
# 2026.08.22.02 — deploy report
**6 of 6 target hosts succeeded.** `officer-dev` skipped (the commander runs there) and verified already
current. No host failed, no step was skipped for a fault, nothing came back `errored`.
Deployed 2026-08-22 from `officer-dev`. Tag `deploy/2026.08.22.02` → `68d60e40`, matching
`package.json` `2026.08.22.02`.
## Summary
| Host | HEAD before | HEAD after | Steps | `officer` restart | `theme_favorites` |
| ---------------------------- | ----------- | ---------- | ---------- | ----------------- | ----------------- |
| `officer-dev` | `68d60e40` | — | skipped | not touched (192) | present |
| `alpha` | `78db9c8c` | `68d60e40` | all 4 | 65 → 66, online | present |
| `fofos` | `78db9c8c` | `68d60e40` | all 4 | 19 → 20, online | present |
| `macbookpro` | `9b459b79` | `68d60e40` | all 4 | 6 → 7, online | present |
| `edge-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 8 → 9, online | present |
| `production-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 10 → 11, online | present |
| `deadpool-rezio-bridge` | `78db9c8c` | `68d60e40` | all 4 | 4 → 5, online | present |
Checkout paths were taken from each `officer` process's `pm_cwd`, not from a hard-coded list.
## Per host
### `officer-dev` — SKIPPED, and current
The commander session runs inside `officer-claude-code` on this machine, so the whole host was skipped as
the plan and `prompts/deploy-commander.md` both require: no pull, no `db:push`, no `plugins:update`, no
restart. It was already at `68d60e40` before the rollout started and `theme_favorites` is present, so it is
running this release rather than lagging. `officer` shows 192 restarts, untouched by this deploy.
**The owner restarts this host deliberately, once happy with the rest.** Strictly nothing is pending on it —
the tree, the schema and the running process are all already on this release.
### `alpha` — done
Tree clean (tracked and untracked). Pulled `78db9c8c` → `68d60e40`. `bun db:push` applied changes;
`backfill-session-models` reported 0 seeded, 32 already recorded. `bun run plugins:update` returned
**12/12 updated in 46s**, exit 0 — and it restarted all twelve plugin sidecars itself (atelier,
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault),
which is the script's normal behaviour and not something the plan asked for. `pm2 restart officer`: 65 → 66,
`online`. All 19 processes `online`. `to_regclass('theme_favorites')` returned the table.
### `fofos` — done
Tracked tree clean. **One untracked file, `scripts/chk.ts`** — noted, not a reason to skip, and untouched by
the fast-forward. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes (3 transcripts already recorded).
`plugins:update`: **1/1 (gitea)**, exit 0, its sidecar restarted by the script. `officer` 19 → 20, `online`;
all 8 processes `online`. `theme_favorites` present.
Note for anyone scripting against this host: its `.env` names the connection `OFFICERDEV_POSTGRES_URL`, not
`POSTGRES_URL`, and a naive `grep POSTGRES_URL` matches a comment line first. Same on `macbookpro`,
`edge-pertento-bridge`, `production-pertento-bridge` and `deadpool-rezio-bridge`; `alpha` and `officer-dev`
use `POSTGRES_URL`.
### `macbookpro` — done, awake
Reachable — not asleep this time. Tree clean. **It was two releases behind**: `9b459b79`, not `78db9c8c`
like the rest. Pulled straight to `68d60e40`.
`PATH` was appended with `/opt/homebrew/bin` for the `bun` commands and `pm2` run separately, per
`docs/servers.md`. `db:push` applied changes, exit 0. `plugins:update` exit 0 with **no plugins installed —
nothing to update**, so no plugin sidecars moved here. `officer` 6 → 7, `online`; all 6 processes `online`.
**`psql` is not installed on this host** — the plan's direct schema check could not be run the way it is
written. Verified instead through the platform's own `postgres` client (`bun --env-file=.env` against
`OFFICERDEV_POSTGRES_URL`), which returned `theme_favorites`. Same fact, different tool.
`$SHELL -ic` also prints `command not found: starship` from `.zshrc:123` on every invocation. Cosmetic, and
unrelated to this release, but it appears in the raw output above every command.
### `edge-pertento-bridge` — done
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **2/2
(gitea, offscale)**, exit 0, both sidecars restarted by the script. `officer` 8 → 9, `online`; all 8
processes `online`. `theme_favorites` present.
### `production-pertento-bridge` — done
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **1/1
(gitea)**, exit 0, sidecar restarted by the script. `officer` 10 → 11, `online`; all 15 processes `online`.
`theme_favorites` present.
### `deadpool-rezio-bridge` — done
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied
changes, exit 0. `plugins:update`: **2/2 (email, offscale)**, exit 0, both sidecars restarted by the script.
`officer` 4 → 5, `online`; all 9 processes `online`. `theme_favorites` present.
## What did not happen
- **No host was skipped for a fault.** The only skip is `officer-dev`, by design.
- **`bun install` was not run anywhere.** The plan says this release changes no dependency, and the plan is
what this agent executes.
- **Nothing but `officer` was restarted deliberately.** The plugin sidecars that bounced were restarted by
`plugins:update` itself — eighteen processes across five hosts, listed per host above. Recording them
here rather than reporting only `officer` is the plan's explicit instruction.
- **The one verification that had to change shape** is `macbookpro`'s schema check, for want of `psql`.
Everything else was verified exactly as written.
## Claim record
`scripts/deploy-claim.sh state 2026.08.22.02` → `done`. The local record
`data/deploys/2026.08.22.02.done` was written before this report was committed, so a push failure here
cannot cause a second rollout.
+64
View File
@@ -0,0 +1,64 @@
# 2026.08.23.01
The commander creation flow, rebuilt end to end — named, given a directory, and started by a button that
makes it interview you rather than waiting for a prompt somebody else wrote. Plus the delete path that
actually removes one, `/minesweeper`, and `docs:check` guarding the documentation's checkable claims.
**SCHEMA CHANGE.** `bun db:push` is a step. `command_hq_spawned` is new and `command_hq_sessions` gains
`name`. Without the push, Command HQ fails on a missing column the first time it lists a commander.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.22.02..HEAD`. `package.json`
moved only to add `engines.bun` and two scripts. **Do not run `bun install`.**
## What is in it
- **A commander is created, not configured.** Name it, choose its working directory, press Start. It reads
the directory, works out whether the job is already written down there, and either reads its
understanding back for confirmation or asks what it should be watching. The name is the brief — it is the
only thing the owner says before anything else, and it now reaches the model instead of only titling the
transcript.
- **`command_hq_sessions.name`** — the platform owns the name. It was only ever in the transcript's summary
record, which the harness cannot write until the first turn ends, so the list showed the opening brief
while the header showed the real name.
- **Deleting a commander removes it.** Four defects, each of which left something behind: the session kill
ran in the wrong process and always failed; the transcript was deleted while the harness was still
writing it, so it came straight back as an untitled chat; `chat_session_events` is keyed on officer's
session key while everything else uses the transcript id, so its rows were never matched; and the key was
resolved from an in-memory map that is empty for anything older than the process. The event rows carry
both ids, so they are asked when the maps cannot answer.
- **`GET /chat/live?kind=`** — `agent` (the default), `commander`, or `all`, with `counts` always
describing the whole set. Defaulting to agents means a client that forgets cannot be handed a commander;
the web list asks for `all` explicitly.
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
error — so answering a question during the reconnect window after a restart vanished and the card span
forever. Question answers and plan decisions now report it.
- **`/minesweeper`** — a wine session streamed to the browser over RFB. The session is not running on
`officer-dev`, so the route renders its "not running" state with a Start button. That is expected.
- **Usage limits are read live.** `limitsCache` was keyed on nothing, so switching Anthropic account showed
the previous account's percentages under the new name. The cache remains for the failure path only, keyed
per account.
- **`bun docs:check`** compares documented claims against values derived at run time — the permission
count, the PM2 entry count, the generated platform skill. It found both claims it was written to check
already wrong. **Required before tagging a release.**
- **Empty transcripts no longer list.** A file holding only harness metadata and no turns is not a
conversation; four had accumulated as "(untitled) · 0 msgs".
- **Bun is held at the 1.3 series** — `engines.bun` and `BUN_SERIES` move together. Do not run
`bun upgrade` on a host.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
`docs/servers.md`.
## `officer-dev` — skip it
This is the machine the rollout runs from, and restarting `officer` here ends the agent performing it.
Name it as skipped in the report. It is already current.
+134
View File
@@ -0,0 +1,134 @@
# 2026.08.23.01 — deploy report
**4 of 6 target hosts succeeded.** `officer-dev` skipped by the plan (the commander runs there) and
verified already current. Two hosts were **not reached at all**, both for access reasons that have nothing
to do with this release: `edge-pertento-bridge` will not complete an SSH handshake, and
`production-pertento-bridge` presented a **changed host key**, which is not something a deploy agent
clears. Neither was pulled, pushed to, or restarted. No host failed a step; nothing came back `errored`.
Deployed 2026-08-23 from `officer-dev`. Tag `deploy/2026.08.23.01` → `dee0b89b`, matching `package.json`
`2026.08.23.01`.
**Hosts landed on `e6f1388a`, one commit past the tag.** The plan says `git pull --ff-only`, which follows
`master`, and `master` had moved on by one commit — `plan: localizing the web UI, and the scanner that
sizes it`, three files: `CLAUDE.md`, `docs/localization.md`, `scripts/i18n/scan-hardcoded.ts`. Documentation
and a standalone script, no runtime code, no schema and no dependency. Recorded here because "deployed the
tag" would not be true of any host.
## Summary
| Host | HEAD before | HEAD after | Steps | `officer` restart | Schema |
| ---------------------------- | ----------- | ---------- | -------- | ----------------- | ------- |
| `officer-dev` | `e6f1388a` | — | skipped | not touched (237) | ✓ |
| `alpha` | `68d60e40` | `e6f1388a` | all 4 | 66 → 67, online | ✓ |
| `fofos` | `68d60e40` | `e6f1388a` | all 4 | 20 → 21, online | ✓ |
| `macbookpro` | `8b4a5b3b` | `e6f1388a` | all 4 | 7 → 8, online | ✓ |
| `edge-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `production-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `deadpool-rezio-bridge` | `68d60e40` | `e6f1388a` | all 4 | 5 → 6, online | ✓ |
Checkout paths came from each `officer` process's `pm_cwd`, not from a hard-coded list. "Schema ✓" is a
live check, not an inference from `db:push` exiting zero: `command_hq_spawned` exists and
`command_hq_sessions.name` exists, queried per host against that host's own database.
## Per host
### `officer-dev` — SKIPPED, and current
The commander session runs inside `officer-claude-code` here, so the whole host was skipped as the plan
requires: no pull, no `db:push`, no `plugins:update`, no restart. It was already at `e6f1388a` and both
schema objects are present, so it is running this release rather than lagging. `officer` shows 237 restarts,
untouched by this deploy. Nothing is pending on it — the owner restarts it deliberately if they want to.
### `alpha` — done
Tree clean, tracked and untracked. Pulled `68d60e40` → `e6f1388a`. `bun db:push` applied changes;
`backfill-session-models` reported 0 seeded. `bun run plugins:update` returned **12/12 updated in 48s**,
exit 0, and restarted all twelve plugin sidecars itself — atelier, bitcoin-wallet, email, gitea, invoices,
jellyfin, music, offscale, photos, soulseek, transmission, vault. Then `pm2 restart officer`: 66 → 67,
`online`. Every other PM2 entry `online`.
### `fofos` — done
Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied changes. `plugins:update`: **1/1 in 8s**
(gitea), which restarted `officer-gitea`. `pm2 restart officer`: 20 → 21, `online`. No trace of the
`dubious ownership` / `EACCES` pair that broke its capabilities sync for three deploys — `capabilities:
pulled` came back clean, as it has since the 2026-08-21 `chown`.
### `macbookpro` — done
Awake and reachable. Tree clean. Pulled `8b4a5b3b` → `e6f1388a` — two releases behind, so it picked up
2026.08.22.01 and .02 as well. `db:push` applied changes (3 plugin schemas generated: atelier, kiosk,
music). `plugins:update`: `no plugins installed — nothing to update`, exit 0. `pm2 restart officer` was run
as a **separate** command per `docs/servers.md`: 7 → 8, `online`.
`PATH` was appended, never replaced — `export PATH="$PATH:/opt/homebrew/bin"` inside the interactive shell,
so `bun` resolves and `pm2` stays where nvm put it. `.zshrc` still fails on a missing `starship` on every
non-interactive login; noise, not a fault, and it has been there for at least three deploys.
### `deadpool-rezio-bridge` — done
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied
changes. `plugins:update`: **2/2 in 8s** (email, offscale), restarting both sidecars. `pm2 restart officer`:
5 → 6, `online`.
### `edge-pertento-bridge` — NOT REACHED
**Four attempts, ConnectTimeout up to 60s: `Connection timed out during banner exchange`.** The TCP
connection succeeds — `nc -z 100.64.0.15 22` reports the port open and `ssh -v` logs `Connection
established` — and then sshd never sends its version string. Tailscale shows the node `active; direct`, so
the tunnel is up and this is not a tailnet problem.
That is a host-side sshd or load problem, not a credential one and not a rate-limit one. Nothing was run
against it: it is still on whatever it was before this release, which I could not read, because reading it
needs the same handshake. It also did not answer HTTPS on its tailnet address during the probe.
**This host needs a look before the next release**, and the check is one command from anywhere on the
tailnet — `ssh edge-pertento-bridge hostname`. If it still hangs, the box wants a console.
### `production-pertento-bridge` — NOT REACHED, host key changed
```
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
The fingerprint for the ED25519 key sent by the remote host is
SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww
Offending ECDSA key in /home/pastilhas/.ssh/known_hosts:37
```
**I did not clear the entry and did not connect.** Accepting a new host key is a trust decision, and a
deploy agent that removes a `known_hosts` line to get its release out has removed the only warning the
owner would ever get. `StrictHostKeyChecking` did exactly what it is for.
What is known, all read locally without touching the host:
- `known_hosts` holds three keys for this name: ED25519 `SHA256:dw7kG2MDftuLDT1CduEO+OVYuMVHsx02Cnwikj7GS/4`
(line 33), RSA (line 36), ECDSA (line 37, the one ssh names as offending).
- The host now offers ED25519 `SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww` — a different key from
the ED25519 already trusted.
- Tailscale shows it `active; direct`, port 22 accepts TCP, and it did not answer HTTPS on 100.64.0.12
during the probe.
The benign explanation is a rebuild or a regenerated host key — this is `PPROD`, a bridge that has been
reinstalled before. The other explanation is the one the warning is about. **Settle it out of band**: read
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the machine's own console, compare it to
`63S0GK…`, and only then drop the stale lines with
`ssh-keygen -f ~/.ssh/known_hosts -R production-pertento-bridge`. Do not compare it over SSH; that is the
channel in question.
Until then this host is a release behind — it was at `68d60e40` as of the 2026.08.22.02 report.
## What I did not do
- **Did not deploy `edge-pertento-bridge` or `production-pertento-bridge`**, and did not force either.
Neither was pulled, neither had a process restarted, and neither has this release's schema.
- **Did not touch `~/.ssh/known_hosts`.**
- **Did not run `bun install`** — the plan forbids it and `bun.lock` is unchanged across
`deploy/2026.08.22.02..HEAD`.
- **Did not run `bun run docs:check`** — not the commander's job; it is run before the tag.
- **Did not restart anything the plan did not name.** `officer` only, per host. The plugin sidecars that
bounced were restarted by `plugins:update` itself, which is that script's own behaviour: 12 on `alpha`,
1 on `fofos`, 2 on `deadpool-rezio-bridge`.
- **Did not touch `officer-dev`** at all.
The local record `data/deploys/2026.08.23.01.done` was written before this report, as the protocol requires:
the record is what prevents a second attempt, and pushing this file is paperwork that is allowed to fail.
+53
View File
@@ -0,0 +1,53 @@
# 2026.08.23.02
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
push, `/sendoff` fails on a missing relation the first time it lists anything.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
moved only its version. **Do not run `bun install`.**
## What is in it
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
`Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually
browses — the owner's real login home, a member's managed home — because those are different directories
and writing to the wrong one makes a delivery invisible rather than wrong.
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
accounts. The owner bypasses this as a SENDER only.
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
the catalogue syncs.
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
days, and the last rollout spent four attempts and a minute apiece discovering that.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
## `officer-dev` — skip it
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
+69
View File
@@ -0,0 +1,69 @@
# 2026.08.23.02 — deploy report
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
## Per host
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
| `officer-dev` | — | — | — | — | — | SKIPPED |
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
`sendoff_senders` are new tables, so the diff was additive.
## What restarted, beyond what the plan asked for
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
not a fault, and it is the larger part of what moved:
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
- `fofos` — one (`officer-gitea`).
- `production-pertento-bridge` — one (`officer-gitea`).
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
- `macbookpro` — none; no plugins are installed there.
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
on any host was `errored`.
## Hosts the plan flagged
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
steps, with `pm2` run separately through `$SHELL -ic`.
## Things worth knowing, none of them failures
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
it, and it was left alone.
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
## What was not done
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
+64
View File
@@ -0,0 +1,64 @@
# 2026.08.23.03
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
changes and nothing reads the new table yet.
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
existing data.
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
`bun install`.**
## What is in it
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
`db:push` and is a later release.
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
step here. It never overwrites an existing row.
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
(`en` → `en-GB`), with no database migration.
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
create.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
exists and holds four rows on that host. `db:push` can fail and still exit 0.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## The check that matters this release
```sql
select count(*) from roles; -- expect 4
```
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
per host in the report.
No direct SQL is needed and none is authorised for this release.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
## `officer-dev` — skip it
The rollout runs from here. It already holds the release and the four rows, verified locally.
+42
View File
@@ -0,0 +1,42 @@
# 2026.08.23.03 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
attempted, nothing spent on it).
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
authorised and none was needed.
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
## Per host
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
`online`** — not just `officer`, every entry.
## Notes, and what was not done
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
restarting `officer` on it is the owner's deliberate last step.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
never have collided with it, so it is not a reason to skip a host.
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
before `officer` itself was touched.
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
- **Nothing was retried and nothing was repaired.** No step failed on any host.
+61
View File
@@ -0,0 +1,61 @@
# 2026.08.23.04
The web UI can be read in Portuguese. The shell and the shared components are fully keyed — 581 keys across
two locales, with the build failing on a missing one — plus an endpoint for spawning an agent, and spawned
agents becoming visible in both directions.
**No schema change.** Nothing under `src/databases/**/schema.ts` moved across
`deploy/2026.08.23.03..HEAD`. `bun db:push` is still a step because it is cheap and it verifies the
database agrees with the code, but it should report no changes.
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
## What is in it
- **Localization, phases 0 through 2.** `src/apps/officer-web` and `src/workspaces/components` have no
hardcoded user-facing strings left: 555 and 47 occurrences respectively, both now zero. `en-GB` and
`pt-PT` are complete and in step at 581 keys.
- **Every locale ships a complete catalogue.** There is no fallback between regional variants, so a
Portuguese reader is never served Brazilian wording. What enforces that is a **key-parity test that fails
the build** on a gap — verified by removing one key and watching it go red.
- **The language picker offers only locales that exist.** It listed about twenty codes, eighteen of which
silently rendered English. Stored bare codes widen on read (`en` → `en-GB`), with no database migration.
- **Dates, numbers and plurals follow the account's locale**, not the browser's, through `useFormat()` and
`Intl.PluralRules`. Roughly 23 call sites moved.
- **`POST /api/sub-agents/spawn`** — an endpoint for starting an agent from a prompt, for callers that are
not a chat and so cannot reach the harness's own tool. Budget-checked before spawning, and it refuses with
a 429 naming the window rather than burning the last of one.
- **Spawned agents are visible in both directions.** A commander's pane lists its children as links; opening
one shows which commander started it, linking back.
- **A dropped sidecar command says so.** `sendFire` did nothing when no sidecar was registered — no log, no
error — so answering a question during a restart window vanished and the card spun forever.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.04`
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: that means
this tree and that host's database disagree about something this release did not touch.
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
report.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## What to check, this release
The visible change is the language picker in profile settings. It should offer exactly **English (UK)** and
**Português (Portugal)** and nothing else. A host still offering twenty languages did not pick up the
frontend bundle.
No direct SQL is needed and none is authorised.
## Hosts
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
- **`production-pertento-bridge`** — deployed cleanly last release after being two behind. Nothing expected.
## `officer-dev` — skip it
The rollout runs from here. It is already at this commit and `officer` has been restarted on it.
+19
View File
@@ -57,6 +57,25 @@ so the walk exists — this is a second consumer of it, not a new class of work.
live count needs no new endpoint. Correlate `live.claudeSessionId === session.id`; a live row whose id is
still null contributes to no directory (see above).
**`/chat/live` returns only agents unless asked otherwise.** `?kind=` takes `agent` (the default),
`commander`, or `all`; anything else is a 400 rather than a silent fallback, and the plural forms are
accepted because the web client's own type is already `'agents' | 'commanders'`.
The default is the point. Commanders run background work and the phone swipes between live chats one at a
time, with no room for tabs and no way to skip a row — so a client that forgets the parameter must not be
handed a deploy commander. Defaulting to `agent` means forgetting can only ever under-report. The two
callers that genuinely want both say `kind=all`, which is a fair price: they are few, they are on screens
built to show both, and they are the ones positioned to notice if the answer is wrong.
`counts` describes **every** live session regardless of the filter, so a filtered caller still renders
`Agents (n) / Commanders (n)` without a second request. That is what stops a server-side filter from
costing more round trips than the client-side one it replaced.
Which rows are commanders comes from `command_hq_sessions`, **not** from the working directory — a
commander chooses its own cwd, so the directory answers nothing. `GET /chat/sessions` reports no
`isCommander` at all, because it removes commanders entirely; a list that excludes them has nothing to say
about which of its rows is one.
---
## Landmines
+400
View File
@@ -0,0 +1,400 @@
# Localization
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
This is an execution plan. It is written to be handed to an agent who has not had the conversation
that produced it, so it states the decisions and the reasons for them, not just the steps.
Every count here is a **measurement taken on 2026-08-23**, not a claim that stays true — the whole
point is for it to fall. Re-derive with `bun scripts/i18n/scan-hardcoded.ts` rather than trusting the
number beside it, and do not wire these into `docs:check`: a guard that fails when the count drops
would be a guard against progress.
---
## What this is
Every word the UI shows a person should come from a catalog keyed by locale, so the platform can be
read in a language other than English. Routes stay English (`/minesweeper`, `/chat`) — that is a URL
decision, deferred, and nothing here depends on it.
The scope is **the web UI only**. Not the server, not the plugins, not email or push. See D1 for what
the server does instead, which is the one decision that reaches outside this boundary.
---
## What exists today, and what it cannot do
**Updated 2026-08-23, after phases 0, 1 and 2.** This section described the pre-phase-0 world for long
enough that the next reader would have started from a false picture — bare `en.json`, 46 keys, three
components. All of that is now history and is kept below only as the "before" column, because the shape of
the gap is the argument for the plan and deleting it would leave the phases looking arbitrary.
| | before phase 0 | now |
| -------------------------- | ---------------------- | --------------------------------------------- |
| catalogs | `en.json`, `pt.json` | `en-GB.json`, `pt-PT.json` per workspace |
| keys | 46 | **581** — 538 `app.*`, 43 `ui.*` |
| workspaces with a catalog | 1 (`officer-web`) | 3 (`officer-web`, `components`, `officerdev`) |
| components using it | 3 | all of `officer-web` and `components` |
| plural rules | none | `Intl.PluralRules`, per locale |
| dates and numbers | browser locale | `useFormat()`, the account's locale |
| a missing key | rendered its dot-path | falls back to English, warns in dev |
| locales offered vs shipped | ~20 offered, 2 existed | only shipped locales are offered |
| parity enforcement | none | **a gap fails the build** |
Still standing from the original description, and still true: the package is hand-rolled, has no npm
dependency, and gives type-safe keys — a wrong key is a typecheck failure, which is worth more than it
sounds when one agent is editing hundreds of files.
**What remains:** `src/workspaces/officerdev` — the panels — and then translation. `panel.*` is scaffolded,
wired into the bridge, and empty.
Two things learned doing phases 1 and 2 that the plan did not anticipate:
- **`TranslationKeys` walked into a plural object**, so `t('key', { count })` — the entire point of D7 —
did not compile while `t('key.one')` did. The documented feature was uncompilable and the workaround was
not, which no test would ever have noticed because phase 1 contained no plurals. Fixed with type-level
assertions.
- **The typed wrappers rebuilt `t` on every render**, which is the trap `CLAUDE.md` already records against
`useClient()` and the Jellyfin player. It matters the moment `t` lands in a `useMemo` dependency list,
which phase 2 needed immediately. Memoised.
**A shape this format cannot express:** a sentence with an inline link. Splitting around the anchor gives a
key whose Portuguese value is `"."`. The cheap fix is rephrasing so the link falls at a clause boundary;
there is no node-level interpolation helper and adding one is a design decision, not a patch. Worth
raising if a phase turns out to be full of them rather than absorbing it forty times.
### The size of the remaining work
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
| area | files | occurrences |
| --------------------- | ------- | ----------- |
| `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 |
| **total** | **436** | **1170** |
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
That is a floor, not a ceiling. The scanner cannot see multi-line JSX, ternaries inside JSX, strings
passed down as props, or anything a plugin renders. Budget for **1200–1500 keys**.
---
## Decisions
### D1. The web UI only — and the server returns codes, not prose
The server should **never emit user-facing prose**. It returns a stable machine-readable code; the UI
owns every word a person reads and localizes it like any other string.
```jsonc
// not this
{ "error": "The Minesweeper session is not running" }
// this
{ "code": "MINESWEEPER_NOT_RUNNING" }
```
This is better than translating the server, for reasons that have nothing to do with translation. A
code maps to exactly one failure site, so it is greppable and a support conversation has something
precise to quote. Prose drifts; a code does not. And the server stops needing to know who is reading.
- **Phase 1 establishes the convention** so new code stops adding debt: new error paths emit codes,
and the catalog gets an `errors.*` namespace.
- **Phase 2 migrates the existing ~53** prose strings in `src/servers`. Not in this plan's scope.
- **Unmapped code** renders a generic localized line **with the code visible**. Never a blank, never
a raw key.
**The trap, and it is a real one: a code must not leak more than the prose it replaces.** Where a
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
the prose was careful not to be. When in doubt, one code for one _response_, not one per branch.
### D2. Regional locales, BCP 47, and every catalog is complete
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
it is the owner's own language, so getting it wrong is felt immediately.
**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
every key the UI has, translated for that locale, or it does not ship.
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
carried the shared bulk and a regional file carried only the differences. That is the conventional design
and it was rejected for a product reason that outranks the engineering one:
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
variant's words.
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
by the test rather than by a fallback quietly papering over a gap.
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
base-versus-overlay merge order, and no question about which file a given string lives in.
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
over a database migration — a stale bare code should resolve, not fail.
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
the other.
### D3. RTL — prepare, do not ship
No right-to-left locale ships in this phase. But since 436 files are being opened anyway, they get
opened once:
- **CSS logical properties** as components are touched — `margin-inline-start`, `padding-inline`,
`text-align: start` — instead of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this.
- **`dir` on `<html>`**, driven by the locale, wired now and set to `ltr` for every shipped locale.
The cost during a pass we are already making is close to nothing. The cost of a second pass over 436
files later is not. Hebrew stays in the picker but is not selectable until a catalog exists (see the
"twenty offered, two that exist" defect above — fix it by filtering the picker to shipped locales).
### D4. Extend the existing package. Do not adopt a library
`react-i18next`, `lingui` and `formatjs` are all better than what is here, and all of them are
dependencies. This repository froze its lockfile and hand-rolled a secret store rather than take
one, for reasons written down in `CLAUDE.md` → "Installs are frozen".
The gap is smaller than it looks, because the hard parts are in the runtime already: `Intl.PluralRules`,
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
lines of glue, listed in "What it cannot do yet".
Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms.
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
### D5. Keys, not English source text
`t('settings.profile.changePassword.title')`, never `t('Change Password')`.
Source-text keys (the gettext and lingui style) read nicely and mean an untranslated string still
renders. They also mean **editing an English word invalidates every translation of it**, and this
codebase already has typed keys — a wrong key is a compile error today, and that is not worth
trading away.
### D6. Catalogs live per workspace, merged at the bridge
Not one god file. Three:
```
src/apps/officer-web/locales/ → namespace app.*
src/workspaces/officerdev/src/locales/ → namespace panel.*
src/workspaces/components/locales/ → namespace ui.*
```
`I18nBridge` deep-merges them before handing the map to the provider. Each workspace exports its own
typed `useTranslation` the way `officer-web/lib/i18n.ts` does today.
Two reasons, and the second is the one that matters for execution. Strings belong next to the code
that renders them, so a panel stays self-contained. And **one 900-key `en.json` is a merge conflict
on every parallel edit** — with several agents or several days of work, a single file is a queue.
Namespaces also make an unqualified key impossible to place wrongly: `ui.*` is shared, so anything
under it must not mention a feature.
### D7. Plurals and formatting go through `Intl`
A plural value becomes an object; a string stays a string:
```jsonc
"panel.files.selected": { "one": "{{count}} file selected", "other": "{{count}} files selected" }
```
`t()` picks the branch with `new Intl.PluralRules(locale).select(count)` when `count` is passed.
Categories are per-locale — English and Portuguese use `one`/`other`, Arabic uses six — so the shape
must be an open record, never a two-field type.
A `useFormat()` hook returns `date`, `time`, `dateTime`, `number` and `relative`, each bound to the
active locale. **The ~23 `toLocale*` call sites move to it.** Left alone they follow the browser's
locale and quietly contradict the rest of the UI.
### D8. A missing key falls back to English and is loud in dev
Never render a dot-path to a person. Resolution walks the chain, ends at `en`, and only if English is
missing too does it render the key — which at that point is a bug the parity test below should have
caught.
In dev, a miss logs once per key with the key and the locale. In production it is silent.
---
## The key naming convention
Three segments, sometimes four. **Name the role, never the text.**
```
<namespace>.<area>.<component>.<element>
app.settings.profile.changePassword.title
panel.files.contextMenu.rename
panel.chat.composer.placeholder
ui.button.cancel
app.errors.MINESWEEPER_NOT_RUNNING
```
- `namespace` — `app` / `panel` / `ui`, per D6.
- `area` — the route or panel it belongs to; matches the directory.
- `component` — the file, lowercased.
- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
Rules that keep 1200 keys navigable:
- **Never key by the English words.** `ui.button.save`, not `ui.button.saveText` and never
`ui.button.Save`.
- **Never build a key at runtime.** `t(\`panel.${kind}.title\`)` defeats both the type checker and
the extractor. Use an explicit map from the dynamic value to literal keys.
- **`ui.*` may not name a feature.** If a shared component needs feature words, the caller passes
them as a prop.
- **One key per meaning, not per occurrence.** "Cancel" in twelve dialogs is `ui.button.cancel` once.
But a word that is the same in English and different elsewhere — "Post" the noun and the verb —
gets two keys. When unsure, split: merging later is safe, splitting later is not.
---
## What must never be translated
Getting this wrong is worse than missing a string, because it corrupts data or breaks a contract.
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
account language, not by ours. Translating them sends somebody hunting for "Ativar" in a console that
says "Enable".
So translate the connective prose around them and leave the quoted label in English. Do not key it with
identical values in both catalogs either — that is a key whose two sides can never legitimately differ,
which is a maintenance cost bought for nothing.
Decided 2026-08-23 on `GoogleOAuthConfig.tsx`, where 55 of the file's 67 strings are of this kind.
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
names, database values.
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
reading a terminal at 4am, and they should stay greppable in one language.
- **"Officer"**, and product names generally.
---
## Execution
Four phases. Each is a commit that leaves the tree green — the app must run in English at every point,
because a half-converted UI is unreviewable.
### Phase 0 — infrastructure, no visible change
Everything in "What it cannot do yet". Nothing in this phase touches a component.
1. `resolve.ts`: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).
2. `I18nProvider`: accept a merged map; expose `useFormat()`.
3. `I18nBridge`: deep-merge the three catalogs (D6); set `dir` on `<html>` (D3).
4. `Languages.tsx`: regional tags, and **filter the picker to locales that actually exist**.
5. Settings migration: widen stored bare codes on read.
6. Scaffold `locales/` in `officerdev` and `components`, each with its typed hook.
7. Land the tooling below.
Phase 0 is the whole plan's risk. It is also small, and it is the only part where a wrong decision is
expensive to reverse — review it before phase 1 starts.
### Phase 1 — shared components (91 files, ~47 strings)
Smallest, highest reuse, and it establishes `ui.*` before anything depends on it. Do this first so
phases 2 and 3 can reach for `ui.button.cancel` instead of inventing it twice.
### Phase 2 — the shell (132 files, ~556 strings)
`officer-web`: settings, dock, header, screens. Highest-traffic text and the part a reviewer will
look at first. Convert per directory, one commit per area.
### Phase 3 — the panels (213 files, ~567 strings)
`officerdev` apps. The largest surface and the most parallelizable, because panels barely share
strings. **One panel per commit** — this is where per-workspace catalogs earn their keep, but two
agents in the same panel will still conflict.
### Then: `pt-PT`
Only after English is fully keyed. Translating against a moving catalog is wasted work.
---
## Tooling and guardrails
Without these, 1200 keys rot. All three are small and belong in phase 0.
**`scripts/i18n/scan-hardcoded.ts`** — already written and committed. Reports every candidate
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
meter: the count goes down as work lands.
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
`bun docs:check` fails on a stale number.
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
it globally on day one would just be red forever.
**Also worth having:** an unused-key report. Keys outlive the components that used them, and nothing
else will ever notice.
---
## Verification
- `bunx tsgo` clean. A wrong key is a type error, so this is the primary check.
**Note for this machine:** `earlyoom` (`-r 3600`) SIGTERMs `tsgo` under memory pressure — it dies
around 15s with `Command failed`. That is not a type error. Free memory or scope the check.
- `bun test` — parity test green for every shipped locale.
- `bun scripts/i18n/scan-hardcoded.ts` — count strictly decreasing per phase.
- By hand: switch the locale to `pt-PT` and walk the converted area. Untranslated strings should read
as **English, never as dot-paths**.
- Check a plural at 0, 1 and 2, and a date, in both locales. These are the two things that look fine
in English and wrong everywhere else.
---
## Open questions
1. **Routes.** Deferred deliberately. Nothing here depends on it, and localized URLs bring
canonicalization and redirect questions that are not worth answering yet.
2. **Who translates?** Machine translation for `pt-PT` and `pt-BR` reviewed by the owner is the
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
manifest. Their strings are not counted above.
**One constraint that contract has to answer, found while converting the shared components
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
provider-absent fallback inside the `components` package was considered and rejected: it would have
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
+210
View File
@@ -0,0 +1,210 @@
# Roles and teams
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
design, written down so the work starts from a spec rather than from a conversation.
## What this is
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
a homelab and it cannot express a company, where the same person is an administrator of one area and an
ordinary member of another.
Two things are wanted, and they are separable:
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
provisioning.
- **Teams**, so a user belongs to several and holds a different role in each.
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
uses both. Nothing below may make teams mandatory to reach a feature.
---
## What already landed — phase 0, 2026-08-23
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
Verified against a scratch database: push reported the violation, created the table, skipped the
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
---
## Decisions
### D1. The owner is an identity. It is never a role.
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
whatever UI manages roles.
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
else can wait behind it.
### D2. Several Super Admins are allowed, and the role provisions sudo
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
a company has an owner and several administrators, and a homelab owner may add one or never do.
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
they left behind.
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
the UI to say what it is rather than presenting it as one more row in a dropdown.
### D3. `Admin` is app-wide authority with no operating-system privilege
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
"administering the platform is not developing on it". This decision only writes that down.
### D4. A role may be deleted, but not while anyone holds it
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
nobody asked for, and "absence denies" is the posture everywhere else in this system.
`isSystem` roles refuse both delete and rename, because the code names them by name:
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
`Member`. Renaming one is a code change that has not happened yet.
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
rename cascades to every user and every grant, atomically, with no code change.
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
### D6. A team is a scope. Permissions UNION across a user's teams.
Admin in marketing and member in developers means marketing's admin level does not reach developers'
files — but a user's effective permission for a given capability is the **highest level any of their teams
grants**, plus their global role.
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
should be refused rather than half-built.
### D7. The global role remains, and answers a different question
Two questions, two mechanisms:
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
- **"What may this account do in this area?"** — team membership with a role in that team.
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
has no natural team to ask it about. A homelab uses only the first and never makes a team.
### D8. Per-role provisioning is a script the role carries
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
in code that should become a row.
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
rootless specifically. `usermod -aG docker` is root by a longer route —
`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet seed. Any
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
---
## Phases
### Phase 0 — roles are a table ✅ DONE 2026-08-23
Additive only. Nothing reads it.
### Phase 1 — the owner stops being a role
The load-bearing phase, and small.
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
"owner or Super Admin" is a separate, reviewable change per route.
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
Reviewable on its own, and nothing downstream can start until ownership is an identity.
### Phase 2 — the foreign key, and role CRUD
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
replace what the compiler was doing.
### Phase 3 — per-role provisioning
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
D8's warning in front of whoever writes the first script.
### Phase 4 — teams
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
one table with a nullable `team_id`.
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
4. Teams UI, and the account screen showing which teams a user is in and as what.
### Phase 5 — the thing that actually unblocks all of it
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
for accounts that cannot yet exist.
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
find nothing to test it with. **It may be worth doing first.**
---
## What must not be built
- **Deny rules.** See D6. Teams grant; they never restrict.
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
the answer is a `confined` grant with a Linux user, which is per-account by nature.
## Verification
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
binary directly.
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
## Open questions
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
or a self-service one.
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
holds globally; the same question exists per team and is not answered here.
+93
View File
@@ -0,0 +1,93 @@
# SendOff — files land in the user's own tree, and a push says where
**From:** mobile · **Date:** 2026-08-23 · **v2, replacing this morning's spec entirely.** The v1
design (staging area, transfer/target/file tables, ack lifecycle, TTL cleanup) is withdrawn — Andre's
observation kills it: every user already has a filesystem on the server, a file browser on the web,
and a Files app on the phone. SendOff is not a new storage system; it is a folder convention, one
endpoint, and a push.
## The convention
```
~/Downloads/sendoff/from-<username>/ ← a file another USER sent you
~/Downloads/sendoff/<machine>/ ← a file you sent yourself, aimed at a named device
```
That is the whole data model. Nothing to ack, nothing to expire, nothing to clean up — the files are
ordinary files in the recipient's tree, visible in every file UI that already exists, managed like
anything else they own.
## The endpoint
```
POST /api/sendoff/send
multipart: any number of `file` parts + a `meta` part:
{ "toUser": "<username>" } — cross-user: writes into THEIR tree, from-<sender>/
{ "machine": "<device name>" } — self: writes into own tree, <machine>/
→ { path: "Downloads/sendoff/…", files: [{ name, size }] }
```
Server-side because of the cross-user case: `/api/file-browser/upload` correctly writes only the
caller's own tree, and that must not change. This endpoint is the one sanctioned door into another
user's filesystem, and it writes ONLY under `Downloads/sendoff/from-<sender>/` — the sender's
identity comes from their token, never from the payload, so the folder name cannot lie about who
sent it. Filenames sanitised, collisions suffixed `(1)` — never overwritten; a second send of the
same name is a second file.
Whether user A may send to user B at all is a platform policy decision (everyone on the server? a
grant?) — flagging it rather than deciding it here.
## The push
On write, push to the recipient's devices through the existing pipeline:
```
{ "officer": { "c": "officer/core:sendoff.landed", "path": "Downloads/sendoff/from-andre" } }
alert: "photo.jpg from andre" / "3 files from omega"
```
The push catalogue's route for the category:
```
officer://files?path={path}
```
Mobile opens routes from the catalogue verbatim (`openNotificationRoute`), and Officer now parses
`officer://files?path=…` into the Files app at that folder. The web can deep-link its file browser
the same way.
For a `machine`-targeted send, push only to that machine's device if it can be identified; to all
the user's devices otherwise — the path in the folder name already says who it was for.
## Devices
The `machine` name needs a picker on the sending side, so a minimal registry survives from v1:
```
devices: id (client uuid), user_id, name, platform, last_seen_at
POST /api/devices/register { id, name, platform } (upsert; called on every app start)
GET /api/devices → { devices } (the "send to my…" picker)
PATCH /api/devices/:id { name } · DELETE /api/devices/:id
```
No link to transfers — nothing references a device row except the picker and, best-effort, push
targeting.
## Web UI
One send form: files + target (one of my machines / another user), POST, done. Received files need
no UI at all — they are in the file browser already.
## What mobile already does (shipped against this)
- Registers the device on start (silent failure until the endpoint exists).
- Parses `officer://files?path=…` and opens the Files app at that path — this is the piece that
makes the push tap land somewhere.
- SendOff screen: the device's name (the folder senders will aim at), and an explanation of where
arrivals land. Sending FROM the phone comes later, likely through the existing share sheet.
## Out of scope
Receiving in the browser (already exists — it is the file browser); cross-user permission model
(platform decision); phone-side arbitrary-file sending (needs a document picker; the share sheet is
the natural door and is a later step).
+20 -1
View File
@@ -29,13 +29,32 @@ Seven, all running.
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
## Deliberately skipped
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
and it must not be reported as one.
| Host | Since | Why |
| ---------------------- | ---------- | ---------------------------------------------------------------- |
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
Its row, kept so restoring it is one move:
```
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
```
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
is what this section exists to prevent: a host that is known to be down should cost a line in a report,
not a retry budget.
### Names that disagree with themselves
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
+8 -3
View File
@@ -1,12 +1,15 @@
{
"name": "officer",
"version": "2026.08.22.01",
"version": "2026.08.23.04",
"private": true,
"type": "module",
"workspaces": [
"src/databases/*",
"src/workspaces/*"
],
"engines": {
"bun": "1.3.x"
},
"scripts": {
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
"gen:index": "bun run ./scripts/gen-index.ts",
@@ -27,14 +30,16 @@
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
"db:gen": "cd src/databases/officer_db && bun run generate",
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts",
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
"db:migrate": "cd src/databases/officer_db && bun run migrate",
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/install.sh",
"check:theme": "bun scripts/check-theme-tokens.ts"
"check:theme": "bun scripts/check-theme-tokens.ts",
"docs:check": "bun run scripts/docs-check.ts",
"skills:platform": "bun run scripts/gen-platform-skill.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
+110 -1
View File
@@ -9,6 +9,27 @@ one.
---
## Arming for the first time — there is nothing to rehearse against
General commander doctrine says to run the action once before arming, so it is known to work unattended.
**That does not apply here, and reading it as "replay the last release" is wrong.**
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
release, which you must never do.
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
fights the claim or invents an occasion.
Two things follow from it, and they are the reason this is fine rather than a gap:
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
What is unproven is this loop, not the rollout.
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
## What wakes you
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
@@ -18,6 +39,21 @@ there is a release to deploy that has not been deployed.
git ls-remote --tags origin 'refs/tags/deploy/*'
```
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
long stretches.
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
already trusted.
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
a commit message, not a branch, not a push.
@@ -81,6 +117,21 @@ load-bearing. A missing report is a missing record; a missing lock is a second d
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
clone does not arrive believing it has deployed nothing.
## Before a release is tagged — not your job, but know it happened
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
thirteen, listing eleven processes that do not run here while omitting six that do.
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
it would put a prose edit between two hosts.
## The release
The tag names the version. The commit it points at carries the rest:
@@ -106,6 +157,11 @@ That document holds every machine, its tailnet name, **its login user**, its che
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
platform as out of scope.
It also has a **Deliberately skipped** section. A host listed there is not a target: name it as skipped
in the report and spend nothing on it. On 2026-08-23 a host that was known to be down cost four attempts
and a minute apiece before being reported unreachable — correct behaviour against a list that had not been
told. Read that section before you try to reach anything.
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
@@ -133,7 +189,15 @@ assemble at the end from memory is a report that will be wrong about the host th
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
discard someone's work to ship a release.
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
2. **`git fetch origin --tags` then `git merge --ff-only <tag>`** — the TAG you are deploying, not
`master`. `git pull --ff-only` follows the branch, so a commit pushed while you are rolling out lands
on some hosts and not others, and "deployed the tag" stops being true of any of them. That happened on
2026-08-23: every host came up one commit past the tag because a docs commit landed mid-rollout. It was
harmless and it need not have been.
A non-fast-forward means that checkout has diverged: skip and report. A tag that will not fast-forward
because the host is AHEAD is the same answer — report it, do not reset.
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
@@ -149,6 +213,51 @@ assemble at the end from memory is a report that will be wrong about the host th
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
one that stopped in the middle and said nothing about the four hosts it never reached.
### Schema changes, and why `db:push` cannot be trusted
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
success while the schema is half-applied.
So on any release whose plan says the schema changed:
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
a guess.
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
table empty and adds the constraint in the same run, so every existing row violates it. There is no
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
This is allowed and it is not a licence to improvise. The rules, in order:
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
that is a code change and it is not yours to make on five production hosts at 3am.
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
the fleet has diverged in a way nothing will detect.
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
forensic exercise.
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
finding for the report rather than something to push past.
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
the specific check, and its answer, for each.
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
repair you invented.
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
### Skip the host you are running on
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# The wait half of the deploy commander: poll for a release tag, and when one appears that has not been
# attempted, spawn a headless agent to deploy it.
#
# WHY THIS IS IN THE REPOSITORY AND NOT WRITTEN FRESH EACH ARMING
#
# prompts/deploy-commander.md says the watcher loop is rewritten from scratch every time one arms, and
# treats that as a given. It is also how the same defect keeps arriving. The loop that was on disk at
# data/deploy-watch.sh from 2026-08-21 gated on "$v.done" — a file that exists only on SUCCESS — and never
# called deploy-claim.sh at all. That is precisely the retry storm the claim script was written to make
# impossible, reintroduced in the one place the claim script cannot reach: the caller that decides whether
# to invoke it. A rule enforced by a script nobody calls is not enforced.
#
# So the loop lives next to the claim it depends on, and changes with it.
#
# THE BASELINE IS THE CLAIM, NOT THE SUCCESS
#
# A watcher needs to know what counts as new. Here that is "no claim directory for this version" — a record
# of what has been HANDLED, not of what has SUCCEEDED. The distinction is the whole point: a release that
# failed is handled. It is looked at by a human, not retried by a loop.
#
# deploy-claim.sh honours a pre-existing .done with no .claim, so releases deployed before it existed are
# already skipped and arming this today fires on none of them.
#
# THE CLAIM IS TAKEN HERE, BEFORE THE SPAWN
#
# An action that claims its own work has claimed nothing if it dies before getting that far. So the loop
# claims, then spawns — which means the child must be TOLD the claim is already held. It is about to read
# prompts/deploy-commander.md, whose claim instruction is addressed to whoever reads it, and an obedient
# child that claims for itself gets exit 1 and correctly refuses to deploy. See PROMPT below.
set -u
PLATFORM_DIR="${PLATFORM_DIR:-$HOME/officer-dev/platform}"
# Empty in the sidecar's environment, where anything built from it resolves to /data/... and the record
# lands where nothing reads it. A watcher whose baseline is invisible to itself has none.
OFFICER_ROOT="${OFFICER_ROOT:-$HOME/officer-dev}"
DEPLOYS_DIR="${DEPLOYS_DIR:-$OFFICER_ROOT/data/deploys}"
POLL_SECONDS="${POLL_SECONDS:-120}"
export OFFICER_ROOT DEPLOYS_DIR
CLAIM_SH="$PLATFORM_DIR/scripts/deploy-claim.sh"
stamp() { date -Iseconds; }
if [ ! -x "$CLAIM_SH" ]; then
echo "$(stamp) deploy-watch: no claim script at $CLAIM_SH — refusing to arm without one" >&2
exit 2
fi
mkdir -p "$DEPLOYS_DIR" || exit 2
echo "$(stamp) deploy-watch: armed. polling $(cd "$PLATFORM_DIR" && git remote get-url origin) every ${POLL_SECONDS}s"
echo "$(stamp) deploy-watch: baseline is $DEPLOYS_DIR — a version with a claim is never fired again"
while true; do
# origin is the LOCAL gitea over the tailnet. The public hostname arrives through a proxy where every
# connection shares one source IP and SSH is refused for long stretches — which presents as an
# access-rights error and is not one. Eighty minutes of detection latency on 2026-08-21.
raw=$(cd "$PLATFORM_DIR" && timeout 30 git ls-remote --tags origin 'refs/tags/deploy/*' 2>&1)
rc=$?
if [ $rc -ne 0 ]; then
echo "$(stamp) tick: ls-remote failed (exit $rc) — ${raw%%$'\n'*}"
sleep "$POLL_SECONDS"
continue
fi
versions=$(printf '%s\n' "$raw" | grep -v '\^{}' | awk '{print $2}' | sed -E 's#.*refs/tags/deploy/##' | sort -u)
pending=""
for v in $versions; do
[ -n "$v" ] || continue
[ "$("$CLAIM_SH" state "$v" 2>/dev/null)" = unclaimed ] && pending="$pending $v"
done
# Every tick leaves a line, including the quiet ones: a fresh timestamp is the only thing that tells
# "nothing to deploy" apart from "the loop died".
echo "$(stamp) tick: $(printf '%s\n' "$versions" | grep -c .) tags,${pending:- none} pending"
for v in $pending; do
if ! "$CLAIM_SH" claim "$v"; then
# Lost a race with another watcher, or it was claimed between the state check and here. Either way
# it is not ours and never becomes ours.
continue
fi
echo "$(stamp) deploy/$v claimed — spawning deploy agent"
# The child reads the procedure, which tells ITS reader to claim first. It must not: we hold the claim
# on its behalf, and its own claim would return exit 1 and stop the deploy it was spawned to do.
PROMPT="Read prompts/deploy-commander.md in $PLATFORM_DIR and follow it exactly, for the release deploy/$v.
The claim for $v is ALREADY HELD on your behalf by the watcher that spawned you. Do NOT run
'scripts/deploy-claim.sh claim $v' — it will refuse, correctly, because the claim is taken, and stopping
there would abandon a deploy you were started to perform. Everything else in that file applies unchanged,
including running 'scripts/deploy-claim.sh done $v' once the rollout has succeeded.
Roll it out across every host the plan names, skip the host you are running on, and write the report
exactly as the procedure specifies."
raw_log="$DEPLOYS_DIR/$v.child.jsonl"
# Nothing links a spawned agent to its parent and a child transcript does not last, so the stream is
# kept here as it arrives. stderr never enters the pipe: the CLI writes warnings there and one landing
# in the stream corrupts the first jq parse.
{
cd "$PLATFORM_DIR" && claude -p "$PROMPT" \
--dangerously-skip-permissions \
--output-format=stream-json \
--verbose
} 2> >(while IFS= read -r l; do echo " ! $l"; done) \
| tee -a "$raw_log" \
| jq -r --unbuffered '
if .type=="system" and .subtype=="init" then
" · child session " + .session_id
elif .type=="assistant" then
(.message.content[]? |
if .type=="tool_use" then
" ⚙ " + .name + ": " + ((.input.description // .input.command // .input.pattern // (.input | tostring)))
elif .type=="text" then
" » " + .text
else empty end)
elif .type=="user" then
(.message.content[]? | select(.type=="tool_result") |
" " + (if (.content|type)=="string" then .content else (.content // [] | map(.text? // "") | join(" ")) end))
elif .type=="result" then
" = deploy agent " + .subtype + ", " + (.num_turns|tostring) + " turns, " + (.total_cost_usd|tostring) + " usd"
else empty end
' 2>/dev/null
if [ -f "$DEPLOYS_DIR/$v.done" ]; then
echo "$(stamp) deploy/$v SUCCEEDED — recorded, transcript at $raw_log"
else
# The claim stays held. This is the loop's most important line and the one that used to say
# "will retry next poll".
echo "$(stamp) deploy/$v FAILED — no .done written. NOT retrying; the claim is held."
echo "$(stamp) transcript: $raw_log"
echo "$(stamp) to allow another attempt, deliberately: rm -rf $DEPLOYS_DIR/$v.claim"
fi
done
sleep "$POLL_SECONDS"
done
+43
View File
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { Glob } from 'bun';
/*
Which settings sections actually exist, read from the screens that declare them.
Separated from `docs-check.ts` because it is the only check that parses source rather than importing it —
the sections are JSX-adjacent object literals inside components, so importing them would drag React and a
browser environment into a CLI. A regex over `BASE_PATH` and `key:` is the honest tool for that, and it is
narrow enough to be obvious when it breaks.
*/
const ROOT = resolve(import.meta.dir, '..');
const SETTINGS_DIR = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Settings');
/** `/settings/profile` + `change-password` → `/settings/profile/change-password`. */
export async function declaredSettingsPaths(): Promise<Set<string>> {
const paths = new Set<string>();
const glob = new Glob('**/*.tsx');
for await (const file of glob.scan({ cwd: SETTINGS_DIR, absolute: true })) {
const body = readFileSync(file, 'utf-8');
const base = /BASE_PATH\s*=\s*'([^']+)'/.exec(body)?.[1];
if (!base) continue;
paths.add(base);
for (const m of body.matchAll(/key:\s*'([^']+)'/g)) paths.add(`${base}/${m[1]}`);
}
return paths;
}
/**
* Settings routes Spotlight offers, and which of them nothing declares.
*
* Parsed rather than imported for the same reason: `useSpotlightItems` is a hook and pulls in the dock,
* lucide and React.
*/
export async function SPOTLIGHT_SETTINGS_PATHS(): Promise<{ offered: string[]; missing: string[] }> {
const file = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts');
const body = readFileSync(file, 'utf-8');
const offered = [...body.matchAll(/to:\s*'(\/settings\/[^']+)'/g)].map((m) => m[1]!);
const declared = await declaredSettingsPaths();
return { offered, missing: offered.filter((p) => !declared.has(p)) };
}
+169
View File
@@ -0,0 +1,169 @@
/*
Does the documentation still match the code?
── Why a script and not a habit ──
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
number is exactly the thing nobody re-derives while editing a sentence around it.
Real examples this was written from, both live at the time:
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
── The rule for adding a check ──
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
the same edit that misses the doc.
*/
import { readFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
type Finding = { file: string; claim: string; documented: string; actual: string };
type CheckResult = { findings: Finding[]; skipped?: string };
const ROOT = resolve(import.meta.dir, '..');
const WORKSPACE = resolve(ROOT, '..');
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
const WORDS: Record<string, number> = {
ten: 10,
eleven: 11,
twelve: 12,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20,
'twenty-one': 21,
'twenty-two': 22,
'twenty-three': 23,
'twenty-four': 24,
'twenty-five': 25,
};
const asNumber = (raw: string): number | null => {
const digits = Number(raw);
if (Number.isFinite(digits)) return digits;
return WORDS[raw.toLowerCase()] ?? null;
};
/**
* A documented count, found by a pattern with one capture group, compared to the live value.
*
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
*/
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
const body = read(file);
if (!body) return { findings: [], skipped: `${file} not found` };
const findings: Finding[] = [];
for (const match of body.matchAll(pattern)) {
const stated = asNumber(match[1] ?? '');
if (stated === null || stated === actual) continue;
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
}
return { findings };
}
async function main(): Promise<void> {
const all: Finding[] = [];
const skipped: string[] = [];
const record = (r: CheckResult) => {
all.push(...r.findings);
if (r.skipped) skipped.push(r.skipped);
};
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
record(
checkCount(
join(ROOT, 'CLAUDE.md'),
/([0-9]+|[a-z-]+) entries as of/gi,
PERMISSIONS.length,
'permission registry entries',
),
);
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
// admission that it needs a check rather than a caveat.
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
if (pm2.exitCode === 0) {
try {
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
record(
checkCount(
join(WORKSPACE, 'CLAUDE.md'),
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
officer,
'officer PM2 entries',
),
);
} catch {
skipped.push('pm2 jlist did not parse');
}
} else {
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
skipped.push('pm2 unavailable — PM2 entry count unchecked');
}
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
const declared = await SPOTLIGHT_SETTINGS_PATHS();
for (const bad of declared.missing) {
all.push({
file: 'Spotlight/useSpotlightItems.ts',
claim: 'settings route offered by Spotlight',
documented: bad,
actual: 'no screen declares that section',
});
}
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
// an agent quotes it to a user with no way to tell it is out of date.
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
const expected = await renderPlatformSkill();
const onDisk = read(SKILL_PATH);
if (onDisk === null) {
all.push({
file: 'capabilities/skills/platform/SKILL.md',
claim: 'generated platform skill',
documented: 'missing',
actual: 'run `bun run skills:platform`',
});
} else if (onDisk !== expected) {
all.push({
file: 'capabilities/skills/platform/SKILL.md',
claim: 'generated platform skill is stale',
documented: `${onDisk.split('\n').length} lines on disk`,
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
});
}
for (const note of skipped) console.log(` skipped: ${note}`);
if (all.length === 0) {
console.log('docs:check — every checkable claim matches the code');
return;
}
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
for (const f of all) {
console.log(` ${f.file}`);
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
}
process.exit(1);
}
await main();
+38
View File
@@ -34,6 +34,44 @@ const fail = (what: string, fix: string) => {
};
const pass = (what: string) => console.log(`${green('✓')} ${what}`);
console.log('\n── Runtime ──\n');
/**
* bun is deliberately held at a series — `BUN_SERIES` in
* scripts/setup/machine-setup/lib/dev.sh — while 1.4 is watched for a while.
*
* The setup script can pin the INSTALL and nothing more. `bun upgrade` takes no version
* argument (it offers latest-stable or --canary and nothing else), so it always jumps to the
* newest release and walks a pinned machine off the pin without saying so. This is the only
* thing that notices, which is why a version check sits in a script otherwise about accounts.
*
* The wanted series is READ from `engines.bun` rather than repeated here, because a second
* hardcoded copy is a second thing to drift. bun does not enforce that field — verified: an
* install declaring `engines.bun: "1.9.x"` ran clean under 1.3.14, no warning — so the field is
* the declaration and this is the enforcement.
*/
const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as {
engines?: { bun?: string };
};
const wantSeries = (pkg.engines?.bun ?? '').replace(/\.x$/, '');
const nowSeries = Bun.version.split('.').slice(0, 2).join('.');
if (!wantSeries) {
fail(
'package.json declares no engines.bun',
'The setup scripts pin a bun series and nothing else records which one. Add it.',
);
} else if (nowSeries === wantSeries) {
pass(`bun ${Bun.version} ${dim(`(inside the pinned ${wantSeries} series)`)}`);
} else {
fail(
`bun ${Bun.version} is outside the pinned ${wantSeries} series`,
`Usually a 'bun upgrade', which always takes the newest release. Pin it back with ` +
`'curl -fsSL https://bun.sh/install | bash -s bun-v<newest ${wantSeries}.x>', or raise ` +
`engines.bun and BUN_SERIES together if ${nowSeries} is now wanted.`,
);
}
console.log('\n── Accounts ──\n');
const users = await getUsers();
+167
View File
@@ -0,0 +1,167 @@
/*
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
── Why generated ──
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
and the prose is limited to what no registry can express.
── The split, and where the line is ──
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
keyboard shortcuts
PROSE what the platform IS, what a permission KIND means for a user, where to look next
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
differs, so a stale skill is a failed check rather than a confident wrong answer.
*/
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
const KIND_MEANING: Record<string, string> = {
core: 'every account has it and it cannot be denied — the caller acting on themselves',
app: 'grantable per role, at read or write',
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
admin: 'the server owner only',
};
export async function renderPlatformSkill(): Promise<string> {
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
const enCatalog = (await import('../src/apps/officer-web/locales/en-GB.json')).default as Record<string, unknown>;
/** Resolve a dot-path against the English catalog. This doc is for agents and stays in one language. */
const enString = (key: string): string => {
const value = key.split('.').reduce<unknown>((node, part) => {
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
return undefined;
}, enCatalog);
if (typeof value !== 'string') throw new Error(`gen-platform-skill: no English string for "${key}"`);
return value;
};
const byKind = new Map<string, typeof PERMISSIONS>();
for (const p of PERMISSIONS) {
const list = byKind.get(p.kind) ?? [];
list.push(p);
byKind.set(p.kind, list as typeof PERMISSIONS);
}
const lines: string[] = [];
const out = (s = '') => lines.push(s);
out('---');
out('name: Officer platform');
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
out('description: >-');
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
out('version: 1');
out('---');
out();
out('<!--');
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
out(' differs from what the generator produces, and an edit here is lost on the next run.');
out();
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
out('-->');
out();
out('# What Officer is');
out();
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
out('user, and **absence denies** — there is no row meaning "no".');
out();
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
out('no, and *not shareable at all*.');
out();
out('# Permissions');
out();
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
out('diligence.');
out();
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
const entries = byKind.get(kind) ?? [];
if (!entries.length) continue;
out(`## \`${kind}\` — ${meaning}`);
out();
for (const p of entries) {
const bits: string[] = [];
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
if (bits.length) out(` - ${bits.join(' · ')}`);
if (p.selfService?.length) {
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
}
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
if (p.readOnlyWrites?.length) {
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
}
}
out();
}
out('# Keyboard shortcuts');
out();
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
out('list rather than remembering one.');
out();
// The registry holds catalog keys since the shell was localized; this doc is agent-facing and stays
// English, so it resolves against en-GB rather than the reader's locale.
for (const section of SHORTCUT_SECTIONS) {
out(`## ${enString(section.titleKey)}`);
if (section.noteKey) {
out();
out(enString(section.noteKey));
}
out();
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${enString(s.descriptionKey)}`);
out();
}
out('# What is NOT in here');
out();
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
out(' commander is, how one is started, and how to help somebody shape one.');
out();
out('# When you are about to guess');
out();
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
out('certain as one quoting a true one.');
return `${lines.join('\n')}\n`;
}
if (import.meta.main) {
const body = await renderPlatformSkill();
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
if (existing === body) {
console.log('skills:platform — already current');
} else {
writeFileSync(SKILL_PATH, body);
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
}
}
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env bun
/**
* Counts candidate hardcoded user-facing strings in the web UI.
*
* Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area
* reaches zero it becomes the guard that stops the debt coming back — see ZEROED below.
*
* It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries
* inside JSX, strings passed down as props, or anything a plugin renders. A falling number means
* progress; a zero means "nothing this scanner can see", not "nothing left".
*
* bun scripts/i18n/scan-hardcoded.ts # summary by area
* bun scripts/i18n/scan-hardcoded.ts --files # worst files first
* bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves
* bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const AREAS = [
{ label: 'officer-web (shell)', root: 'src/apps/officer-web' },
{ label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' },
{ label: 'shared components', root: 'src/workspaces/components' },
] as const;
/**
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
* zero — enabling every area on day one would just be red for weeks and get ignored.
*
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
* that must NEVER be translated; they are reported but excluded from the check, which is why the
* neverTranslate split below has to exist for this entry to be possible at all.
*
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
* `stripUntranslated` skips for the same reason.
*/
const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
/**
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
*
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
*/
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
// >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends
{
kind: 'attributes',
rx: new RegExp(
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
'g',
),
},
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
{
kind: 'object labels',
rx: new RegExp(
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
'g',
),
},
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
];
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
function isHuman(value: string | undefined): value is string {
if (!value) return false;
const s = value.trim();
if (s.length < 3) return false;
if (!/[A-Za-z]{3}/.test(s)) return false;
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
// Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
if (/^[A-Z0-9_.-]+$/.test(s)) return false;
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
// followed by a credential is never something a person reads.
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
// A single token carrying digits AND punctuation is a name, not a sentence — the OCR section's
// placeholder is the example model file `Qwen2.5-VL-7B-Instruct-q4_k_m.gguf`. Prose has spaces; the
// whitespace test is what keeps this from eating real one-word labels, which never look like this.
if (!/\s/.test(s) && /\d/.test(s) && /[-_.]/.test(s)) return false;
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
// `description: 'group-[.toast]:text-muted-foreground'`.
//
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
const tokens = s.split(/\s+/);
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
return true;
}
/**
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
*
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
*/
const stripComments = (source: string): string =>
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
/**
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
* thing it documents.
*/
const stripCodeElements = (source: string): string =>
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
/**
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
* another product's UI, which docs/localization.md classifies as an identifier.
*
* Skipped for the same reason `neverTranslate` categories are excluded from `--check`: counting work the
* doc FORBIDS doing against a target of zero makes the target unreachable. The marker is explicit and
* carries a reason, so this cannot become a quiet catch-all — unlike a heuristic, somebody has to write
* it and say why.
*/
const stripUntranslated = (source: string): string =>
source.replace(/<Untranslated\b[^>]*>[\s\S]*?<\/Untranslated>/g, '<Untranslated/>');
function* walk(dir: string): Generator<string> {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const path = join(dir, entry);
if (SKIP.test(path)) continue;
let isDir = false;
try {
isDir = statSync(path).isDirectory();
} catch {
continue;
}
if (isDir) yield* walk(path);
else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path;
}
}
type Hit = { kind: string; value: string; file: string };
function scan(root: string): { files: number; hits: Hit[] } {
const hits: Hit[] = [];
let files = 0;
for (const file of walk(root)) {
files++;
let source: string;
try {
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
} catch {
continue;
}
for (const { kind, rx } of PATTERNS) {
rx.lastIndex = 0;
for (const match of source.matchAll(rx)) {
const value = match.slice(1).find((group) => group !== undefined);
if (isHuman(value)) hits.push({ kind, value: value.trim(), file });
}
}
}
return { files, hits };
}
const args = new Set(process.argv.slice(2));
const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined;
const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n);
console.log(
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
);
for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
}
const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
console.log(
`${'TOTAL'.padEnd(22)} ${pad(
results.reduce((n, r) => n + r.files, 0),
5,
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
);
console.log();
console.log(`occurrences ${all.length}`);
console.log(` convertible ${convertible(all).length}`);
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
console.log(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`);
if (args.has('--files')) {
console.log('\nworst files:');
const perFile = new Map<string, number>();
for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1);
for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
console.log(` ${pad(n, 4)} ${file}`);
}
}
if (listPrefix) {
console.log(`\nstrings in files matching "${listPrefix}":`);
for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`);
}
if (args.has('--check')) {
const regressed = results
.filter((r) => ZEROED.includes(r.label))
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
.filter((r) => r.hits.length > 0);
if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:');
for (const r of regressed) {
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
}
console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1);
}
console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Seed the `roles` table with the roles the platform's own code names.
*
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
* mechanism `backfill-session-models.ts` relies on.
*
* ── Why these four are `isSystem` ──
*
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
*
* ── What this deliberately does NOT do ──
*
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
* and adds the constraint in the same run, so every existing account violates it — verified against a
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
*
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
* unattended.
*
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
*/
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
const SEED: { name: string; description: string }[] = [
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
];
async function main(): Promise<void> {
await waitForDatabase();
let created = 0;
for (const role of SEED) {
if (await seedRole({ ...role, isSystem: true })) created += 1;
}
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
}
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
// a successful push look like a failed one — the rows are additive and the next push tries again.
main()
.catch((err: unknown) => {
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
})
.finally(() => process.exit(0));
+81 -4
View File
@@ -206,7 +206,7 @@ nvim_clone_config() {
# every process here. officer-pty imports node-pty, a native addon with
# no Linux prebuild — it compiles against the installed Node on every
# machine. Either one alone makes Node load-bearing.
# bun the platform itself and nineteen of the twenty pm2 apps.
# bun the platform itself, and every pm2 app except officer-pty.
# pm2 the process manager the ecosystem files are written for.
#
# Deno is not. Nothing in the platform imports it — checked across the whole
@@ -243,6 +243,69 @@ install_node() {
npm config set prefix /usr/local >/dev/null 2>&1 || true
}
# ── Held at the 1.3 series, deliberately and temporarily ──
#
# bun 1.4.0 landed on 2026-08-20 and https://bun.sh/install takes whatever is
# newest, so a machine provisioned two days later puts a brand-new release under
# the platform and every pm2 app it runs. Held at 1.3 until 1.4 has been watched
# for a while. That is the owner's call and it is meant to be revisited in a few
# months, not a permanent ceiling — raise BUN_SERIES when 1.4 has earned it.
#
# The SERIES is pinned, not a patch. 1.3.x fixes still land and should still be
# taken; a hardcoded 1.3.14 would rot into "the version we happened to pick"
# exactly the way setup_22.x did for Node.
#
# This covers the INSTALL path only, and cannot cover the other one: `bun upgrade`
# takes no version argument — checked against `bun upgrade --help` on 1.3.14, which
# offers latest-stable or --canary and nothing else — so it always jumps to the
# newest release and will walk a pinned machine onto 1.4 on its own. Nothing in a
# setup script can prevent that. `bun run doctor` is where that drift is caught.
BUN_SERIES="${BUN_SERIES:-1.3}"
# The newest stable release inside the series.
#
# Asked of the npm registry rather than GitHub's releases API, which is the obvious
# source and the wrong one: it rate-limits unauthenticated callers to 60 an hour per
# IP, and it returned 504 twice while this was being written. An empty answer here
# means no bun gets installed at all (see install_bun), so where it comes from is
# load-bearing rather than a matter of taste. bun publishes every release to npm.
#
# `sort -V` rather than trusting the order the registry returns them in, and the
# pattern is anchored so 1.3.1 cannot be mistaken for the newest when 1.3.14 exists.
bun_series_latest() {
curl -fsSL https://registry.npmjs.org/bun 2>/dev/null |
jq -r '.versions | keys[]' 2>/dev/null |
grep -E "^${BUN_SERIES//./\\.}\.[0-9]+$" |
sort -V | tail -1
}
# The major.minor of a full version — "1.3.14" is in series "1.3".
bun_series_of() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"${1:-}"; }
# Where an installed bun sits relative to the pin: in, above, below, or unknown.
#
# Above and below are different problems and get different answers — above is a
# machine that has upgraded itself past the pin, below is one that is simply old —
# so this reports which rather than a yes/no. `sort -V` does the comparing, so
# nothing here has to parse a version.
bun_series_position() {
local now
now="$(bun_series_of "${1:-}")"
[[ -n "$now" ]] || {
echo unknown
return
}
[[ "$now" == "$BUN_SERIES" ]] && {
echo in
return
}
if [[ "$(printf '%s\n%s\n' "$now" "$BUN_SERIES" | sort -V | tail -1)" == "$now" ]]; then
echo above
else
echo below
fi
}
# Present anywhere: on PATH for this root shell, or in the account's own
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
@@ -260,8 +323,8 @@ bun_version() {
# The system-wide link, ensured on every run rather than only after an install.
#
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
# then fails to start on reboot while working perfectly when started by hand. A
# PATH — and every ecosystem app that says `script: 'bun'` then fails to start on
# reboot while working perfectly when started by hand. A
# machine that already had bun before this script ran would never get the link if
# it were only made as part of installing.
#
@@ -280,8 +343,22 @@ ensure_bun_symlink() {
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
# symlink every bun-based sidecar fails to start on reboot and works fine when
# started by hand, which is a miserable thing to debug.
#
# The version goes in as the installer's first positional argument, which it
# documents as "a specific tag of bun to install (e.g. bun-v0.1.4)" and turns into a
# releases/download/<tag>/ URL. `bash -s` is what carries an argument through a pipe.
# The installer handles being pointed at an older tag on purpose — it re-probes AVX2
# rather than assuming the baseline the newest release was built for — so this both
# installs and pins back down.
#
# It REFUSES to run unpinned. An empty tag means bun_series_latest could not reach
# the registry, and installing whatever is newest is the single outcome the pin
# exists to prevent, so a machine with no bun is the better failure: it is loud, and
# officer-setup's pre-flight stops on a missing bun rather than building on 1.4.
install_bun() {
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
local tag="${1:-}"
[[ -n "$tag" ]] || return 1
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
}
+88 -9
View File
@@ -1981,14 +1981,18 @@ step "JavaScript runtimes"
if ! skip; then
NODE_LTS="$(node_lts_major)"
NODE_NOW="$(node_installed_major)"
# Resolved before the banner so it can state the pin rather than only the version
# that happens to be here. One network call, same as node_lts_label above it.
BUN_WANT="$(bun_series_latest)"
BUN_NOW="$(bun_version)"
echo ""
info "JavaScript runtimes — required, so these are installed rather than offered"
echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
echo " pm2 is a Node application, and officer-pty compiles node-pty"
echo " against whatever Node is installed — there is no Linux prebuild."
echo " bun $(bun_installed && bun_version || echo 'not installed')"
echo " the platform itself, and nineteen of the twenty pm2 apps."
echo " bun ${BUN_NOW:-not installed} — pinned to the ${BUN_SERIES} series${BUN_WANT:+, newest is ${BUN_WANT}}"
echo " the platform itself, and every pm2 app except officer-pty."
echo " pm2 $(pm2_installed && echo installed || echo 'not installed')"
echo " supervises all of them, and the ecosystem files are written for it."
echo ""
@@ -2014,18 +2018,93 @@ if ! skip; then
fi
fi
# ── bun ──
if bun_installed; then
ok "bun $(bun_version) already installed"
else
info " installing bun..."
if install_bun && bun_installed; then
# ── bun, held at the pinned series ──
#
# Four states, because "installed" is not one answer. A machine can be inside the
# pin, behind it, or past it — and past it is the case worth catching, since that
# is what a `bun upgrade` leaves behind and nothing else reports it.
if [[ -z "$BUN_WANT" ]]; then
# No pin resolved means no install: taking whatever bun.sh serves is the one
# thing the pin exists to stop.
warn "could not reach the npm registry to find the newest bun ${BUN_SERIES}.x"
if bun_installed; then
echo " leaving bun ${BUN_NOW} where it is"
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; left ${BUN_NOW} alone")
else
echo " nothing installed — this will not install an unpinned bun, and"
echo " officer-setup stops on a missing bun rather than building on 1.4."
ERRORS+=("Bun: could not resolve ${BUN_SERIES}.x; nothing installed")
fi
elif ! bun_installed; then
info " installing bun ${BUN_WANT}, pinned to the ${BUN_SERIES} series..."
if install_bun "bun-v${BUN_WANT}" && bun_installed; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: $(bun_version)")
SUMMARY+=("Bun: $(bun_version) (pinned to ${BUN_SERIES}.x)")
else
warn "the bun install did not complete"
ERRORS+=("Bun: install failed")
fi
else
case "$(bun_series_position "$BUN_NOW")" in
in)
if [[ "$BUN_NOW" == "$BUN_WANT" ]]; then
ok "bun ${BUN_NOW} — the newest in the pinned ${BUN_SERIES} series"
else
info "bun ${BUN_NOW} is inside the pin, and ${BUN_WANT} is out"
echo " A patch inside the series is what the pin is meant to let"
echo " through. Note that 'bun upgrade' would not stop there — it"
echo " takes the newest release, which is past the pin."
if confirm "Move to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not move to ${BUN_WANT}")
fi
else
echo " left at ${BUN_NOW}"
fi
fi
;;
above)
# The `bun upgrade` case. Reported rather than corrected silently: moving
# somebody's runtime backwards is a decision, and this is the only place
# that knows it happened.
warn "bun ${BUN_NOW} is PAST the pinned ${BUN_SERIES} series"
echo " Most likely a 'bun upgrade', which takes no version argument and"
echo " always jumps to the newest release. The platform and every pm2"
echo " app except officer-pty run on this."
if confirm "Pin it back to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version) — back inside the pin"
SUMMARY+=("Bun: pinned back, ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not pin ${BUN_NOW} back to ${BUN_WANT}")
fi
else
warn "left at ${BUN_NOW}, outside the pin"
SUMMARY+=("Bun: ${BUN_NOW}, PAST the ${BUN_SERIES} pin by choice")
fi
;;
*)
info "bun ${BUN_NOW} is older than the pinned ${BUN_SERIES} series"
if confirm "Move to ${BUN_WANT}?"; then
if install_bun "bun-v${BUN_WANT}"; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: ${BUN_NOW} -> $(bun_version)")
else
warn "the install did not complete — bun ${BUN_NOW} is untouched"
ERRORS+=("Bun: could not move to ${BUN_WANT}")
fi
else
echo " left at ${BUN_NOW}"
fi
;;
esac
fi
# Ensured every run, not only after an install. A machine that already had bun
+6 -6
View File
@@ -760,7 +760,7 @@ if ! skip; then
echo " The ecosystem file is GENERATED, not checked in. It describes this"
echo " install and nothing else, so nothing in git can drift from it."
echo ""
echo " six processes:"
echo " ${#CORE_PROCESSES[@]} processes:"
for entry in "${CORE_PROCESSES[@]}"; do
IFS='|' read -r _name _script _args <<<"$entry"
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
@@ -772,7 +772,7 @@ if ! skip; then
if confirm "Write it and start them?"; then
write_ecosystem
ok "written — $(ecosystem_file)"
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
report_changed "wrote $(ecosystem_file) — ${#CORE_PROCESSES[@]} pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
# Starting against a database that is not answering is not fatal — the server
# waits and the agent retries forever — but it makes the Verify section below
@@ -792,13 +792,13 @@ if ! skip; then
if pm2_enable_startup; then
ok "pm2 will resurrect them at boot"
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
SUMMARY+=("Services: 6 processes started, enabled at boot")
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started, enabled at boot")
else
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; boot hook NOT enabled")
fi
else
SUMMARY+=("Services: 6 processes started; not enabled at boot")
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; not enabled at boot")
fi
else
warn "pm2 did not start cleanly"
@@ -945,7 +945,7 @@ fi
#
# Root, and that surprises people — reasonably, because everything this script just
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
# node_modules, .env, the secret store and every pm2 process are theirs. Root was
# the installer's privilege, never the platform's.
#
# Saying so matters for two things that are invisible until they bite:
+1 -1
View File
@@ -59,7 +59,7 @@ tool_why() {
case "$1" in
git) echo "to clone and update the platform" ;;
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
bun) echo "the platform itself and nineteen of the twenty processes" ;;
bun) echo "the platform itself and every process except the pty sidecar" ;;
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
docker) echo "Postgres, and anything the app store provisions" ;;
*) echo "" ;;
+2
View File
@@ -106,6 +106,8 @@ export function App() {
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useTranslation } from '@/lib/i18n';
export function ForgotPassword() {
const { t } = useTranslation();
const client = useClient('/api/auth');
const [isSubmitting, setIsSubmitting] = useState(false);
const [done, setDone] = useState(false);
@@ -34,29 +36,29 @@ export function ForgotPassword() {
<Card className="md:py-12 md:px-24 flex flex-col gap-6">
{done ? (
<div className="text-center">
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
<h2 className="text-2xl font-bold text-duck-dark mb-2">{t('auth.checkEmail')}</h2>
<div className="text-sm text-duck-dark/60">
<p>
A password reset link has been sent to <strong>{state.email}</strong>.
</p>
<p>Click it to reset your password.</p>
<p>{t('auth.checkEmailHint')}</p>
</div>
</div>
) : (
<>
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Forgot Password</div>
<div className="text-duck-dark/60">Enter your email to receive a reset link</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.forgotTitle')}</div>
<div className="text-duck-dark/60">{t('auth.forgotSubtitle')}</div>
</div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<span className="text-duck-dark/70">{t('auth.email')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
name="email"
placeholder="you@example.com"
placeholder={t('auth.emailPlaceholder')}
autoComplete="email"
/>
</Label>
@@ -74,7 +76,7 @@ export function ForgotPassword() {
<div className="text-center text-sm text-duck-dark/60">
<p>
<Link to="/" className="text-duck-forest underline hover:text-duck-forest/80">
Back to login
{t('auth.backToLogin')}
</Link>
</p>
</div>
@@ -4,8 +4,10 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { cn } from '@/lib/utils';
import { useResetPassword } from './useResetPassword';
import { useTranslation } from '@/lib/i18n';
export function ResetPassword() {
const { t } = useTranslation();
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useResetPassword();
const loading = tokenStatus === 'loading';
const invalid = tokenStatus === 'invalid';
@@ -15,45 +17,45 @@ export function ResetPassword() {
<>
{loading && (
<Card className="py-12 px-24 flex flex-col gap-6">
<div className="text-center text-duck-dark/60">Verifying...</div>
<div className="text-center text-duck-dark/60">{t('auth.verifying')}</div>
</Card>
)}
{invalid && (
<Card className="py-12 px-24 flex flex-col gap-6">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
<div className="text-duck-dark/60">This reset link is invalid or has expired.</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.invalidLink')}</div>
<div className="text-duck-dark/60">{t('auth.invalidLinkHint')}</div>
</div>
</Card>
)}
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
<div className="text-duck-dark/60">Enter your new password</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.resetTitle')}</div>
<div className="text-duck-dark/60">{t('auth.resetSubtitle')}</div>
</div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<div className="grid md:flex gap-4">
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span>
<span className="text-duck-dark/70">{t('auth.password')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Min 12 characters"
placeholder={t('auth.minChars')}
autoComplete="new-password"
/>
</Label>
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span>
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder="Confirm your password"
placeholder={t('auth.confirmPlaceholder')}
autoComplete="new-password"
/>
</Label>
@@ -65,7 +67,7 @@ export function ResetPassword() {
disabled={!isValid || isSubmitting}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
{isSubmitting ? 'Resetting...' : t('auth.resetTitle')}
</Button>
</div>
</form>
@@ -4,8 +4,10 @@ import { toast } from 'sonner';
import { useMounted } from 'hooks/useMounted';
import { useForm } from 'hooks/useForm';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
export const useResetPassword = () => {
const { t } = useTranslation();
const isMounted = useMounted();
const navigate = useNavigate();
const client = useClient('/api/auth');
@@ -44,7 +46,7 @@ export const useResetPassword = () => {
password: state.password,
verificationCode,
});
toast.success('Password reset successfully. Please sign in.');
toast.success(t('auth.resetOk'));
navigate('/');
} catch (ex) {
const error = ex as { message?: string };
@@ -7,8 +7,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/Card';
import { useForm } from 'hooks/useForm';
import { useTranslation } from '@/lib/i18n';
export function Bootstrap() {
const { t } = useTranslation();
const authClient = useClient('/api/auth');
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -30,7 +32,7 @@ export function Bootstrap() {
password: state.password,
confirmPassword: state.confirmPassword,
});
toast.success('Administrator account created. Please sign in.');
toast.success(t('auth.bootstrapOk'));
navigate('/');
} catch (ex) {
const error = ex as { message?: string };
@@ -43,35 +45,35 @@ export function Bootstrap() {
return (
<Card className="md:min-w-[480px] flex flex-col gap-6">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Welcome, Admin</div>
<div className="text-duck-dark/60">Create your administrator account</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.bootstrapTitle')}</div>
<div className="text-duck-dark/60">{t('auth.bootstrapSubtitle')}</div>
</div>
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<span className="text-duck-dark/70">{t('auth.name')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="name"
placeholder="Your name"
placeholder={t('auth.namePlaceholder')}
autoComplete="name"
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<span className="text-duck-dark/70">{t('auth.email')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
name="email"
placeholder="admin@example.com"
placeholder={t('auth.adminEmailPlaceholder')}
autoComplete="email"
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span>
<span className="text-duck-dark/70">{t('auth.username')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
@@ -83,23 +85,23 @@ export function Bootstrap() {
<div className="grid md:flex gap-4">
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span>
<span className="text-duck-dark/70">{t('auth.password')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Min 12 characters"
placeholder={t('auth.minChars')}
autoComplete="new-password"
/>
</Label>
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span>
<span className="text-duck-dark/70">{t('auth.confirmPassword')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder="Confirm your password"
placeholder={t('auth.confirmPlaceholder')}
autoComplete="new-password"
/>
</Label>
@@ -8,12 +8,14 @@ import { toast } from 'sonner';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
import { useGlobal } from 'hooks/useGlobal';
import { useTranslation } from '@/lib/i18n';
const initialState: LoginFormState = {
// email: 'pastilhas@pastilhas.dev',
// password: '',
};
export function Login() {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
const { signin } = useAuth();
@@ -40,20 +42,20 @@ export function Login() {
return (
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
<div className="text-duck-dark/60">Sign in to your account</div>
<div className="text-duck-dark text-2xl font-bold">{t('auth.signInTitle')}</div>
<div className="text-duck-dark/60">{t('auth.signInSubtitle')}</div>
</div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email or username</span>
<span className="text-duck-dark/70">{t('auth.emailOrUsername')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
// `text`, not `email`: the browser refuses to submit a username under type="email", so the
// field would have validated the user out of an account they have.
type="text"
name="email"
placeholder="you@example.com"
placeholder={t('auth.emailPlaceholder')}
// `username` rather than `email`, which is what password managers expect for a field that
// takes either — and it is the value they already store beside the password.
autoComplete="username"
@@ -61,12 +63,12 @@ export function Login() {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Password</span>
<span className="text-duck-dark/70">{t('auth.password')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Your password"
placeholder={t('auth.passwordPlaceholder')}
autoComplete="current-password"
/>
</Label>
@@ -90,7 +92,7 @@ export function Login() {
{/* </p> */}
<p>
<Link to="/auth/forgot-password" className="text-duck-forest underline hover:text-duck-forest/80">
Forgot password?
{t('auth.forgotPassword')}
</Link>
</p>
</div>
@@ -1,3 +1,5 @@
import { useTranslation } from '@/lib/i18n';
type DebugPanelProps = {
fullControlMode: boolean;
cameraInfo: {
@@ -13,9 +15,10 @@ type DebugPanelProps = {
};
export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPanelProps) {
const { t } = useTranslation();
return (
<div className="fixed top-4 left-4 bg-black/80 text-white p-4 rounded text-xs font-mono space-y-2 z-[100]">
<div className="font-bold text-blue-400">Controls:</div>
<div className="font-bold text-blue-400">{t('auth.debug.controls')}</div>
<div className={fullControlMode ? 'text-green-300' : 'text-yellow-300'}>
Mode: {fullControlMode ? 'FULL CONTROL' : 'ROTATION ONLY'}
</div>
@@ -29,12 +32,12 @@ export function DebugPanel({ fullControlMode, cameraInfo, modelInfo }: DebugPane
<div>* Ctrl+Alt+Enter: Toggle full control</div>
<div>* Ctrl+Alt+D: Toggle debug panel</div>
<div className="font-bold text-green-400 pt-2">Camera:</div>
<div className="font-bold text-green-400 pt-2">{t('auth.debug.camera')}</div>
<div>Position: [{cameraInfo.cameraPosition.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Target: [{cameraInfo.target.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Zoom: {cameraInfo.zoom.toFixed(2)}</div>
<div className="font-bold text-yellow-400 pt-2">Model:</div>
<div className="font-bold text-yellow-400 pt-2">{t('auth.debug.model')}</div>
<div>Position: [{modelInfo.position.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Rotation: [{modelInfo.rotation.map((v) => v.toFixed(2)).join(', ')}]</div>
<div>Scale: {modelInfo.scale.toFixed(2)}</div>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
import { useTranslation } from '@/lib/i18n';
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
@@ -15,6 +16,7 @@ const MAX_LINES = 600;
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
// the screen re-derives the param from the registry row — one address shape for both kinds.
export const ActivityScreen = () => {
const { t } = useTranslation();
const { token, get } = useClient();
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
const [regLoaded, setRegLoaded] = useState(false);
@@ -87,30 +89,32 @@ export const ActivityScreen = () => {
<div className="flex h-full w-full">
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<ActivityIcon size={16} className="text-primary" /> Activity
<ActivityIcon size={16} className="text-primary" /> {t('activity.title')}
</div>
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Background tasks
{t('activity.backgroundTasks')}
</div>
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
{reg.tasks.map((t) => (
{reg.tasks.length === 0 && (
<p className="px-2 py-1 text-xs text-muted-foreground">{t('activity.noneRunning')}</p>
)}
{reg.tasks.map((task) => (
<Link
key={t.id}
to={`/activity/${encodeURIComponent(t.id)}`}
className={rowCls(selectedId === t.id)}
title={t.cwd}
key={task.id}
to={`/activity/${encodeURIComponent(task.id)}`}
className={rowCls(selectedId === task.id)}
title={task.cwd}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
className={`inline-block h-2 w-2 shrink-0 rounded-full ${task.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
/>
<span className="truncate font-mono text-xs">{t.id}</span>
<span className="truncate font-mono text-xs">{task.id}</span>
</Link>
))}
{reg.detached.length > 0 && (
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Detached
{t('activity.detached')}
</div>
)}
{reg.detached.map((d) => (
@@ -169,7 +173,7 @@ export const ActivityScreen = () => {
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
it never started.{' '}
<Link to="/activity" className="underline">
Back to the list
{t('activity.backToList')}
</Link>
</>
) : (
@@ -187,7 +191,7 @@ export const ActivityScreen = () => {
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a task to follow its live output
{t('activity.selectTask')}
</div>
)}
</main>
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
import { useChat, EmbeddableChat } from 'officerdev';
import { useTranslation } from '@/lib/i18n';
type CapabilitySummary = {
dirName: string;
name: string;
@@ -277,12 +278,18 @@ export const CapabilityChat = ({
description,
onResponseEnd,
}: CapabilityChatProps) => {
const { t } = useTranslation();
const seedFile = `${kind.toUpperCase()}.md`;
const genericPrefix = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
const promptFrontmatter = isNew ? (buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix) : genericPrefix;
/*
The composer's PRE-FILLED text, which the reader sees and edits — so it is translated. The hidden
`promptFrontmatter` above is not: that one instructs the agent and carries file paths, and is
machine-facing rather than something anybody reads.
*/
const defaultInput = isNew
? (description ?? `Help me create the content for this new ${kind} file`)
: `Help me understand and improve this ${kind} file`;
? (description ?? t('capability.createPrompt', { kind: kind.toLowerCase() }))
: t('capability.improvePrompt', { kind: kind.toLowerCase() });
const chat = useChat(undefined, undefined, { replaceUrl: false });
@@ -301,6 +308,7 @@ export const CapabilityChat = ({
};
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
@@ -310,7 +318,7 @@ export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
<span className="text-xs font-medium">Frontmatter</span>
<span className="text-xs font-medium">{t('capability.frontmatter')}</span>
</button>
{open && (
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
@@ -330,6 +338,7 @@ export const CapabilityList = ({
showCreate,
onShowCreateChange,
}: CapabilityListProps) => {
const { t, tUi } = useTranslation();
const client = useClient();
const qc = useQueryClient();
const [internalCreating, setInternalCreating] = useState(false);
@@ -356,7 +365,7 @@ export const CapabilityList = ({
setNewName('');
onCreate(res.dirName);
} catch {
toast.error(`Failed to create ${kind}`);
toast.error(t('capability.createFailed', { kind: kind.toLowerCase() }));
}
};
@@ -427,7 +436,7 @@ export const CapabilityList = ({
<input
value={internalSearch}
onChange={(ev) => setInternalSearch(ev.target.value)}
placeholder={`Search ${kind.toLowerCase()}s...`}
placeholder={t('capability.searchPlaceholder', { kind: `${kind.toLowerCase()}s` })}
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
@@ -452,7 +461,7 @@ export const CapabilityList = ({
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
)}
{items.length > 0 && filtered.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">{t('capability.noMatches')}</p>
)}
</div>
</>
@@ -469,6 +478,7 @@ export const CapabilityList = ({
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
*/
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
const { t, tUi } = useTranslation();
const client = useClient();
const qc = useQueryClient();
const navigate = useNavigate();
@@ -497,7 +507,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
navigate(basePath, { replace: true });
await qc.invalidateQueries({ queryKey: [queryKey] });
} catch {
toast.error(`Failed to delete ${kind}`);
toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() }));
}
};
@@ -526,7 +536,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</Link>
<span className="text-sm font-medium text-duck-dark/70 flex-1">
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
</span>
{detail && (
<>
@@ -554,10 +564,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
</ReactMarkdown>
</article>
) : detail ? (
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
<p className="text-sm text-duck-dark/40 text-center mt-12">{t('capability.emptyFile')}</p>
) : (
<p className="text-sm text-duck-dark/40 text-center mt-12">
Select a {kind.toLowerCase()} to view its contents
{t('capability.selectToView', { kind: kind.toLowerCase() })}
</p>
)}
</div>
@@ -606,10 +616,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
</DialogHeader>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
Cancel
{tUi('button.cancel')}
</Button>
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
Delete
{t('capability.delete')}
</Button>
</div>
</DialogContent>
@@ -9,6 +9,7 @@ import { errorText } from 'helpers/error-text';
import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import { defaultLayout, hasAppType } from './defaultLayout';
import { useTranslation } from '@/lib/i18n';
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
const CHAT_TAIL = 20;
@@ -18,6 +19,7 @@ type SessionListPageProps = {
};
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { t } = useTranslation();
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
const groupCwd = cwdFromSplat(splat);
@@ -122,7 +124,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
// no hint that the transcript could not be read, which is indistinguishable from a new session.
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') }));
setSelected({ id: sessionId });
}
})();
@@ -1,12 +1,16 @@
import { CodeEditorView } from 'officerdev';
import { Widget } from 'widgets/Widget';
import { useTranslation } from '@/lib/i18n';
export const CodeEditor = () => (
<div className="h-full w-full flex items-center justify-center">
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
export const CodeEditor = () => {
const { t } = useTranslation();
return (
<div className="h-full w-full flex items-center justify-center">
<Widget title={t('nav.codeEditor')} className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
wrapper deliberately does not pass this: a dashboard can hold two editors. */}
<CodeEditorView className="h-full w-full" urlState />
</Widget>
</div>
);
<CodeEditorView className="h-full w-full" urlState />
</Widget>
</div>
);
};
@@ -1,15 +1,29 @@
import { useEffect } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL
// /command-hq: navigation and detail, all panels always mounted (like /chat), selection carried by the URL
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
// "Route, surface, placement" and "Layout, v1".
// "Route, surface, placement".
export const CommandHqScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
/*
Move a saved three-column layout onto the two-column one.
The default alone cannot do it: this key is persisted per account, so anybody who has opened the screen
keeps the arrangement they had. See `isLegacyThreeColumn` for why the test is structural rather than an
appType check, and why replacing outright is the safe direction on a `locked` screen.
*/
useEffect(() => {
if (!workspace.isLoaded) return;
if (!isLegacyThreeColumn(workspace.value)) return;
workspace.setValue(defaultLayout);
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
@@ -1,14 +1,60 @@
import type { LayoutNode } from 'officerdev';
// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The
// detail pane gets the most room because it is a live chat transcript once something is selected.
/*
Two columns, with the navigation stacked into the left one.
── Why it changed from three ──
It was `16 / 34 / 50`: a category column, a commander column, and the transcript. So half the screen was
navigation for a list that holds a handful of commanders — one, on the machine this was written on — while
the only thing anybody reads got the other half. A category pane of its own is a filter for a list short
enough to see whole.
Now: commanders on top, categories beneath them, transcript taking three quarters.
── Why the categories are BELOW the commanders ──
Because the commanders are the thing you came for and the categories are how you narrow them when there
are too many to scan. Above, the filter would push the list down the screen and be the first thing read;
below, the list is at the top where a list belongs and the filter is where you reach for it. It also means
the categories can grow their own scrollbar without ever taking room from the commanders.
*/
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'command-hq-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 },
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 },
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
{
node: {
type: 'group',
id: 'command-hq-nav',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 62 },
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 38 },
],
},
size: 24,
},
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 76 },
],
};
/**
* Is this a saved copy of the three-column layout this replaced?
*
* No appType was retired — all three panels survive and only their arrangement changed — so `hasAppType`
* cannot see this the way the /chat migration could. The test is structural instead: the old shape is a
* horizontal root with THREE children, and the new one has two.
*
* It terminates for that reason: the replacement does not satisfy the test.
*
* Replacing outright is safe because the screen is `locked` — its structure is dictated by code, and the
* only thing a person can have contributed is the column sizes, which is a cheap thing to lose once. The
* same reasoning, and the same warning, as `ChatHistory/index.tsx`: get the test backwards and a stranded
* panel collapses onto `appTypes.fallback` and is written back, which is how /offscale once rendered three
* copies of one picker.
*/
export function isLegacyThreeColumn(node: LayoutNode): boolean {
return node.type === 'group' && node.direction === 'horizontal' && node.children.length === 3;
}
@@ -3,6 +3,9 @@ import { useParams, Link } from 'react-router';
import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { useTranslation } from '@/lib/i18n';
type TFn = ReturnType<typeof useTranslation>['t'];
// Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled
// from the job's persisted progress (the executor emits counter snapshots, not per-item events).
@@ -29,26 +32,40 @@ type DownloadJob = {
const POLL_MS = 1500;
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
const statusBadge = (status: string) => {
// Takes `t` because it is a module-level helper, not a component — the alternative is a hook
// call outside a component body.
const statusBadge = (status: string, t: TFn) => {
switch (status) {
case 'running':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
label: 'Running',
label: t('jobs.status.running'),
cls: 'text-amber-600 dark:text-amber-500',
};
case 'pending':
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
return {
icon: <Clock className="h-4 w-4" />,
label: t('jobs.status.queued'),
cls: 'text-blue-600 dark:text-blue-400',
};
case 'completed':
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
case 'failed':
return { icon: <XCircle className="h-4 w-4" />, label: 'Failed', cls: 'text-red-600 dark:text-red-400' };
return {
icon: <XCircle className="h-4 w-4" />,
label: t('jobs.status.failed'),
cls: 'text-red-600 dark:text-red-400',
};
case 'stopped':
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
return {
icon: <Ban className="h-4 w-4" />,
label: t('jobs.status.stopped'),
cls: 'text-duck-dark/50 dark:text-foreground/50',
};
default:
return {
icon: <XCircle className="h-4 w-4" />,
label: 'Interrupted',
label: t('jobs.status.interrupted'),
cls: 'text-orange-600 dark:text-orange-400',
};
}
@@ -100,6 +117,7 @@ export const PhaseBar = ({
};
export const DownloadJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
const [job, setJob] = useState<DownloadJob | null>(null);
@@ -134,9 +152,9 @@ export const DownloadJobDetail = () => {
if (!id) return;
try {
await client.post(`/jobs/${id}/stop`, {});
toast.success('Stop requested');
toast.success(t('jobs.stopRequested'));
} catch {
toast.error('Failed to stop');
toast.error(t('jobs.stopFailed'));
}
};
@@ -149,14 +167,14 @@ export const DownloadJobDetail = () => {
if (!job)
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
<span>Job not found.</span>
<span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal hover:underline">
Back to jobs
{t('jobs.backToJobs')}
</Link>
</div>
);
const badge = statusBadge(job.status);
const badge = statusBadge(job.status, t);
const running = !isTerminal(job.status);
const p = job.progress;
const isAudio = job.inputs?.format !== 'video';
@@ -197,7 +215,7 @@ export const DownloadJobDetail = () => {
onClick={stop}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
>
<Square className="h-3 w-3" /> Stop
<Square className="h-3 w-3" /> {t('jobs.stop')}
</button>
)}
</div>
@@ -206,14 +224,14 @@ export const DownloadJobDetail = () => {
<div className="mx-auto flex max-w-md flex-col gap-5">
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
<PhaseBar
label="Titles"
label={t('jobs.titles')}
c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
active={p?.phase === 'metadata'}
savedLabel="found"
failedLabel="skipped"
/>
<PhaseBar
label="Download"
label={t('jobs.download')}
c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
active={p?.phase === 'download'}
savedLabel="saved"
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { WorkspaceLayout } from 'officerdev';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { randomId } from 'helpers/random-id';
import { useTranslation } from '@/lib/i18n';
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
@@ -162,6 +163,7 @@ type ToolCallEntryProps = {
};
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
return (
@@ -183,14 +185,14 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
{expanded && (
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
<div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Input</div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.input')}</div>
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
{JSON.stringify(entry.toolInput, null, 2)}
</pre>
</div>
{entry.output !== undefined && (
<div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">{t('jobs.output')}</div>
<pre
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
>
@@ -236,6 +238,7 @@ const DEFAULT_LAYOUT: LayoutNode = {
};
const StepsPanel = () => {
const { t } = useTranslation();
const ctx = useJobPanel();
const {
displaySteps,
@@ -255,7 +258,7 @@ const StepsPanel = () => {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-3 py-2 border-b border-duck-dark/10">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Steps</h2>
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.steps')}</h2>
</div>
<div className="flex-1 overflow-y-auto">
{displaySteps.map((step, i) => {
@@ -336,6 +339,7 @@ const StepsPanel = () => {
};
const OutputPanel = () => {
const { t } = useTranslation();
const ctx = useJobPanel();
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
@@ -345,7 +349,7 @@ const OutputPanel = () => {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">{t('jobs.output')}</h2>
{selectedKey && (
<span className="text-xs text-duck-dark/40 truncate">
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
@@ -355,12 +359,12 @@ const OutputPanel = () => {
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
{!selectedKey && (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
Select a step or sub-task to view output
{t('jobs.selectStep')}
</div>
)}
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
No output yet
{t('jobs.noOutput')}
</div>
)}
{selectedOutputs.map((entry) => {
@@ -385,6 +389,7 @@ const OutputPanel = () => {
};
export const PipelineJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
@@ -792,15 +797,15 @@ export const PipelineJobDetail = () => {
);
if (isLoading) {
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">{t('jobs.loading')}</div>;
}
if (!job) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
<span>Job not found</span>
<span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
Back to jobs
{t('jobs.backToJobs')}
</Link>
</div>
);
@@ -821,7 +826,11 @@ export const PipelineJobDetail = () => {
<Card className="px-4 py-3 shrink-0">
<div className="flex items-center gap-3">
{/* The other two job detail panes already do this; this was the last imperative back in Jobs. */}
<Link to="/jobs" aria-label="Back to jobs" className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
<Link
to="/jobs"
aria-label={t('jobs.backToJobs')}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer"
>
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</Link>
<div className="flex-1 min-w-0">
@@ -837,7 +846,7 @@ export const PipelineJobDetail = () => {
{isRunning && (
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
<Square className="h-3 w-3" />
Stop
{t('jobs.stop')}
</Button>
)}
</div>
@@ -852,13 +861,13 @@ export const PipelineJobDetail = () => {
{displayCost && (
<>
<div className="flex items-center gap-1.5 text-duck-dark/60">
<span className="text-duck-dark/40">Tokens:</span>
<span className="text-duck-dark/40">{t('jobs.tokens')}</span>
<span className="font-mono tabular-nums">
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
</span>
</div>
<div className="flex items-center gap-1.5 text-duck-dark/60">
<span className="text-duck-dark/40">Cost:</span>
<span className="text-duck-dark/40">{t('jobs.cost')}</span>
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
</div>
</>
@@ -22,6 +22,7 @@ import { Card } from '@/components/Card';
import { ScriptJobDetail } from './ScriptJobDetail';
import { DownloadJobDetail } from './DownloadJobDetail';
import { PipelineJobDetail } from './JobDetail';
import { useTranslation } from '@/lib/i18n';
type JobSummary = {
id: string;
@@ -99,6 +100,7 @@ const useJobsData = () => {
type JobRowProps = { job: JobSummary; onAction: (ev: MouseEvent, job: JobSummary) => void };
const JobRow = ({ job, onAction }: JobRowProps) => {
const { t } = useTranslation();
const { id: activeId } = useParams<{ id: string }>();
const isRunning = job.status === 'running';
const target = basename(job.target);
@@ -126,7 +128,7 @@ const JobRow = ({ job, onAction }: JobRowProps) => {
</Link>
<button
onClick={(ev) => onAction(ev, job)}
title={isRunning ? 'Stop' : 'Delete'}
title={isRunning ? t('jobs.stop') : 'Delete'}
className="shrink-0 mr-2 p-1.5 rounded-md text-duck-dark/40 hover:text-red-500 hover:bg-red-500/10 md:opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
{isRunning ? <Square className="h-4 w-4" /> : <Trash2 className="h-4 w-4" />}
@@ -144,6 +146,7 @@ const PanelHeader = ({ children }: { children: ReactNode }) => (
// real event is wired to it. Sends { type: 'test' }, which reaches every configured channel — Discord
// today, APNs and FCM once their credentials exist. Delete this once a real producer replaces it.
const TestNotificationButton = () => {
const { t } = useTranslation();
const client = useClient();
const [sending, setSending] = useState(false);
@@ -157,12 +160,12 @@ const TestNotificationButton = () => {
);
const results = res?.results ?? [];
if (results.length === 0) {
toast.warning('No channels configured');
toast.warning(t('jobs.noChannels'));
} else {
toast.success(results.map((r) => `${r.channel}: ${r.sent} sent`).join(', '));
}
} catch {
toast.error('Failed to send notification');
toast.error(t('jobs.notifyFailed'));
} finally {
setSending(false);
}
@@ -172,7 +175,7 @@ const TestNotificationButton = () => {
<button
onClick={send}
disabled={sending}
title="Send a test notification to every configured channel"
title={t('jobs.testNotification')}
className="ml-auto shrink-0 flex items-center gap-1 text-xs font-medium text-duck-dark/50 hover:text-duck-teal cursor-pointer disabled:opacity-40"
>
<Bell className="h-3.5 w-3.5" />
@@ -182,6 +185,7 @@ const TestNotificationButton = () => {
};
const ActiveJobsPanel = () => {
const { t } = useTranslation();
const { jobs, isLoading, act } = useJobsData();
const active = [
...jobs.filter((j) => j.status === 'running'),
@@ -191,15 +195,17 @@ const ActiveJobsPanel = () => {
<div className="h-full p-2">
<Card className="h-full flex flex-col overflow-hidden">
<PanelHeader>
<h2 className="text-sm font-semibold text-duck-dark">Running &amp; Queued</h2>
<h2 className="text-sm font-semibold text-duck-dark">{t('jobs.runningQueued')}</h2>
{active.length > 0 && <span className="text-xs text-duck-dark/40">{active.length}</span>}
<TestNotificationButton />
</PanelHeader>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
) : active.length === 0 ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Nothing running</div>
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
{t('jobs.nothingRunning')}
</div>
) : (
active.map((job) => <JobRow key={job.id} job={job} onAction={act} />)
)}
@@ -211,6 +217,7 @@ const ActiveJobsPanel = () => {
// Bottom-left panel: finished / failed / stopped, newest first, searchable.
const HistoryJobsPanel = () => {
const { t } = useTranslation();
const { jobs, isLoading, act, clearHistory } = useJobsData();
const [search, setSearch] = useState('');
const history = jobs
@@ -224,12 +231,12 @@ const HistoryJobsPanel = () => {
<div className="h-full p-2">
<Card className="h-full flex flex-col overflow-hidden">
<PanelHeader>
<h2 className="text-sm font-semibold text-duck-dark shrink-0">History</h2>
<h2 className="text-sm font-semibold text-duck-dark shrink-0">{t('jobs.history')}</h2>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
<input
type="text"
placeholder="Search…"
placeholder={t('jobs.search')}
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="w-full pl-8 pr-3 py-1 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
@@ -238,16 +245,16 @@ const HistoryJobsPanel = () => {
{history.length > 0 && (
<button
onClick={clearHistory}
title="Delete all finished jobs"
title={t('jobs.deleteFinished')}
className="shrink-0 text-xs font-medium text-duck-dark/50 hover:text-red-500 cursor-pointer"
>
Clear all
{t('jobs.clearAll')}
</button>
)}
</PanelHeader>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">Loading…</div>
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">{t('jobs.loading')}</div>
) : history.length === 0 ? (
<div className="flex items-center justify-center h-full text-duck-dark/30 text-xs">
{search ? 'No matches' : 'No finished jobs'}
@@ -263,6 +270,7 @@ const HistoryJobsPanel = () => {
// ── Right panel: the selected job's detail, by mode (script terminal / pipeline steps) ──
const JobDetailPanel = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
const [mode, setMode] = useState<string | null>(null);
@@ -284,7 +292,7 @@ const JobDetailPanel = () => {
<div className="h-full p-2">
<Card className="h-full flex flex-col items-center justify-center gap-2 text-duck-dark/30">
<Inbox className="h-8 w-8" />
<span className="text-sm">Select a job</span>
<span className="text-sm">{t('jobs.selectJob')}</span>
</Card>
</div>
);
@@ -301,7 +309,7 @@ const JobDetailPanel = () => {
if (mode === 'notfound') {
return (
<div className="h-full p-2">
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">Job not found.</Card>
<Card className="h-full flex items-center justify-center text-duck-dark/50 text-sm">{t('jobs.notFound')}</Card>
</div>
);
}
@@ -4,6 +4,9 @@ import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'l
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { PhaseBar, type DownloadProgress } from './DownloadJobDetail';
import { useTranslation } from '@/lib/i18n';
type TFn = ReturnType<typeof useTranslation>['t'];
type ScriptJob = {
id: string;
@@ -27,36 +30,46 @@ const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === '
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
const statusBadge = (status: string, exitCode: number | null) => {
// Takes `t` because it is a module-level helper, not a component.
const statusBadge = (status: string, exitCode: number | null, t: TFn) => {
switch (status) {
case 'running':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
label: 'Running',
label: t('jobs.status.running'),
cls: 'text-amber-600 dark:text-amber-500',
};
case 'pending':
return { icon: <Clock className="h-4 w-4" />, label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' };
return {
icon: <Clock className="h-4 w-4" />,
label: t('jobs.status.queued'),
cls: 'text-blue-600 dark:text-blue-400',
};
case 'completed':
return { icon: <CheckCircle2 className="h-4 w-4" />, label: 'Completed', cls: 'text-duck-teal' };
return { icon: <CheckCircle2 className="h-4 w-4" />, label: t('jobs.status.completed'), cls: 'text-duck-teal' };
case 'failed':
return {
icon: <XCircle className="h-4 w-4" />,
label: `Failed${exitCode != null ? ` (exit ${exitCode})` : ''}`,
label: exitCode != null ? t('jobs.failedWithCode', { code: exitCode }) : t('jobs.status.failed'),
cls: 'text-red-600 dark:text-red-400',
};
case 'stopped':
return { icon: <Ban className="h-4 w-4" />, label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' };
return {
icon: <Ban className="h-4 w-4" />,
label: t('jobs.status.stopped'),
cls: 'text-duck-dark/50 dark:text-foreground/50',
};
default:
return {
icon: <XCircle className="h-4 w-4" />,
label: 'Interrupted',
label: t('jobs.status.interrupted'),
cls: 'text-orange-600 dark:text-orange-400',
};
}
};
export const ScriptJobDetail = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const client = useClient();
@@ -137,9 +150,9 @@ export const ScriptJobDetail = () => {
if (!id) return;
try {
await client.post(`/jobs/${id}/stop`, {});
toast.success('Stop requested');
toast.success(t('jobs.stopRequested'));
} catch {
toast.error('Failed to stop');
toast.error(t('jobs.stopFailed'));
}
};
@@ -153,15 +166,15 @@ export const ScriptJobDetail = () => {
if (!job) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-duck-dark/60 dark:text-foreground/60">
<span>Job not found.</span>
<span>{t('jobs.notFound')}</span>
<Link to="/jobs" className="text-duck-teal hover:underline">
Back to jobs
{t('jobs.backToJobs')}
</Link>
</div>
);
}
const badge = statusBadge(job.status, job.exitCode);
const badge = statusBadge(job.status, job.exitCode, t);
const running = !isTerminal(job.status);
return (
@@ -185,7 +198,7 @@ export const ScriptJobDetail = () => {
onClick={stop}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 cursor-pointer transition-colors"
>
<Square className="h-3 w-3" /> Stop
<Square className="h-3 w-3" /> {t('jobs.stop')}
</button>
)}
</div>
@@ -193,14 +206,14 @@ export const ScriptJobDetail = () => {
{isDownloadProgress(job.progress) && (
<div className="px-5 py-4 border-b border-duck-dark/10 flex flex-col gap-4">
<PhaseBar
label="Titles"
label={t('jobs.titles')}
c={job.progress.meta}
active={job.progress.phase === 'metadata'}
savedLabel="found"
failedLabel="skipped"
/>
<PhaseBar
label="Download"
label={t('jobs.download')}
c={job.progress.dl}
active={job.progress.phase === 'download'}
savedLabel="saved"
@@ -1,16 +1,18 @@
import { Bug } from 'lucide-react';
import { useBugReport } from './use-bug-report';
import { useTranslation } from '@/lib/i18n';
import { BugReportDialog } from './BugReportDialog';
export const BugReportButton = () => {
const bugReport = useBugReport();
const { t } = useTranslation();
return (
<>
<button
onClick={bugReport.handleOpen}
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
aria-label="Report a bug"
aria-label={t('bugReport.label')}
>
<Bug size={16} />
</button>
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { useTranslation } from '@/lib/i18n';
type BugReportDialogProps = {
open: boolean;
@@ -21,6 +22,7 @@ export const BugReportDialog = ({
onClose,
onSubmit,
}: BugReportDialogProps) => {
const { t, tUi } = useTranslation();
const [description, setDescription] = useState('');
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
@@ -36,7 +38,7 @@ export const BugReportDialog = ({
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Report a Bug</DialogTitle>
<DialogTitle>{t('bugReport.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -46,34 +48,32 @@ export const BugReportDialog = ({
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : previewUrl ? (
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" />
<img src={previewUrl} alt={t('bugReport.screenshot')} className="max-h-48 w-full object-contain" />
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
No screenshot captured
{t('bugReport.noScreenshot')}
</div>
)}
</div>
<Textarea
placeholder="Describe what went wrong..."
placeholder={t('bugReport.describePlaceholder')}
value={description}
onChange={(ev) => setDescription(ev.target.value)}
rows={4}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Console logs, browser info, and recent API errors are collected automatically.
</p>
<p className="text-xs text-muted-foreground">{t('bugReport.collectedNote')}</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Cancel
{tUi('button.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Submit
{submitting ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
{t('bugReport.submit')}
</Button>
</DialogFooter>
</DialogContent>
@@ -6,6 +6,7 @@ import { useGlobal } from 'hooks/useGlobal';
import type { ErrorDetails } from 'hooks/useClient';
import { getConsoleBuffer } from '@/lib/console-buffer';
import { toast } from 'sonner';
import { useTranslation } from '@/lib/i18n';
export const useBugReport = () => {
const [open, setOpen] = useState(false);
@@ -14,6 +15,7 @@ export const useBugReport = () => {
const [submitting, setSubmitting] = useState(false);
const client = useClient();
const { user } = useAuth();
const { t } = useTranslation();
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
const handleOpen = useCallback(async () => {
@@ -57,15 +59,15 @@ export const useBugReport = () => {
}
await client.post('/bug-report', formData);
toast.success('Bug report submitted');
toast.success(t('bugReport.submitted'));
handleClose();
} catch {
toast.error('Failed to submit bug report');
toast.error(t('bugReport.failed'));
} finally {
setSubmitting(false);
}
},
[screenshot, client, user, apiError, handleClose],
[screenshot, client, user, apiError, handleClose, t],
);
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
@@ -6,8 +6,9 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
import { Dock, useDockItems, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight';
import { ShortcutsModal } from './ShortcutsModal';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
@@ -23,10 +24,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
// will then slam is worse than one that never showed it.
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
const permitted = useMemo(
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
[canVisit, plugins],
);
const allDockItems = useDockItems(plugins);
const permitted = useMemo(() => allDockItems.filter((item) => canVisit(item.to)), [canVisit, allDockItems]);
const { items: visibleItems } = useDock(
permitted,
useMemo(() => defaultDockPaths(plugins), [plugins]),
@@ -51,6 +50,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
<Spotlight />
{/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
panel, an error boundary, or mid-navigation. */}
<ShortcutsModal />
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { resolveIcon } from 'officerdev';
import type { PluginManifest } from 'hooks/usePermissions';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
export type DockItem = {
/** Already resolved for the active locale — see `useDockItems`. */
label: string;
to: string;
color: string;
@@ -155,6 +157,7 @@ import {
Store,
Puzzle,
Radar,
Send,
} from 'lucide-react';
/**
@@ -168,18 +171,22 @@ import {
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
* the light profile because it fronts a remote instance and installs nothing locally.
*/
export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
export const CORE_DOCK_ITEMS: CoreDockItem[] = [
{ labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' },
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because it is the door into another account's filesystem, and the permission that gates it is
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets.
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure
@@ -189,16 +196,16 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
{ label: 'Calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
{ label: 'Contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
{ labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
{ labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
{ labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' },
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
{ label: 'Marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
{ labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
];
/**
@@ -208,6 +215,30 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
*/
/**
* A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only
* dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest,
* which is the plugin's string and not ours to translate (docs/localization.md open question 3).
*/
type CoreDockItem = Omit<DockItem, 'label'> & { labelKey: AppTranslationKey };
/**
* The composed dock, with the shell's own labels in the active locale.
*
* One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by
* spreading the same two sources, and each would otherwise have to resolve labels itself.
*/
export function useDockItems(plugins: PluginManifest[]): DockItem[] {
const { t } = useTranslation();
return useMemo(
() => [
...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })),
...dockItemsFromPlugins(plugins),
],
[t, plugins],
);
}
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
return plugins.flatMap((plugin) => {
const tile = (t: {
@@ -13,6 +13,7 @@ import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle';
import { useAuth } from 'hooks/useAuth';
import { useThemeAsset } from '@/components/ui/ThemeProvider';
import { useTranslation } from '@/lib/i18n';
// import { BugReportButton } from '../BugReport/BugReportButton';
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
@@ -20,14 +21,15 @@ import { useThemeAsset } from '@/components/ui/ThemeProvider';
// the field hand the tab back to the route's own name.
function EditablePageTitle() {
const [title, rename] = usePageTitle();
const { t } = useTranslation();
return (
<EditableTitle
value={title}
onCommit={rename}
allowEmpty
ariaLabel="Tab name"
hint="Click to rename this tab — clear it to go back to the page name"
ariaLabel={t('header.tabName')}
hint={t('header.tabNameHint')}
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
/>
@@ -48,6 +50,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
const [open, setOpen] = useState(false);
const isTouch = useIsTouch();
const { user } = useAuth();
const { t } = useTranslation();
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
const logo = useThemeAsset('--logo-src');
@@ -81,7 +84,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
*/}
<div
role="img"
aria-label="Officer"
aria-label={t('header.logoAlt')}
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
@@ -119,7 +122,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetTitle>{t('header.navigation')}</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 mt-4 px-2">
{dockItems.map((item) => (
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
import { Loader2, ListOrdered } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions';
import { useTranslation } from '@/lib/i18n';
type Counts = { running: number; runningJobId: string | null; queued: number };
@@ -15,6 +16,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
export const JobsIndicator = () => {
const client = useClient();
const { can } = usePermissions();
const { t } = useTranslation();
const allowed = can('tasks');
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
@@ -44,7 +46,7 @@ export const JobsIndicator = () => {
<div className="flex items-center gap-1.5">
<Link
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
title={counts.running ? 'Running job' : 'Nothing running'}
title={counts.running ? t('header.jobsRunning') : t('header.jobsNothingRunning')}
className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`}
>
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
@@ -52,7 +54,7 @@ export const JobsIndicator = () => {
</Link>
<Link
to="/jobs"
title="Queued jobs"
title={t('header.jobsQueued')}
className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`}
>
<ListOrdered className="h-3.5 w-3.5" />
@@ -1,8 +1,11 @@
import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check } from 'lucide-react';
import { Check, Star } from 'lucide-react';
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
import * as Dropdown from '@/components/ui/dropdown-menu';
import type { Theme } from '@/components/ui/ThemeProvider';
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
import { useThemeFavorites } from '@/state/useThemeFavorites';
import { useTranslation } from '@/lib/i18n';
const {
DropdownMenu,
@@ -21,7 +24,7 @@ const {
*
* ── The overlay ──
*
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
* you browse, so as little of it as possible should be covered.
@@ -30,22 +33,42 @@ const {
*/
export const ThemeSwitcher = () => {
const { theme, themeId, setTheme, themes } = useTheme();
const { open, setOpen, focused, setFocused } = useThemeKeys();
const { t } = useTranslation();
const { favorites, isFavorite, toggle } = useThemeFavorites();
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
const [onlyFavorites, setOnlyFavorites] = useState(false);
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is
// readonly, so this builds mutable arrays rather than pushing into its own type.
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
(acc[t.family] ??= []).push(t);
return acc;
}, {});
/*
One ordering, used by both lists.
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
is the registry order — families sit together in it — and lifting the selected theme out of its family
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
`theme/rotate.ts`.
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
the order it was given, so the theme you are wearing still leads.
*/
const ordered = useMemo(() => {
const rotated = rotateToSelected(themes, themeId);
const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
return favouritesFirst(listed, favorites);
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
const families = useMemo(() => groupByFamily(ordered), [ordered]);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title={`Theme: ${theme.name} · ctrl+shift+←/→ to cycle · ctrl+shift+space for the list`}
aria-label={`Theme: ${theme.name}`}
title={t('theme.pillTitle', {
name: theme.name,
starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
})}
aria-label={t('theme.pillLabel', { name: theme.name })}
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
// the cluster stays one shape.
@@ -53,23 +76,80 @@ export const ThemeSwitcher = () => {
style={{ color: 'var(--header-fg)' }}
>
{theme.name}
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
ctrl+shift+←/→ · ctrl+shift+space
</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(families).map(([family, entries]) => (
<div key={family}>
{entries.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
{t.name}
</DropdownMenuItem>
))}
</div>
))}
{/*
The CONTENT does not scroll — the list inside it does.
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
visible for the first half-second and then gone for good, which is the same as not having written
it. Header and footer are now siblings of a scroll box rather than passengers in one.
*/}
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
<div className="shrink-0 border-b border-border px-2 py-1.5">
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
<span className="block">{t('theme.cycleHint')}</span>
<span className="block">{t('theme.everyShortcutHint')}</span>
</DropdownMenuLabel>
<button
type="button"
onClick={(ev) => {
// The dropdown closes on any item activation, and this is a filter rather than a choice.
ev.preventDefault();
ev.stopPropagation();
setOnlyFavorites((was) => !was);
}}
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
}`}
>
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
{onlyFavorites
? t('theme.favouritesOnly', { count: favorites.size })
: t('theme.favourites', { count: favorites.size })}
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{ordered.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
) : (
families.map(([family, entries]) => (
<div key={family}>
{entries.map((entry) => (
<DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
<Check
className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
<button
type="button"
aria-label={
isFavorite(entry.id)
? t('theme.unstarNamed', { name: entry.name })
: t('theme.starNamed', { name: entry.name })
}
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
toggle(entry.id);
}}
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
>
<Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
</button>
</DropdownMenuItem>
))}
</div>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -90,31 +170,64 @@ export const ThemeSwitcher = () => {
}}
onClick={(ev) => ev.stopPropagation()}
>
<ul className="flex flex-col gap-1">
{themes.map((t) => {
const isFocused = t.id === focused;
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
same thing in both, or the browse teaches you an order the menu then contradicts. */}
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
{ordered.map((entry) => {
const isFocused = entry.id === focused;
return (
<li key={t.id}>
<li key={entry.id}>
<button
onMouseEnter={() => setFocused(t.id)}
onMouseEnter={() => setFocused(entry.id)}
onClick={() => {
setTheme(t.id);
setTheme(entry.id);
setOpen(false);
}}
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
}`}
>
{t.name}
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
{entry.id === themeId && (
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
)}
{/*
A span, not a button: this sits inside the row's own <button>, and a button inside
a button is invalid HTML that browsers resolve by dropping one of them. The click
handler and the role are set explicitly instead, and `f` does the same thing from
the keyboard.
*/}
<span
role="button"
tabIndex={-1}
aria-label={
isFavorite(entry.id)
? t('theme.unstarNamed', { name: entry.name })
: t('theme.starNamed', { name: entry.name })
}
title={
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
}
onClick={(ev) => {
// Or the row's own onClick applies the theme and closes the overlay, which is
// the opposite of what starring is for.
ev.preventDefault();
ev.stopPropagation();
toggle(entry.id);
}}
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
>
<Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
</span>
</button>
</li>
);
})}
</ul>
<p className="mt-6 text-center text-xs opacity-60">
j/k or ↑/↓ move · l or → preview · h or ← reset · enter keep · esc cancel
</p>
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
header does. */}
<p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
</div>
</div>,
document.body,
@@ -27,13 +27,13 @@ export function UserMenu() {
<DropdownMenuContent align="end" className="z-10 w-48">
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile">
<User className="mr-2 h-4 w-4" />
<User className="me-2 h-4 w-4" />
{t('header.userMenu.profile')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/system">
<Settings className="mr-2 h-4 w-4" />
<Settings className="me-2 h-4 w-4" />
{t('header.userMenu.systemSettings')}
</Link>
</DropdownMenuItem>
@@ -42,14 +42,14 @@ export function UserMenu() {
(navigation-audit M8.) */}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" />
AI
<Bot className="me-2 h-4 w-4" />
{t('header.userMenu.ai')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/integrations">
<Puzzle className="mr-2 h-4 w-4" />
Integrations
<Puzzle className="me-2 h-4 w-4" />
{t('header.userMenu.integrations')}
</Link>
</DropdownMenuItem>
{/* Owner only. This is tidiness, not access control — the route stays reachable and its
@@ -58,15 +58,15 @@ export function UserMenu() {
{user?.role === 'Super Admin' && (
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/user-management">
<UserCog className="mr-2 h-4 w-4" />
User Management
<UserCog className="me-2 h-4 w-4" />
{t('header.userMenu.userManagement')}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" />
<LogOut className="me-2 h-4 w-4" />
{t('header.userMenu.signOut')}
</Link>
</DropdownMenuItem>
@@ -4,9 +4,29 @@ import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
type RescanResponse = { ok: boolean; counts: Record<string, number> };
/**
* One key per counted noun rather than one sentence with six numbers in it.
*
* Each noun pluralizes on its own count — English needs it for `1 task` vs `2 tasks`, and a locale with
* more than two categories needs it for every value. Assembling the sentence in code and translating only
* the frame would have hardcoded English plural rules into the frame.
*
* Spelled out as literal pairs rather than built from the count name: a key assembled at runtime defeats
* the type checker and cannot be grepped (see the key naming convention in docs/localization.md).
*/
const RESCAN_COUNTS: readonly (readonly [string, AppTranslationKey])[] = [
['tasks', 'rescan.counts.tasks'],
['agents', 'rescan.counts.agents'],
['tools', 'rescan.counts.tools'],
['skills', 'rescan.counts.skills'],
['processes', 'rescan.counts.processes'],
['affordances', 'rescan.counts.affordances'],
];
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the
// Automation and Capability pages).
const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories'];
@@ -15,6 +35,7 @@ export function RescanButton() {
const client = useClient();
const qc = useQueryClient();
const { can } = usePermissions();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
@@ -32,32 +53,28 @@ export function RescanButton() {
// you next open that page. This refreshes every item cache immediately, wherever you press it.
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
const c = res.counts ?? {};
toast.success(
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`,
{
// Two things said here rather than left to be discovered.
//
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
// agent, because the loader skips it: a line that does not say when to reach for something
// costs context in every conversation and earns nothing. So a skill can be present, listed and
// editable while being invisible to every agent, and the only symptom is two numbers
// disagreeing where nobody is comparing them.
description:
[
c.affordances || c.skillsOffered
? 'Applies to new chats — an open one keeps what it started with.'
: null,
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
? `${c.skills - c.skillsOffered} skill(s) have no description, so no agent is offered them.`
: null,
]
.filter(Boolean)
.join(' ') || undefined,
},
);
const summary = RESCAN_COUNTS.map(([noun, key]) => t(key, { count: c[noun] ?? 0 })).join(', ');
toast.success(t('rescan.done', { summary }), {
// Two things said here rather than left to be discovered.
//
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
// agent, because the loader skips it: a line that does not say when to reach for something
// costs context in every conversation and earns nothing. So a skill can be present, listed and
// editable while being invisible to every agent, and the only symptom is two numbers
// disagreeing where nobody is comparing them.
description:
[
c.affordances || c.skillsOffered ? t('rescan.newChatsOnly') : null,
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
? t('rescan.noDescription', { count: c.skills - c.skillsOffered })
: null,
]
.filter(Boolean)
.join(' ') || undefined,
});
} catch {
toast.error('Failed to rescan items');
toast.error(t('rescan.failed'));
} finally {
setLoading(false);
}
@@ -68,8 +85,8 @@ export function RescanButton() {
onClick={rescan}
disabled={loading}
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
aria-label="Rescan items"
title="Rescan officer-items (apply changes without restarting)"
aria-label={t('rescan.label')}
title={t('rescan.title')}
>
<RotateCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/lib/i18n';
type ScreenErrorFallbackProps = { error: Error; reset: () => void };
@@ -18,6 +19,7 @@ type ScreenErrorFallbackProps = { error: Error; reset: () => void };
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
const client = useClient();
const queryClient = useQueryClient();
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
@@ -46,36 +48,34 @@ export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps)
<div className="flex items-start gap-3">
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
<div className="space-y-1">
<h1 className="text-xl font-semibold">This screen failed to render</h1>
<p className="text-muted-foreground text-sm">
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
untouched.
</p>
<h1 className="text-xl font-semibold">{t('screenError.title')}</h1>
<p className="text-muted-foreground text-sm">{t('screenError.description')}</p>
</div>
</div>
<details className="bg-muted/40 rounded-md border p-3 text-sm">
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary>
<summary className="cursor-pointer select-none font-medium">
{error.message || t('screenError.unknownError')}
</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
</details>
<div className="flex flex-wrap gap-2">
<Button onClick={reset}>Try again</Button>
<Button onClick={reset}>{t('screenError.tryAgain')}</Button>
<Button variant="secondary" onClick={() => window.location.reload()}>
Reload
{t('screenError.reload')}
</Button>
<Button variant="secondary" asChild>
<Link to="/">Go home</Link>
<Link to="/">{t('screenError.goHome')}</Link>
</Button>
</div>
<div className="space-y-2 border-t pt-4">
<p className="text-muted-foreground text-sm">
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
{t('screenError.resetHint')} <strong>{t('screenError.dashboardsSafe')}</strong>
</p>
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'}
{resetting ? t('screenError.resetting') : confirming ? t('screenError.resetConfirm') : t('screenError.reset')}
</Button>
</div>
</div>
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { Keyboard } from 'lucide-react';
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
import { useTranslation } from '@/lib/i18n';
/*
`Ctrl+?` — every shortcut the shell owns, on one screen.
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
interesting part is `state/shortcuts.ts`.
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
controls grid, and for the same reason: these are short lists that read badly as one long column.
*/
const Row = ({ keys, description }: { keys: string; description: string }) => (
<div className="flex items-baseline gap-3 py-1">
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
putting them on the left. */}
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
{keys}
</kbd>
<span className="text-sm text-muted-foreground">{description}</span>
</div>
);
export const ShortcutsModal = () => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (isShortcutsChord(ev)) {
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
// composer is a help list you cannot open — the lesson from the Spotlight guard.
ev.preventDefault();
setOpen((was) => !was);
return;
}
if (ev.key === 'Escape' && open) setOpen(false);
};
// Capture, so a panel with its own key handling cannot swallow it first.
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [open]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
onMouseDown={() => setOpen(false)}
>
<div
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
onMouseDown={(ev) => ev.stopPropagation()}
>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
<Keyboard className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t('shortcutsModal.title')}</h2>
<span className="ms-auto text-xs text-muted-foreground">{t('shortcutsModal.hint')}</span>
</div>
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
{SHORTCUT_SECTIONS.map((section) => (
<section key={section.titleKey} className="rounded-xl border border-border bg-muted/20 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">
{t(section.titleKey)}
</h3>
{section.noteKey && (
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">{t(section.noteKey)}</p>
)}
<div className="mt-2">
{section.shortcuts.map((shortcut) => (
<Row key={shortcut.keys} keys={shortcut.keys} description={t(shortcut.descriptionKey)} />
))}
</div>
</section>
))}
</div>
</div>
</div>
);
};
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { Search } from 'lucide-react';
import { rank } from './match';
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
import { useTranslation } from '@/lib/i18n';
// Spotlight: type a few letters, go somewhere.
//
@@ -11,12 +12,28 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
//
// ── Keyboard first, and the trigger ──
//
// `alt-space`, and ONLY that. It went ctrl-space → shift-space → alt-space, and each move was the owner
// telling us what the chord actually collides with on the machine he uses:
// `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
// shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
//
// ctrl-space focuses the address bar in Brave
// shift-space types a space — see below, this was the real problem
// alt-space taken by the window menu, and the owner rebound it rather than give up the chord
// ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
// preventDefault would win. It does not — the key never reaches this handler.
// alt-space the GNOME window menu. He rebound the desktop to keep it, disliked the replacement, and
// would rather move the app than the window manager.
// shift-space where it lands, with BOTH of its stock behaviours deliberately overridden.
//
// ── Shift-space overrides two things, on purpose ──
//
// It types a space in a text field, and it scrolls a page UP when nothing has focus. The owner's judgement,
// and it is his to make: neither is worth a launcher. Page-up-by-shift-space is a fossil nobody reaches for,
// and typing a space is what the unmodified spacebar is for.
//
// The cost, stated once because it is the one that will bite: typing in CAPS by holding shift rather than
// using caps lock puts a shift-space between every word. That opens the launcher mid-sentence. If it turns
// out to be a nuisance, the fix is a guard for text fields only — which this had for a day, and which is
// what made the launcher unopenable from the composer, where it is most wanted.
//
// This is also why the ctrl+? registry names the file that implements each chord: five moves in three days
// is the argument for making them configurable, not for choosing better.
//
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
// combination inside a text field: it types a space, and people hold shift through one more often than
@@ -24,7 +41,7 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
// A launcher you have to prepare for is not a launcher.
//
// Alt-space has no meaning inside a text field, so there is NO focus check at all. The guard shift-space
// There is NO focus check, and here that is a choice with a cost rather than a free one — see above. The guard shift-space
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
@@ -165,6 +182,7 @@ export const Spotlight = () => {
const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const items = useSpotlightItems();
const { t } = useTranslation();
const results = useMemo(() => rank(items, query), [items, query]);
@@ -200,15 +218,18 @@ export const Spotlight = () => {
// anywhere — including inside a panel that has its own key handling.
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
// `altKey` and `code`, not `key`: alt-space produces a different `key` on several layouts (and
// nothing at all on some), while the physical key is `Space` everywhere.
// `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
// the physical key is `Space` everywhere.
//
// Every other modifier must be ABSENT. `ctrl+shift+space` is the theme browser, and one chord opening
// both is the bug that took a capture-phase listener to fix on the theme side.
//
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
// below covers the only thing the guard was protecting against — a layout that would otherwise type
// a character.
if (ev.altKey && ev.code === 'Space') {
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
ev.preventDefault();
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
@@ -268,14 +289,14 @@ export const Spotlight = () => {
ref={inputRef}
value={query}
onChange={(ev) => setQuery(ev.target.value)}
placeholder="Go to…"
placeholder={t('spotlight.placeholder')}
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2">
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
<p className="px-3 py-6 text-center text-sm text-muted-foreground">{t('spotlight.noResults', { query })}</p>
) : (
results.map((result, i) => (
<Row
@@ -19,7 +19,8 @@ import {
Wrench,
} from 'lucide-react';
import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
import { useDockItems } from '../Dock';
// What Spotlight can take you to.
//
@@ -40,6 +41,7 @@ import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
export type SpotlightItem = {
/** Stable across renders — used as the React key and for the recency store. */
id: string;
/** Resolved for the active locale, because `rank()` matches what the reader can actually see. */
label: string;
/** Where it goes. Matched against `canVisit` for anything not already filtered. */
to: string;
@@ -78,64 +80,137 @@ export type SpotlightItem = {
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
* does not say where it lives.
*/
const SETTINGS_ITEMS: SpotlightItem[] = [
type SettingsItem = Omit<SpotlightItem, 'label' | 'hint'> & {
labelKey: AppTranslationKey;
hintKey?: AppTranslationKey;
};
const SETTINGS_ITEMS: SettingsItem[] = [
// Profile — ProfileSettings/index.tsx
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock },
{
id: 'set:profile',
labelKey: 'settings.sections.profile.label',
to: '/settings/profile',
hintKey: 'settings.sections.profile.hint',
icon: User,
},
{
id: 'set:password',
labelKey: 'settings.sections.changePassword.label',
to: '/settings/profile/change-password',
icon: Lock,
},
{
id: 'set:api-keys',
label: 'API keys',
labelKey: 'settings.sections.apiKeys.label',
to: '/settings/profile/api-keys',
hint: 'keys that sign in as you',
hintKey: 'settings.sections.apiKeys.hint',
icon: KeyRound,
},
{
id: 'set:languages',
label: 'Languages',
labelKey: 'settings.sections.languages.label',
to: '/settings/profile/languages',
hint: 'spoken and translation',
hintKey: 'settings.sections.languages.hint',
icon: Globe,
},
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 },
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid },
{
id: 'set:voice',
labelKey: 'settings.sections.voice.label',
to: '/settings/profile/voice',
hintKey: 'settings.sections.voice.hint',
icon: Volume2,
},
{
id: 'set:dock',
labelKey: 'settings.sections.dock.label',
to: '/settings/profile/dock',
hintKey: 'settings.sections.dock.hint',
icon: LayoutGrid,
},
// AI — AISettings.tsx
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles },
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles },
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles },
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot },
{
id: 'set:ai-my-models',
labelKey: 'settings.sections.myModels.label',
to: '/settings/ai/my-models',
icon: Sparkles,
},
{
id: 'set:ai-channel-models',
labelKey: 'settings.sections.channelModels.label',
to: '/settings/ai/member-models',
icon: Sparkles,
},
{
id: 'set:ai-default-models',
labelKey: 'settings.sections.defaultModels.label',
to: '/settings/ai/default-models',
icon: Sparkles,
},
{
id: 'set:ai-harnesses',
labelKey: 'settings.sections.providers.label',
to: '/settings/ai/ai-harnesses',
hintKey: 'settings.sections.providers.hint',
icon: Bot,
},
// System — SystemSettings.tsx
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText },
{ id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', labelKey: 'settings.sections.stt.label', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
{
id: 'set:maintenance',
label: 'Rebuild & restart',
labelKey: 'settings.sections.maintenance.label',
to: '/settings/system/maintenance',
hint: 'maintenance',
hintKey: 'settings.sections.maintenance.hint',
icon: Cpu,
},
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail },
{
id: 'set:smtp',
labelKey: 'settings.sections.smtp.label',
to: '/settings/system/smtp',
hintKey: 'settings.sections.smtp.hint',
icon: Mail,
},
// Integrations — IntegrationsSettings
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound },
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench },
{
id: 'set:google-oauth',
labelKey: 'settings.sections.googleOauth.label',
to: '/settings/integrations/google-oauth',
icon: KeyRound,
},
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
{
id: 'set:dav',
label: 'Calendar & Contacts sync',
labelKey: 'settings.sections.davSync.label',
to: '/settings/integrations/dav-app-passwords',
hint: 'app passwords',
hintKey: 'settings.sections.davSync.hint',
icon: CalendarDays,
},
// Users — UserManagement
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users },
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck },
{
id: 'set:users',
labelKey: 'settings.sections.accounts.label',
to: '/settings/user-management/users',
hintKey: 'settings.sections.accounts.hint',
icon: Users,
},
{
id: 'set:permissions',
labelKey: 'settings.sections.permissions.label',
to: '/settings/user-management/permissions',
icon: ShieldCheck,
},
];
/** Everything Spotlight can offer this account, in a stable order. */
export function useSpotlightItems(): SpotlightItem[] {
const { canVisit, plugins } = usePermissions();
const { t } = useTranslation();
const dockItems = useDockItems(plugins);
return useMemo(() => {
const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)]
const fromDock: SpotlightItem[] = dockItems
.filter((item) => canVisit(item.to))
.map((item) => ({
id: `nav:${item.to}`,
@@ -149,8 +224,14 @@ export function useSpotlightItems(): SpotlightItem[] {
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to));
const settings: SpotlightItem[] = SETTINGS_ITEMS.filter((item) => canVisit(item.to)).map(
({ labelKey, hintKey, ...item }) => ({
...item,
label: t(labelKey),
...(hintKey ? { hint: t(hintKey) } : {}),
}),
);
return [...fromDock, ...settings];
}, [canVisit, plugins]);
}, [canVisit, dockItems, t]);
}
@@ -0,0 +1,20 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /minesweeper — Minesweeper X running under wine on a virtual display, streamed over RFB.
//
// The pixels arrive through /api/minesweeper/ws, which is a byte relay onto x11vnc's own TCP socket on
// loopback — the same shape as the terminal's relay onto the pty sidecar, and the reason this works at
// all where `plugins/desktop` could not: a websocket provider is a platform concern, and a plugin
// cannot claim one.
//
// Owner-only, and not because a game is dangerous: wine runs as the server owner, so the game's menus
// are file dialogs with the owner's home behind them.
export const MinesweeperScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/minesweeper', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['minesweeper'], fallback: 'minesweeper' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The board is 152x214 and there is nothing to put beside it — a split here would be a
// second panel of empty space.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'minesweeper',
appType: 'minesweeper',
};
@@ -0,0 +1 @@
export * from './MinesweeperScreen';
@@ -4,8 +4,10 @@ import { KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
export const PasskeyGate = () => {
const { t } = useTranslation();
const { user, createPasskeyCredentials } = useAuth();
const [isRegistering, setIsRegistering] = useState(false);
@@ -31,7 +33,7 @@ export const PasskeyGate = () => {
<KeyRound className="h-8 w-8 text-duck-teal" />
</div>
<h2 className="text-2xl font-bold text-duck-dark">Set up your passkey</h2>
<h2 className="text-2xl font-bold text-duck-dark">{t('passkeyGate.setUp')}</h2>
<p className="text-duck-dark/70">
Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /sendoff — pick files, pick a machine of your own or a person who has allowed you, send.
//
// There is no receiving half. Files land in `Documents/SendOff/` inside the recipient's own tree, so the
// file browser already IS the inbox — and a second view of the same directory would be a worse copy of
// one that already works, kept in sync by hand.
export const SendOffScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/sendoff', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['sendoff'], fallback: 'sendoff' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The form is a column and there is nothing to put beside it — receiving already has a UI,
// which is the file browser.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'sendoff',
appType: 'sendoff',
};
@@ -0,0 +1 @@
export * from './SendOffScreen';
@@ -11,6 +11,7 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings';
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
import { useAccessPolicy } from 'state/useAccessPolicy';
import { useUserState } from 'state/useUserState';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
// --- My Models Section (per-user hidden models) ---
function MyModelsSection() {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings();
const visibleModels = useModels();
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
@@ -174,11 +176,7 @@ function MyModelsSection() {
}, []);
if (providerGroups.length === 0) {
return (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
}
return (
@@ -199,9 +197,9 @@ function MyModelsSection() {
))}
</div>
<DropZone label="Visible" onDrop={showModel}>
<DropZone label={t('settings.ai.visible')} onDrop={showModel}>
{shown.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to show</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.dragToShow')}</span>
)}
{shown.map((m) => (
<ModelPill
@@ -215,9 +213,9 @@ function MyModelsSection() {
))}
</DropZone>
<DropZone label="Hidden" onDrop={hideModel}>
<DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
{hidden.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models visible</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allVisible')}</span>
)}
{hidden.map((m) => (
<ModelPill
@@ -237,6 +235,7 @@ function MyModelsSection() {
// --- Member Models Section (system-wide policy, admin only) ---
function MemberModelsSection() {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const { policy, savePolicy } = useAccessPolicy();
@@ -249,9 +248,9 @@ function MemberModelsSection() {
try {
await client.post('/server-settings/chat-providers/local-providers/refresh');
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success('Models refreshed');
toast.success(t('settings.ai.refreshed'));
} catch {
toast.error('Failed to refresh models');
toast.error(t('settings.ai.refreshFailed'));
} finally {
setRefreshing(false);
}
@@ -325,11 +324,7 @@ function MemberModelsSection() {
}, []);
if (providerGroups.length === 0) {
return (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
}
return (
@@ -353,15 +348,17 @@ function MemberModelsSection() {
onClick={refreshModels}
disabled={refreshing}
className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Refresh models from providers"
title={t('settings.ai.refresh')}
>
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
<DropZone label="Enabled" onDrop={enableModel}>
<DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
{enabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.ai.dragToEnable')}
</span>
)}
{enabled.map((m) => (
<ModelPill
@@ -375,9 +372,9 @@ function MemberModelsSection() {
))}
</DropZone>
<DropZone label="Disabled" onDrop={disableModel}>
<DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
{disabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allEnabled')}</span>
)}
{disabled.map((m) => (
<ModelPill
@@ -410,49 +407,49 @@ function MemberModelsSection() {
function useAISettingsGroups(): SettingsSectionGroup[] {
return useMemo(() => {
const modelsGroup: SettingsSectionGroup = {
label: 'Models',
labelKey: 'settings.groups.models',
icon: Eye,
sections: [
{
key: 'my-models',
icon: Eye,
title: 'My Models',
description: 'Show or hide models for yourself',
titleKey: 'settings.sections.myModels.label',
descriptionKey: 'settings.sections.myModels.description',
content: <MyModelsSection />,
},
{
key: 'member-models',
icon: Eye,
title: 'Channel Models',
description: 'Models reachable by non-owner accounts',
titleKey: 'settings.sections.channelModels.label',
descriptionKey: 'settings.sections.channelModels.description',
content: <MemberModelsSection />,
},
],
};
const defaultsGroup: SettingsSectionGroup = {
label: 'Defaults',
labelKey: 'settings.groups.defaults',
icon: Sparkles,
sections: [
{
key: 'default-models',
icon: Sparkles,
title: 'Default Models',
description: 'Default models for chat, projects, and tasks',
titleKey: 'settings.sections.defaultModels.label',
descriptionKey: 'settings.sections.defaultModels.description',
content: <AIModels />,
},
],
};
const providersGroup: SettingsSectionGroup = {
label: 'Providers',
labelKey: 'settings.groups.providers',
icon: Terminal,
sections: [
{
key: 'ai-harnesses',
icon: Terminal,
title: 'Providers',
description: 'Remote and local AI providers',
titleKey: 'settings.sections.providers.label',
descriptionKey: 'settings.sections.providers.description',
content: <AIHarnessesSection />,
},
],
@@ -482,7 +479,7 @@ export const AISettings = () => {
createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: Bot,
sidebarLabel: 'AI',
sidebarLabelKey: 'settings.nav.ai',
groups,
}),
[groups],
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type ApifyConfigData = {
apiToken: string;
@@ -15,6 +16,7 @@ type ApifyStatus = {
};
export const ApifyConfig = () => {
const { t } = useTranslation();
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@@ -38,10 +40,10 @@ export const ApifyConfig = () => {
setIsSaving(true);
try {
await client.put('/integrations/apify/config', { apiToken: apiToken.trim() });
toast.success('Apify API token saved');
toast.success(t('settings.apify.saved'));
setStatus({ configured: !!apiToken.trim() });
} catch {
toast.error('Failed to save Apify API token');
toast.error(t('settings.apify.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -64,7 +66,7 @@ export const ApifyConfig = () => {
<form onSubmit={handleSave} className="grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Token</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.apify.token')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
@@ -7,6 +7,8 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
//
@@ -26,19 +28,21 @@ type DavPassword = {
type Minted = { password: string; username: string; label: string };
const formatDate = (value: string | null) =>
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(`${what} copied`);
} catch {
toast.error('Could not copy — select and copy manually');
}
};
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
export const DavAppPasswords = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.dav.copied', { what }));
} catch {
toast.error(t('settings.dav.copyFailed'));
}
};
const client = useClient();
const [passwords, setPasswords] = useState<DavPassword[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -71,7 +75,7 @@ export const DavAppPasswords = () => {
setLabel('');
load();
} catch {
toast.error('Could not create the app password');
toast.error(t('settings.dav.createFailed'));
} finally {
setIsCreating(false);
}
@@ -80,10 +84,10 @@ export const DavAppPasswords = () => {
const revoke = async (id: number) => {
try {
await client.post(`/dav/passwords/${id}/revoke`, {});
toast.success('Revoked — that device will stop syncing');
toast.success(t('settings.dav.revoked'));
load();
} catch {
toast.error('Could not revoke');
toast.error(t('settings.dav.revokeFailed'));
}
};
@@ -92,7 +96,7 @@ export const DavAppPasswords = () => {
await client.delete(`/dav/passwords/${id}`);
load();
} catch {
toast.error('Could not delete');
toast.error(t('settings.dav.deleteFailed'));
}
};
@@ -126,7 +130,12 @@ export const DavAppPasswords = () => {
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{value}
</code>
<Button variant="ghost" size="sm" onClick={() => copy(value, field)} title={`Copy ${field}`}>
<Button
variant="ghost"
size="sm"
onClick={() => copy(value, field)}
title={t('settings.dav.copyField', { field })}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
@@ -146,29 +155,31 @@ export const DavAppPasswords = () => {
<form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New device</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={label}
onChange={(ev) => setLabel(ev.target.value)}
placeholder="Pixel 9, iPad, Thunderbird…"
placeholder={t('settings.dav.placeholder')}
/>
</Label>
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
Create
{t('settings.dav.create')}
</Button>
</form>
{passwords.length === 0 ? (
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
No devices yet.
{t('settings.dav.none')}
</div>
) : (
<div className="grid gap-2">
{passwords.map((entry) => {
const revoked = !!entry.revokedAt;
const lastUsed = formatDate(entry.lastUsedAt);
const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div
key={entry.id}
@@ -192,7 +203,7 @@ export const DavAppPasswords = () => {
</div>
</div>
{!revoked && (
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title="Revoke">
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
@@ -200,7 +211,7 @@ export const DavAppPasswords = () => {
variant="ghost"
size="sm"
onClick={() => remove(entry.id)}
title="Delete"
title={t('settings.dav.delete')}
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type GoogleStatus = {
connected: boolean;
@@ -11,6 +12,7 @@ type GoogleStatus = {
};
export const GoogleAccount = () => {
const { t } = useTranslation();
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<GoogleStatus>({
@@ -30,9 +32,9 @@ export const GoogleAccount = () => {
const params = new URLSearchParams(window.location.search);
const result = params.get('google');
if (result === 'success') {
toast.success('Google account connected');
toast.success(t('settings.googleAccount.connectedToast'));
} else if (result === 'error') {
toast.error('Failed to connect Google account');
toast.error(t('settings.googleAccount.connectFailed'));
}
if (result) {
window.history.replaceState({}, '', window.location.pathname);
@@ -51,9 +53,9 @@ export const GoogleAccount = () => {
try {
await client.delete('/integrations/google/connection');
setStatus({ ...status, connected: false, email: null });
toast.success('Google account disconnected');
toast.success(t('settings.googleAccount.disconnectedToast'));
} catch {
toast.error('Failed to disconnect Google account');
toast.error(t('settings.googleAccount.disconnectFailed'));
}
};
@@ -63,17 +65,17 @@ export const GoogleAccount = () => {
return (
<div className="grid gap-4">
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Google Account</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
Connect your Google account for Calendar, Contacts, and other Google services.
</p>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">{t('settings.googleAccount.title')}</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">{t('settings.googleAccount.hint')}</p>
</div>
{status.connected ? (
<>
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-success shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.googleAccount.connected')}
</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
</div>
{status.picture && (
@@ -81,7 +83,7 @@ export const GoogleAccount = () => {
)}
</div>
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
Disconnect
{t('settings.googleAccount.disconnect')}
</Button>
</>
) : (
@@ -90,7 +92,7 @@ export const GoogleAccount = () => {
onClick={handleConnect}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
>
Connect Google Account
{t('settings.googleAccount.connect')}
</Button>
)}
</div>
@@ -7,197 +7,175 @@ import { Label } from '@/components/ui/label';
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';
type GoogleOAuthSettings = {
clientId: string;
clientSecret: string;
};
// The scope strings are Google's own identifiers; only the explanation beside them is ours.
const SCOPES = [
{ scope: 'gmail.readonly', description: 'Read emails' },
{ scope: 'calendar.readonly', description: 'Read calendar events' },
];
{ scope: 'gmail.readonly', descriptionKey: 'settings.google.scopeGmail' },
{ scope: 'calendar.readonly', descriptionKey: 'settings.google.scopeCalendar' },
] as const;
const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
/*
Every quoted Google-console label below is wrapped in <Untranslated>. Per docs/localization.md they
are identifiers, not prose: they exist to match what the reader sees on Google's screen, and Google
localises by the reader's Google account language rather than ours.
The sentences are keyed WHOLE, each ending at the label or link it introduces. The first attempt
split them around inline labels, which needs "Go to the" and "page." as separate keys — and the
Portuguese value of the second one is "." That is the fragmentation smell, so the English was
rephrased to put the label at a clause boundary, which reads naturally in both languages.
*/
const GoogleLink = ({ href, label }: { href: string; label: string }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<Untranslated reason="foreign-ui-label">{label}</Untranslated>
</a>
);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
Step-by-step setup guide
{t('settings.google.guideToggle')}
</CollapsibleTrigger>
<CollapsibleContent>
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
<li>
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step1.title')}</strong>
<p className="mt-1">
Go to the{' '}
<a
{t('settings.google.step1.body')}{' '}
<GoogleLink
href="https://console.cloud.google.com/projectcreate"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
label={t('settings.google.step1.link')}
/>{' '}
→ <Untranslated reason="foreign-ui-label">Create</Untranslated>
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step2.title')}</strong>
<p className="mt-1">
Go to{' '}
<a
{t('settings.google.step2.body')}{' '}
<GoogleLink
href="https://console.cloud.google.com/apis/library"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
API Library
</a>
. Search for and enable each of these:
label={t('settings.google.step2.link')}
/>{' '}
→ <Untranslated reason="foreign-ui-label">Enable</Untranslated>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
<strong>Gmail API</strong>
<Untranslated reason="foreign-ui-label">Gmail API</Untranslated>
</li>
<li>
<strong>Google Calendar API</strong>
<Untranslated reason="foreign-ui-label">Google Calendar API</Untranslated>
</li>
</ul>
<p className="mt-1">
Click each one, then click <strong>Enable</strong>.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step3.title')}</strong>
<p className="mt-1">
Go to{' '}
<a
<GoogleLink
href="https://console.cloud.google.com/auth/branding"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Branding
</a>
.
label={t('settings.google.step3.link')}
/>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
Set <strong>App name</strong> to your organization name or &quot;Officer&quot;
{t('settings.google.step3.appName')} <Untranslated reason="foreign-ui-label">App name</Untranslated>
</li>
<li>
Set <strong>User support email</strong> to your admin email
{t('settings.google.step3.supportEmail')}{' '}
<Untranslated reason="foreign-ui-label">User support email</Untranslated>
</li>
<li>
Add your admin email under <strong>Developer contact information</strong>
{t('settings.google.step3.devContact')}{' '}
<Untranslated reason="foreign-ui-label">Developer contact information</Untranslated>
</li>
<li>
Click <strong>Save</strong>
{t('settings.google.step3.save')} <Untranslated reason="foreign-ui-label">Save</Untranslated>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step4.title')}</strong>
<p className="mt-1">
Go to{' '}
<a
<GoogleLink
href="https://console.cloud.google.com/auth/audience"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Audience
</a>
.
label={t('settings.google.step4.link')}
/>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
{t('settings.google.step4.internal')} <Untranslated reason="foreign-ui-label">Internal</Untranslated>
</li>
<li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
(required while the app is unverified; limit of 100 test users)
{t('settings.google.step4.external')} <Untranslated reason="foreign-ui-label">External</Untranslated>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step5.title')}</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a
href="https://console.cloud.google.com/auth/scopes"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
{t('settings.google.step5.body')}{' '}
<GoogleLink href="https://console.cloud.google.com/auth/scopes" label={t('settings.google.step5.link')} />{' '}
→ <Untranslated reason="foreign-ui-label">Add or remove scopes</Untranslated>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => (
<li key={s.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
{s.description}
{SCOPES.map((entry) => (
<li key={entry.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{entry.scope}
</code>{' '}
— {t(entry.descriptionKey)}
</li>
))}
</ul>
<p className="mt-1">
Click <strong>Update</strong>, then <strong>Save</strong>.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">{t('settings.google.step5.note')}</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step6.title')}</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a
{t('settings.google.step6.body')}{' '}
<GoogleLink
href="https://console.cloud.google.com/auth/clients"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Clients
</a>
, then click <strong>Create OAuth client</strong>.
label={t('settings.google.step6.link')}
/>{' '}
→ <Untranslated reason="foreign-ui-label">Create OAuth client</Untranslated>
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
Application type: <strong>Web application</strong>
{t('settings.google.step6.appType')}{' '}
<Untranslated reason="foreign-ui-label">Web application</Untranslated>
</li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li>
<li>{t('settings.google.step6.nameAnything')}</li>
<li>
Authorized redirect URIs: add{' '}
{t('settings.google.step6.redirectUris')}{' '}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all">
{redirectUri}
</code>
</li>
<li>
Click <strong>Create</strong>
{t('settings.google.step6.thenClick')} <Untranslated reason="foreign-ui-label">Create</Untranslated>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
<p className="mt-1">
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
them into the fields below.
</p>
<strong className="text-duck-dark dark:text-foreground">{t('settings.google.step7.title')}</strong>
<p className="mt-1">{t('settings.google.step7.body')}</p>
</li>
</ol>
</CollapsibleContent>
@@ -208,11 +186,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
type VerifyStatus = { valid: boolean; error: string | null } | null;
const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
const { t } = useTranslation();
if (isVerifying) {
return (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="h-2.5 w-2.5 rounded-full bg-duck-dark/20 dark:bg-foreground/20 animate-pulse shrink-0" />
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">Verifying credentials...</span>
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">{t('settings.google.verifying')}</span>
</div>
);
}
@@ -223,13 +202,14 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-success' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
{status.valid ? t('settings.google.valid') : (status.error ?? t('settings.google.invalid'))}
</span>
</div>
);
};
export const GoogleOAuthConfig = () => {
const { t } = useTranslation();
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@@ -243,7 +223,7 @@ export const GoogleOAuthConfig = () => {
client
.get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
.then(setVerifyStatus)
.catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' }))
.catch(() => setVerifyStatus({ valid: false, error: t('settings.google.verifyFailed') }))
.finally(() => setIsVerifying(false));
};
@@ -273,10 +253,10 @@ export const GoogleOAuthConfig = () => {
setIsSaving(true);
try {
await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
toast.success('Google OAuth configuration saved');
toast.success(t('settings.google.saved'));
verify();
} catch {
toast.error('Failed to save Google OAuth configuration');
toast.error(t('settings.google.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -294,7 +274,7 @@ export const GoogleOAuthConfig = () => {
<form onSubmit={handleSave} className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientId')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
@@ -305,7 +285,7 @@ export const GoogleOAuthConfig = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client Secret</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.clientSecret')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
@@ -316,10 +296,8 @@ export const GoogleOAuthConfig = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Redirect URI</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Add this URI to your Google OAuth client&apos;s authorized redirect URIs
</p>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.google.redirectUri')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.google.redirectUriHint')}</p>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60"
type="text"
@@ -10,6 +10,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { ApifyConfig } from './ApifyConfig';
import { DavAppPasswords } from './DavAppPasswords';
import { useTranslation } from '@/lib/i18n';
const BASE_PATH = '/settings/integrations';
@@ -17,15 +18,15 @@ const enterpriseSections: SettingsSection[] = [
{
key: 'google-oauth',
icon: KeyRound,
title: 'Google OAuth',
description: 'Client ID and secret for Google APIs',
titleKey: 'settings.sections.googleOauth.label',
descriptionKey: 'settings.sections.googleOauth.description',
content: <GoogleOAuthConfig />,
},
{
key: 'apify',
icon: Wrench,
title: 'Apify',
description: 'API token for web scraping actors',
titleKey: 'settings.sections.apify.label',
descriptionKey: 'settings.sections.apify.description',
content: <ApifyConfig />,
},
];
@@ -39,14 +40,14 @@ const personalSections: SettingsSection[] = [
// key: 'google-account',
// icon: UserCircle,
// title: 'Google Account',
// description: 'Connect your Google account',
// descriptionKey: 'settings.sections.apify.description',
// content: <GoogleAccount />,
// },
{
key: 'dav-app-passwords',
icon: CalendarDays,
title: 'Calendar & Contacts sync',
description: 'Per-device passwords for CalDAV/CardDAV clients',
titleKey: 'settings.sections.davSync.label',
descriptionKey: 'settings.sections.davSync.description',
content: <DavAppPasswords />,
},
];
@@ -60,6 +61,7 @@ const tabForSection = (section: string | undefined) =>
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
const IntegrationsSidebar = () => {
const { t } = useTranslation();
const { section } = useParams<{ section?: string }>();
const navigate = useNavigate();
const tab = tabForSection(section);
@@ -77,22 +79,28 @@ const IntegrationsSidebar = () => {
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Puzzle className="h-4 w-4" />
Integrations
{t('settings.nav.integrations')}
</div>
</div>
<div className="px-3 pb-2">
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="w-full">
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
Enterprise
{t('settings.integrations.enterprise')}
</TabsTrigger>
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
Personal
{t('settings.integrations.personal')}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<SettingsSidebar basePath={BASE_PATH} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
<SettingsSidebar
basePath={BASE_PATH}
icon={Puzzle}
labelKey="settings.nav.integrations"
sections={sections}
hideHeader
/>
</div>
);
};
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/select';
import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
function buildGroups(models: ModelOption[]) {
const groups: Record<string, { id: string; name: string }[]> = {};
@@ -29,6 +30,7 @@ function buildGroups(models: ModelOption[]) {
const NONE = '__none__';
export const AIModels = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings();
const piModels = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false);
@@ -54,9 +56,9 @@ export const AIModels = () => {
chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel },
tasks: { ...settings.tasks, defaultModel: taskModel },
});
toast.success('AI model defaults saved');
toast.success(t('settings.profile.models.aiDefaultsSaved'));
} catch {
toast.error('Failed to save settings');
toast.error(t('settings.profile.models.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -86,25 +88,25 @@ export const AIModels = () => {
return (
<div className="grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultChat')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat from the home screen
{t('settings.profile.models.defaultChatHint')}
</p>
{renderModelSelect(chatModel, setChatModel, 'System default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultProject')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat inside a project dashboard
{t('settings.profile.models.defaultProjectHint')}
</p>
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultTask')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when running tasks from the file browser
{t('settings.profile.models.defaultTaskHint')}
</p>
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label>
@@ -2,6 +2,8 @@ import type { FormEvent } from 'react';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input';
@@ -36,19 +38,22 @@ type Minted = { key: string; name: string };
const API_KEYS_QUERY_KEY = ['API_KEYS'];
const formatDate = (value: string | null) =>
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
const copy = async (text: string) => {
try {
await copyToClipboard(text);
toast.success('Key copied');
} catch {
toast.error('Could not copy — select and copy manually');
}
};
// `toLocaleDateString(undefined, …)` followed the BROWSER's locale, so a Portuguese UI showed the
// browser's dates. `useFormat` binds it to the locale the rest of the app is in (D7).
export const ApiKeys = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.profile.apiKeys.copied'));
} catch {
toast.error(t('settings.profile.apiKeys.copyFailed'));
}
};
const client = useClient();
const queryClient = useQueryClient();
const [name, setName] = useState('');
@@ -74,7 +79,7 @@ export const ApiKeys = () => {
setName('');
await refresh();
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not create the key');
toast.error(ex instanceof Error ? ex.message : t('settings.profile.apiKeys.createFailed'));
} finally {
setIsCreating(false);
}
@@ -85,17 +90,17 @@ export const ApiKeys = () => {
await client.delete(`/api-keys/${id}/record`);
await refresh();
} catch {
toast.error('Could not remove it');
toast.error(t('settings.profile.apiKeys.forgetFailed'));
}
};
const revoke = async (id: number) => {
try {
await client.delete(`/api-keys/${id}`);
toast.success('Revoked — anything using that key is signed out');
toast.success(t('settings.profile.apiKeys.revokeDone'));
await refresh();
} catch {
toast.error('Could not revoke');
toast.error(t('settings.profile.apiKeys.revokeFailed'));
}
};
@@ -116,7 +121,7 @@ export const ApiKeys = () => {
if (isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.profile.apiKeys.loading')}
</div>
);
}
@@ -124,15 +129,13 @@ export const ApiKeys = () => {
return (
<div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not
tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '}
<code className="font-mono text-xs">Authorization: Bearer …</code>.
{t('settings.profile.apiKeys.intro')} <code className="font-mono text-xs">Authorization: Bearer …</code>.
</p>
{minted && (
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
Key for “{minted.name}” — shown once
{t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
@@ -143,30 +146,35 @@ export const ApiKeys = () => {
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{minted.key}
</code>
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key">
<Button
variant="ghost"
size="sm"
onClick={() => copy(minted.key)}
title={t('settings.profile.apiKeys.copyKey')}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
I've saved it
{t('settings.profile.apiKeys.savedIt')}
</Button>
</div>
)}
<form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.apiKeys.newKey')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="iPhone, Music app, laptop CLI…"
placeholder={t('settings.profile.apiKeys.namePlaceholder')}
/>
</Label>
<Button type="submit" disabled={!name.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
Create
<Plus className="me-1.5 h-4 w-4" />
{t('settings.profile.apiKeys.create')}
</Button>
</form>
@@ -174,14 +182,16 @@ export const ApiKeys = () => {
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence
that would send somebody looking for a bug in the list. */}
{allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'}
{allKeys.length === 0 ? t('settings.profile.apiKeys.noneYet') : t('settings.profile.apiKeys.noneInUse')}
</div>
) : (
<div className="grid gap-2">
{keys.map((entry) => {
const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now();
const dead = !!entry.revokedAt || expired;
const lastUsed = formatDate(entry.lastUsedAt);
const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div
key={entry.id}
@@ -195,14 +205,20 @@ export const ApiKeys = () => {
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-duck-dark dark:text-foreground">
{entry.name}
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>}
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>}
{entry.revokedAt && (
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.revoked')}</span>
)}
{!entry.revokedAt && expired && (
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.expired')}</span>
)}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
<code className="font-mono">{entry.prefix}…</code>
{' · '}
{/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */}
{lastUsed ? `last used ${lastUsed}` : 'never used'}
{lastUsed
? t('settings.profile.apiKeys.lastUsed', { date: lastUsed })
: t('settings.profile.apiKeys.neverUsed')}
</div>
</div>
{dead ? (
@@ -212,7 +228,7 @@ export const ApiKeys = () => {
variant="ghost"
size="sm"
onClick={() => void forget(entry.id)}
title="Remove this record"
title={t('settings.profile.apiKeys.forget')}
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -222,7 +238,7 @@ export const ApiKeys = () => {
variant="ghost"
size="sm"
onClick={() => void revoke(entry.id)}
title="Revoke"
title={t('settings.profile.apiKeys.revoke')}
className="hover:text-red-500"
>
<Ban className="h-3.5 w-3.5" />
@@ -241,8 +257,8 @@ export const ApiKeys = () => {
className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70"
>
{showRevoked
? 'Hide revoked and expired'
: `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`}
? t('settings.profile.apiKeys.hideDead')
: t('settings.profile.apiKeys.showDead', { count: deadKeys.length })}
</button>
)}
</div>
@@ -3,7 +3,8 @@ import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
import { useTranslation } from '@/lib/i18n';
type DockPillProps = {
label: string;
@@ -104,10 +105,11 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
};
export const DockSettings = () => {
const { t } = useTranslation();
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
// pin a tile that cannot appear, which reads as the setting being broken.
const { plugins } = usePermissions();
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
const allDockItems = useDockItems(plugins);
const { items, allItems, setItems, reset } = useDock(
allDockItems,
useMemo(() => defaultDockPaths(plugins), [plugins]),
@@ -193,10 +195,10 @@ export const DockSettings = () => {
return (
<div className="grid gap-4">
<DropZone label="Visible" onDrop={onDropVisible}>
<DropZone label={t('settings.profile.dock.visible')} onDrop={onDropVisible}>
{items.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
Drag items here to show in dock
{t('settings.profile.dock.dragToShow')}
</span>
)}
{items.map((item) => (
@@ -216,9 +218,11 @@ export const DockSettings = () => {
))}
</DropZone>
<DropZone label="Hidden" onDrop={onDropHidden}>
<DropZone label={t('settings.profile.dock.hidden')} onDrop={onDropHidden}>
{hiddenItems.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.profile.dock.allVisible')}
</span>
)}
{hiddenItems.map((item) => (
<DockPill
@@ -235,7 +239,7 @@ export const DockSettings = () => {
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Reset to defaults
{t('settings.profile.dock.reset')}
</Button>
</div>
);
@@ -1,45 +1,67 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
import { SHIPPED_LOCALES } from '@/lib/locales';
const LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'pt', label: 'Portuguese' },
{ code: 'es', label: 'Spanish' },
{ code: 'fr', label: 'French' },
{ code: 'de', label: 'German' },
{ code: 'it', label: 'Italian' },
{ code: 'nl', label: 'Dutch' },
{ code: 'ru', label: 'Russian' },
{ code: 'zh', label: 'Chinese' },
{ code: 'ja', label: 'Japanese' },
{ code: 'ko', label: 'Korean' },
{ code: 'ar', label: 'Arabic' },
{ code: 'hi', label: 'Hindi' },
{ code: 'tr', label: 'Turkish' },
{ code: 'pl', label: 'Polish' },
{ code: 'sv', label: 'Swedish' },
{ code: 'da', label: 'Danish' },
{ code: 'no', label: 'Norwegian' },
{ code: 'fi', label: 'Finnish' },
{ code: 'uk', label: 'Ukrainian' },
{ code: 'cs', label: 'Czech' },
{ code: 'ro', label: 'Romanian' },
{ code: 'el', label: 'Greek' },
{ code: 'he', label: 'Hebrew' },
{ code: 'th', label: 'Thai' },
{ code: 'vi', label: 'Vietnamese' },
{ code: 'id', label: 'Indonesian' },
{ code: 'ms', label: 'Malay' },
];
/**
* Language names come from `Intl.DisplayNames`, not from the catalog.
*
* The runtime already knows every language's name in every locale — "German" in English, "alemão" in
* Portuguese — so 28 names would have been 56 hand-written catalog entries that `Intl` gets right for
* free, including for locales we have not shipped yet. Same reasoning as D7 for dates and numbers.
*
* The tags themselves stay a hardcoded list: this is "which languages may I claim to speak", a product
* choice, not something to enumerate from the runtime.
*/
const SPOKEN_LANGUAGE_TAGS = [
'en',
'pt',
'es',
'fr',
'de',
'it',
'nl',
'ru',
'zh',
'ja',
'ko',
'ar',
'hi',
'tr',
'pl',
'sv',
'da',
'no',
'fi',
'uk',
'cs',
'ro',
'el',
'he',
'th',
'vi',
'id',
'ms',
] as const;
const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label ?? code;
/** `Intl` yields "alemão" in pt; a picker row reads better capitalized. */
const useLanguageName = () => {
const { locale } = useTranslation();
return useMemo(() => {
const names = new Intl.DisplayNames([locale], { type: 'language', fallback: 'code' });
return (code: string) => {
const name = names.of(code) ?? code;
return name.charAt(0).toUpperCase() + name.slice(1);
};
}, [locale]);
};
export const Languages = () => {
const { settings, saveSettings } = useSettings();
const { t } = useTranslation();
const languageName = useLanguageName();
const { spoken, default: defaultLang, translateTo } = settings.languages;
const [addingLang, setAddingLang] = useState('');
@@ -56,12 +78,13 @@ export const Languages = () => {
const removeSpoken = (code: string) => {
const next = spoken.filter((s) => s !== code);
const updates = { ...settings.languages, spoken: next };
if (defaultLang === code) updates.default = next[0] ?? 'en';
// `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
// language never leaves it without a valid value.
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
save(updates);
};
const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code));
const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code));
return (
<div className="grid gap-5">
@@ -74,7 +97,7 @@ export const Languages = () => {
key={code}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium"
>
{getLabel(code)}
{languageName(code)}
{spoken.length > 1 && (
<button
onClick={() => removeSpoken(code)}
@@ -94,9 +117,9 @@ export const Languages = () => {
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
>
<option value="">{t('settings.profile.languages.addLanguage')}</option>
{availableToAdd.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
{availableToAdd.map((code) => (
<option key={code} value={code}>
{languageName(code)}
</option>
))}
</select>
@@ -104,7 +127,7 @@ export const Languages = () => {
)}
</div>
{/* Default language */}
{/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
<Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
<select
@@ -112,9 +135,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{spoken.map((code) => (
{SHIPPED_LOCALES.map(({ code, label }) => (
<option key={code} value={code}>
{getLabel(code)}
{label}
</option>
))}
</select>
@@ -129,9 +152,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
{SPOKEN_LANGUAGE_TAGS.map((code) => (
<option key={code} value={code}>
{languageName(code)}
</option>
))}
</select>
@@ -13,8 +13,10 @@ import {
} from '@/components/ui/select';
import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
export const TaskDefaults = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings();
const models = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false);
@@ -44,9 +46,9 @@ export const TaskDefaults = () => {
setIsSaving(true);
try {
await saveSettings({ ...settings, tasks: { defaultProvider: 'chat', defaultModel: model } });
toast.success('Task defaults saved');
toast.success(t('settings.profile.models.taskDefaultsSaved'));
} catch {
toast.error('Failed to save settings');
toast.error(t('settings.profile.models.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -55,10 +57,10 @@ export const TaskDefaults = () => {
return (
<div className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Default Model</span>
<span className="text-duck-dark/70">{t('settings.profile.models.defaultModel')}</span>
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Same as chat default" />
<SelectValue placeholder={t('settings.profile.models.sameAsChat')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{modelGroups.map(({ provider, models }) => (
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type ProfileFormState = {
name?: string;
@@ -24,6 +25,7 @@ const readFileAsBase64 = (file: File): Promise<string> => {
};
export const UserData = () => {
const { t } = useTranslation();
const { user, updateUser } = useAuth();
const [isUpdating, setIsUpdating] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
@@ -35,12 +37,12 @@ export const UserData = () => {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
toast.error(t('settings.profile.userData.selectImage'));
return;
}
if (file.size > MAX_AVATAR_SIZE) {
toast.error('Image must be smaller than 384KB');
toast.error(t('settings.profile.userData.imageTooLarge'));
return;
}
@@ -58,11 +60,11 @@ export const UserData = () => {
name: profileForm.state.name ?? '',
avatar: avatarPreview ?? user?.avatar ?? '',
});
toast.success('Profile updated');
toast.success(t('settings.profile.userData.updated'));
setAvatarPreview(null);
} catch (ex) {
const error = ex as { message?: string };
toast.error(error.message || 'Failed to update profile');
toast.error(error.message || t('settings.profile.userData.updateFailed'));
} finally {
setIsUpdating(false);
}
@@ -93,7 +95,7 @@ export const UserData = () => {
<form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.email')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
@@ -103,7 +105,7 @@ export const UserData = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.username')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
@@ -113,12 +115,12 @@ export const UserData = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.name')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="name"
placeholder="Your name"
placeholder={t('settings.profile.userData.namePlaceholder')}
autoComplete="name"
/>
</Label>
@@ -128,7 +130,7 @@ export const UserData = () => {
disabled={isUpdating}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isUpdating ? 'Saving...' : 'Save'}
{isUpdating ? t('common.saving') : t('common.save')}
</Button>
</form>
</div>
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
type VoiceGroup = { label: string; voices: string[] };
@@ -26,6 +27,7 @@ type TtsConfig = {
const SERVER_DEFAULT = '__server_default__';
export const VoicePreference = () => {
const { t } = useTranslation();
const client = useClient();
const { settings, saveSettings } = useSettings();
const [voices, setVoices] = useState<string[]>([]);
@@ -128,13 +130,13 @@ export const VoicePreference = () => {
<div className="grid gap-4">
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.voice.label')}</span>
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
title={t('settings.profile.voice.refresh')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
@@ -144,7 +146,7 @@ export const VoicePreference = () => {
<div className="flex items-center gap-2">
<Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Server default" />
<SelectValue placeholder={t('settings.profile.voice.serverDefault')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>
@@ -175,7 +177,7 @@ export const VoicePreference = () => {
onClick={handleListen}
disabled={listening === 'loading' || !serverConfig}
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
title={listening === 'playing' ? 'Stop' : 'Listen'}
title={listening === 'playing' ? t('settings.profile.voice.stop') : t('settings.profile.voice.listen')}
>
{listening === 'loading' ? (
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
@@ -186,9 +188,7 @@ export const VoicePreference = () => {
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.profile.voice.hint')}</span>
</Label>
</div>
);
@@ -14,12 +14,18 @@ import { VoicePreference } from './VoicePreference';
const BASE_PATH = '/settings/profile';
const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
{
key: 'profile',
icon: User,
titleKey: 'settings.sections.profile.label',
descriptionKey: 'settings.sections.profile.description',
content: <UserData />,
},
{
key: 'change-password',
icon: Lock,
title: 'Change Password',
description: 'Update your password',
titleKey: 'settings.sections.changePassword.label',
descriptionKey: 'settings.sections.changePassword.description',
content: <ChangePassword />,
},
// Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an
@@ -29,29 +35,29 @@ const sections: SettingsSection[] = [
{
key: 'api-keys',
icon: KeyRound,
title: 'API keys',
description: 'Keys that sign in as you, revocable one at a time',
titleKey: 'settings.sections.apiKeys.label',
descriptionKey: 'settings.sections.apiKeys.description',
content: <ApiKeys />,
},
{
key: 'languages',
icon: Globe,
title: 'Languages',
description: 'Spoken, default, and translation',
titleKey: 'settings.sections.languages.label',
descriptionKey: 'settings.sections.languages.description',
content: <Languages />,
},
{
key: 'voice',
icon: Volume2,
title: 'Voice',
description: 'Text-to-speech voice preference',
titleKey: 'settings.sections.voice.label',
descriptionKey: 'settings.sections.voice.description',
content: <VoicePreference />,
},
{
key: 'dock',
icon: LayoutGrid,
title: 'Dock',
description: 'Choose and reorder dock items',
titleKey: 'settings.sections.dock.label',
descriptionKey: 'settings.sections.dock.description',
content: <DockSettings />,
},
];
@@ -59,7 +65,7 @@ const sections: SettingsSection[] = [
const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: User,
sidebarLabel: 'Profile',
sidebarLabelKey: 'settings.nav.profile',
sections,
});
@@ -13,6 +13,7 @@ import {
CommandList,
} from '@/components/ui/command';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type StoredApiKeys = { keys: { provider: string; value: string }[] };
@@ -38,6 +39,7 @@ type ChatProvider = { key: string; providerId: string };
type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' };
export const AIHarnessesSection = () => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [installing, setInstalling] = useState(false);
@@ -168,7 +170,7 @@ export const AIHarnessesSection = () => {
// No auth needed — save directly
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
toast.error(t('settings.server.harnesses.connectFailed'));
setProbeState({ step: 'url' });
}
};
@@ -192,13 +194,13 @@ export const AIHarnessesSection = () => {
return;
}
if (result.needsAuth) {
toast.error('Authentication failed');
toast.error(t('settings.server.harnesses.authFailed'));
setProbeState({ step: 'auth', probe });
return;
}
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
toast.error(t('settings.server.harnesses.connectFailed'));
setProbeState({ step: 'auth', probe });
}
};
@@ -218,10 +220,10 @@ export const AIHarnessesSection = () => {
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_PROVIDERS'] });
queryClient.invalidateQueries({ queryKey: ['CHAT_PROVIDERS_LOCAL_HEALTH'] });
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success(`Connected to ${probe.name}`);
toast.success(t('settings.server.harnesses.connectedTo', { name: probe.name ?? '' }));
resetLocalForm();
} catch {
toast.error('Failed to save provider');
toast.error(t('settings.server.harnesses.saveFailed'));
setProbeState({ step: 'url' });
}
};
@@ -254,9 +256,9 @@ export const AIHarnessesSection = () => {
<div className="flex flex-col gap-4">
<div>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-duck-dark">Pi Mono</span>
<span className="text-sm font-medium text-duck-dark">{t('settings.server.harnesses.piMono')}</span>
{piMonoLoading ? (
<span className="text-xs text-duck-dark/50">Checking...</span>
<span className="text-xs text-duck-dark/50">{t('settings.server.harnesses.checking')}</span>
) : piMonoVersion?.version ? (
<div className="flex items-center gap-2">
<span className="text-xs text-duck-dark/50">{piMonoVersion.version}</span>
@@ -265,7 +267,7 @@ export const AIHarnessesSection = () => {
onClick={installAgent}
disabled={installing}
className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Update Pi Mono"
title={t('settings.server.harnesses.updatePiMono')}
>
<RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} />
</button>
@@ -285,13 +287,21 @@ export const AIHarnessesSection = () => {
<>
{/* Local Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.harnesses.localProviders')}
</span>
<div className="mt-3 flex flex-col gap-2">
{localProviders.map((lp: LocalProviderEntry) => (
<div key={lp.id} className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-success' : 'bg-red-500') : 'bg-duck-dark/20'}`}
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
title={
lp.id in localHealth
? localHealth[lp.id]
? 'Online'
: 'Offline'
: t('settings.server.harnesses.checking')
}
/>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
@@ -299,7 +309,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => removeLocalProvider(lp.id)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Remove provider"
title={t('settings.server.harnesses.removeProvider')}
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
@@ -312,7 +322,7 @@ export const AIHarnessesSection = () => {
<Input
type="text"
className="h-8 text-xs"
placeholder="Name (e.g. My Ollama)"
placeholder={t('settings.server.harnesses.namePlaceholder')}
value={localName}
onChange={(ev) => setLocalName(ev.target.value)}
onKeyDown={(ev) => {
@@ -340,7 +350,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!localUrl.trim()}
>
Connect
{t('settings.server.harnesses.connect')}
</Button>
)}
{(probeState.step === 'probing' || probeState.step === 'saving') && (
@@ -351,7 +361,7 @@ export const AIHarnessesSection = () => {
onClick={resetLocalForm}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</div>
</form>
@@ -370,7 +380,7 @@ export const AIHarnessesSection = () => {
<Input
type="text"
className="h-8 text-xs"
placeholder="Username"
placeholder={t('settings.server.harnesses.username')}
value={authUsername}
onChange={(ev) => setAuthUsername(ev.target.value)}
autoFocus
@@ -378,7 +388,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-8 text-xs"
placeholder="Password"
placeholder={t('settings.server.harnesses.password')}
value={authPassword}
onChange={(ev) => setAuthPassword(ev.target.value)}
/>
@@ -388,7 +398,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authUsername || !authPassword}
>
Authenticate
{t('settings.server.harnesses.authenticate')}
</Button>
</>
) : (
@@ -396,7 +406,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-8 text-xs"
placeholder="API Key"
placeholder={t('settings.server.harnesses.apiKey')}
value={authApiKey}
onChange={(ev) => setAuthApiKey(ev.target.value)}
autoFocus
@@ -407,7 +417,7 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authApiKey}
>
Authenticate
{t('settings.server.harnesses.authenticate')}
</Button>
</>
)}
@@ -423,7 +433,7 @@ export const AIHarnessesSection = () => {
onClick={() => setAddingLocal(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Local Provider
{t('settings.server.harnesses.connectLocal')}
</Button>
)}
</div>
@@ -431,7 +441,9 @@ export const AIHarnessesSection = () => {
{/* Remote Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.harnesses.remoteProviders')}
</span>
<div className="mt-3 flex flex-col gap-2">
{connectedProviders.map((provider) => (
<div key={provider.providerId} className="flex flex-col gap-2">
@@ -457,7 +469,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Edit key"
title={t('settings.server.harnesses.editKey')}
>
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
</button>
@@ -465,7 +477,7 @@ export const AIHarnessesSection = () => {
type="button"
onClick={() => disconnectProvider(provider)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Disconnect provider"
title={t('settings.server.harnesses.disconnect')}
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
@@ -478,7 +490,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
placeholder={getStoredMasked(provider.providerId) || t('settings.server.harnesses.enterKey')}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
@@ -492,7 +504,7 @@ export const AIHarnessesSection = () => {
type="submit"
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
title={t('settings.server.harnesses.saveKey')}
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
@@ -501,7 +513,7 @@ export const AIHarnessesSection = () => {
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</form>
)}
@@ -524,7 +536,7 @@ export const AIHarnessesSection = () => {
<Input
type="password"
className="h-7 text-xs flex-1"
placeholder="Enter API key"
placeholder={t('settings.server.harnesses.enterKey')}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
@@ -538,7 +550,7 @@ export const AIHarnessesSection = () => {
type="submit"
disabled={keyInputs[provider.providerId] === undefined || savingKey === provider.providerId}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
title={t('settings.server.harnesses.saveKey')}
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
@@ -547,7 +559,7 @@ export const AIHarnessesSection = () => {
onClick={() => setEditingProvider(null)}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
{tUi('button.cancel')}
</button>
</form>
);
@@ -561,14 +573,14 @@ export const AIHarnessesSection = () => {
onClick={() => setCommandOpen(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Provider
{t('settings.server.harnesses.connectProvider')}
</Button>
)}
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Search providers..." />
<CommandInput placeholder={t('settings.server.harnesses.search')} />
<CommandList>
<CommandEmpty>No providers found.</CommandEmpty>
<CommandEmpty>{t('settings.server.harnesses.none')}</CommandEmpty>
<CommandGroup heading="Available Providers">
{unconnectedProviders.map((provider) => (
<CommandItem
@@ -3,6 +3,7 @@ import { Download, Hammer, Loader2, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
/**
* The two ways to load a change without leaving the browser.
@@ -30,6 +31,7 @@ export function errorMessage(err: unknown, fallback: string): string {
}
export const MaintenanceSection = () => {
const { t } = useTranslation();
const client = useClient();
const [pulling, setPulling] = useState(false);
const [pulled, setPulled] = useState<{ from: string; to: string; commits: string[]; needs: string[] } | null>(null);
@@ -58,11 +60,11 @@ export const MaintenanceSection = () => {
}>('/server-settings/pull', {});
if (!res.updated) {
setPulled(null);
toast.success('Already up to date');
toast.success(t('settings.server.maintenance.upToDate'));
return;
}
setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs });
toast.success(`Pulled ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`);
toast.success(t('settings.server.maintenance.pulled', { count: res.commits.length }));
} catch (err) {
toast.error(errorMessage(err, 'Pull failed'));
} finally {
@@ -81,7 +83,7 @@ export const MaintenanceSection = () => {
setLastBuild(`${(res.ms / 1000).toFixed(1)}s · ${res.outputs} files`);
// Nothing reloads itself: the bundle on disk changed, this page is still the old one. Saying so is
// the difference between "it worked" and "why does my change not show".
toast.success('Frontend rebuilt — refresh this page to see it');
toast.success(t('settings.server.maintenance.rebuilt'));
} catch (err) {
// The client throws on any status >= 400 with the raw response body as its message, so a build
// failure arrives here as the JSON the route sent. Unwrapped, or the toast shows a reader
@@ -96,7 +98,7 @@ export const MaintenanceSection = () => {
setRestarting(true);
try {
await client.post('/server-settings/restart', {});
toast.success('Restarting — the page will reconnect on its own');
toast.success(t('settings.server.maintenance.restarting'));
} catch (err) {
/**
* Two failures arrive here and they are opposites.
@@ -116,7 +118,7 @@ export const MaintenanceSection = () => {
setRestarting(false);
return;
}
toast.success('Restarting — the page will reconnect on its own');
toast.success(t('settings.server.maintenance.restarting'));
}
// Left disabled on the success path, deliberately: the button must not become pressable again while
// the server is coming back.
@@ -126,14 +128,12 @@ export const MaintenanceSection = () => {
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Update the platform</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.updateTitle')}</div>
<p className="mt-1 text-xs text-muted-foreground">
Pulls this server&rsquo;s repository. Refuses if anything is uncommitted or the checkout has diverged — it
will never discard local work.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Changes nothing that is running. Whatever the update needs is listed here, and stays your call.
</p>
<p className="mt-1 text-xs text-muted-foreground">{t('settings.server.maintenance.updateHint')}</p>
{pulled && (
<div className="mt-2 rounded-md border border-border bg-muted/40 p-2">
<p className="font-mono text-[11px] text-muted-foreground">
@@ -163,7 +163,7 @@ export const MaintenanceSection = () => {
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.rebuild')}</div>
<p className="mt-1 text-xs text-muted-foreground">
Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session —
refresh the page when it finishes.
@@ -178,7 +178,7 @@ export const MaintenanceSection = () => {
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Restart the server</div>
<div className="text-sm font-medium text-foreground">{t('settings.server.maintenance.restart')}</div>
<p className="mt-1 text-xs text-muted-foreground">
The only way to load changed server code — Bun caches modules for the life of the process, so rebuilding
cannot see an edit to the backend.
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type OcrConfig = {
url: string;
@@ -15,6 +16,7 @@ type OcrConfig = {
};
export const OCRSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
@@ -59,9 +61,9 @@ export const OCRSection = () => {
try {
await client.put('/server-settings/ocr', { url, model });
queryClient.invalidateQueries({ queryKey: ['OCR_CONFIG'] });
toast.success('OCR settings saved');
toast.success(t('settings.server.ocr.saved'));
} catch {
toast.error('Failed to save OCR settings');
toast.error(t('settings.server.ocr.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -75,7 +77,7 @@ export const OCRSection = () => {
if (res.error) {
toast.error(res.error);
} else {
toast.success('Vision model server is reachable');
toast.success(t('settings.server.ocr.reachable'));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -91,12 +93,12 @@ export const OCRSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return (
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Vision Model Server URL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.ocr.serverUrl')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="http://localhost:64203"
@@ -107,13 +109,13 @@ export const OCRSection = () => {
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
<button
type="button"
onClick={() => fetchModels(url)}
disabled={modelsLoading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh models"
title={t('settings.server.ocr.refreshModels')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${modelsLoading ? 'animate-spin' : ''}`}
@@ -123,7 +125,7 @@ export const OCRSection = () => {
{models.length > 0 ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Select a model" />
<SelectValue placeholder={t('settings.server.ocr.selectModel')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{models.map((m) => (
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type Provider = 'resend' | 'smtp' | 'mailhog';
@@ -31,6 +32,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<SmtpConfig>> = {
};
export const SMTPSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const { user } = useAuth();
@@ -92,9 +94,9 @@ export const SMTPSection = () => {
try {
await client.put('/server-settings/smtp', buildConfig());
queryClient.invalidateQueries({ queryKey: ['SMTP_CONFIG'] });
toast.success('SMTP settings saved');
toast.success(t('settings.server.smtp.saved'));
} catch {
toast.error('Failed to save SMTP settings');
toast.error(t('settings.server.smtp.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -105,7 +107,7 @@ export const SMTPSection = () => {
setIsTestingConnection(true);
try {
await client.post('/server-settings/smtp/test-connection');
toast.success('Connection successful');
toast.success(t('settings.server.smtp.connected'));
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Connection failed';
@@ -132,7 +134,7 @@ export const SMTPSection = () => {
if (result.error) {
toast.error(result.error);
} else {
toast.success(`Test email sent to ${testEmail}`);
toast.success(t('settings.server.smtp.testSent', { email: testEmail }));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -148,7 +150,7 @@ export const SMTPSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
const showSmtpFields = provider === 'smtp' || provider === 'mailhog';
@@ -156,22 +158,22 @@ export const SMTPSection = () => {
<div className="grid gap-4">
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[600]">
<SelectItem value="mailhog">MailHog (local testing)</SelectItem>
<SelectItem value="resend">Resend</SelectItem>
<SelectItem value="smtp">Custom SMTP</SelectItem>
<SelectItem value="mailhog">{t('settings.server.smtp.mailhog')}</SelectItem>
<SelectItem value="resend">{t('settings.server.smtp.resend')}</SelectItem>
<SelectItem value="smtp">{t('settings.server.smtp.custom')}</SelectItem>
</SelectContent>
</Select>
</Label>
{provider === 'resend' && (
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Key</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.apiKey')}</span>
<Input
type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -186,7 +188,7 @@ export const SMTPSection = () => {
<>
<div className="grid grid-cols-[1fr_auto] gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Host</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.host')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="smtp.example.com"
@@ -195,7 +197,7 @@ export const SMTPSection = () => {
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Port</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.port')}</span>
<Input
type="number"
className="h-11 w-24 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
@@ -208,16 +210,20 @@ export const SMTPSection = () => {
<>
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Username</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
{t('settings.server.smtp.username')}
</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="user@example.com"
placeholder={t('settings.server.smtp.userExample')}
value={username}
onChange={(ev) => setUsername(ev.target.value)}
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Password</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
{t('settings.server.smtp.password')}
</span>
<Input
type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"
@@ -228,7 +234,7 @@ export const SMTPSection = () => {
</div>
<Label className="flex items-center gap-3">
<Switch checked={secure} onCheckedChange={setSecure} />
<span className="text-duck-dark/70 dark:text-foreground/70">TLS / SSL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.tls')}</span>
</Label>
</>
)}
@@ -237,7 +243,7 @@ export const SMTPSection = () => {
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">From Name</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromName')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
value={fromName}
@@ -245,7 +251,7 @@ export const SMTPSection = () => {
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">From Email</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.smtp.fromEmail')}</span>
<Input
type="email"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -276,12 +282,14 @@ export const SMTPSection = () => {
</form>
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-4 mt-1">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Send Test Email</span>
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{t('settings.server.smtp.sendTest')}
</span>
<form onSubmit={handleTest} className="flex items-center gap-2 mt-2">
<Input
type="email"
className="h-9 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 flex-1"
placeholder="recipient@example.com"
placeholder={t('settings.server.smtp.recipientExample')}
value={testEmail}
onChange={(ev) => setTestEmail(ev.target.value)}
/>
@@ -6,12 +6,14 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type SttConfig = {
url: string;
};
export const STTSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
@@ -36,9 +38,9 @@ export const STTSection = () => {
try {
await client.put('/server-settings/stt', { url });
queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] });
toast.success('STT settings saved');
toast.success(t('settings.server.stt.saved'));
} catch {
toast.error('Failed to save STT settings');
toast.error(t('settings.server.stt.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -52,7 +54,7 @@ export const STTSection = () => {
if (res.error) {
toast.error(res.error);
} else {
toast.success('Whisper server is reachable');
toast.success(t('settings.server.stt.reachable'));
}
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
@@ -68,12 +70,12 @@ export const STTSection = () => {
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return (
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Whisper Server URL</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.stt.serverUrl')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder="http://localhost:64201"
@@ -16,6 +16,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useTranslation } from '@/lib/i18n';
type Provider = 'openai' | 'elevenlabs';
@@ -33,6 +34,7 @@ const PROVIDER_DEFAULTS: Record<Provider, Partial<TtsConfig>> = {
};
export const TTSSection = () => {
const { t } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -110,9 +112,9 @@ export const TTSSection = () => {
try {
await client.put('/server-settings/tts', buildConfig());
queryClient.invalidateQueries({ queryKey: ['TTS_CONFIG'] });
toast.success('TTS settings saved');
toast.success(t('settings.server.tts.saved'));
} catch {
toast.error('Failed to save TTS settings');
toast.error(t('settings.server.tts.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -143,27 +145,27 @@ export const TTSSection = () => {
const audio = new Audio(audioUrl);
audioRef.current = audio;
audio.play();
toast.success('Playing test audio');
toast.success(t('settings.server.tts.playing'));
} catch {
toast.error('Test failed — could not reach TTS server');
toast.error(t('settings.server.tts.testFailed'));
} finally {
setIsTesting(false);
}
};
if (isLoading) return <p className="text-sm text-duck-dark/40">Loading...</p>;
if (isLoading) return <p className="text-sm text-duck-dark/40">{t('settings.server.loading')}</p>;
return (
<form onSubmit={handleSave} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Provider</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.provider')}</span>
<Select value={provider} onValueChange={(v) => handleProviderChange(v as Provider)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[600]">
<SelectItem value="openai">OpenAI-compatible (Kokoro, OpenAI, etc.)</SelectItem>
<SelectItem value="elevenlabs">ElevenLabs</SelectItem>
<SelectItem value="openai">{t('settings.server.tts.openaiCompatible')}</SelectItem>
<SelectItem value="elevenlabs">{t('settings.server.tts.elevenLabs')}</SelectItem>
</SelectContent>
</Select>
</Label>
@@ -195,7 +197,7 @@ export const TTSSection = () => {
<div className="grid grid-cols-2 gap-2">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.model')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
placeholder={provider === 'openai' ? 'kokoro' : 'eleven_multilingual_v2'}
@@ -205,13 +207,13 @@ export const TTSSection = () => {
</Label>
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.server.voice')}</span>
<button
type="button"
onClick={() => fetchVoices(provider, url, apiKey, model)}
disabled={voicesLoading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
title={t('settings.server.tts.refreshVoices')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
@@ -221,7 +223,7 @@ export const TTSSection = () => {
{voices.length > 0 ? (
<Select value={voice} onValueChange={setVoice}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Select a voice" />
<SelectValue placeholder={t('settings.server.tts.selectVoice')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{voiceGroups.length > 0
@@ -3,17 +3,24 @@ import { useState } from 'react';
import { NavLink, Navigate, useParams } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
/**
* Sections are declared as module-level consts holding JSX `content`, so they cannot call a hook —
* they carry catalog KEYS and the renderers below resolve them. `titleKey` rather than `titleKeyName`
* and deliberately not `title_key`: docs-check-settings.ts greps `key:\s*'…'` for route segments, and
* a capital K keeps `titleKey` out of that match.
*/
export type SettingsSection = {
key: string;
icon: LucideIcon;
title: string;
description: string;
titleKey: AppTranslationKey;
descriptionKey: AppTranslationKey;
content: ReactNode;
};
export type SettingsSectionGroup = {
label: string;
labelKey: AppTranslationKey;
icon: LucideIcon;
sections: SettingsSection[];
};
@@ -21,7 +28,7 @@ export type SettingsSectionGroup = {
type SettingsSidebarProps = {
basePath: string;
icon: LucideIcon;
label: string;
labelKey: AppTranslationKey;
sections: SettingsSection[];
groups?: SettingsSectionGroup[];
hideHeader?: boolean;
@@ -32,44 +39,51 @@ type SettingsSidebarProps = {
* the browser knows it has been visited, and `isActive` comes from the router instead of from a global
* that the content pane had to be told about separately.
*/
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => (
<NavLink
to={to}
className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
<section.icon
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
</div>
</>
)}
</NavLink>
);
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => {
const { t } = useTranslation();
return (
<NavLink
to={to}
className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
<section.icon
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{t(section.titleKey)}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">
{t(section.descriptionKey)}
</div>
</div>
</>
)}
</NavLink>
);
};
export const SettingsSidebar = ({
basePath,
icon: Icon,
label,
labelKey,
sections,
groups,
hideHeader,
}: SettingsSidebarProps) => {
const [search, setSearch] = useState('');
const { t } = useTranslation();
const query = search.toLowerCase();
// Matched against the RESOLVED words, so searching works in the language on screen.
const matchesSearch = (s: SettingsSection) =>
s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query);
t(s.titleKey).toLowerCase().includes(query) || t(s.descriptionKey).toLowerCase().includes(query);
return (
<div className="flex flex-col h-full overflow-y-auto">
@@ -77,13 +91,13 @@ export const SettingsSidebar = ({
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Icon className="h-4 w-4" />
{label}
{t(labelKey)}
</div>
</div>
)}
<div className="px-3 pb-2">
<Input
placeholder="Search..."
placeholder={t('settings.searchPlaceholder')}
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs"
@@ -95,11 +109,11 @@ export const SettingsSidebar = ({
const filtered = group.sections.filter(matchesSearch);
if (filtered.length === 0) return null;
return (
<div key={group.label}>
<div key={group.labelKey}>
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
<group.icon className="h-3 w-3 text-duck-dark/30 dark:text-foreground/30" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30">
{group.label}
{t(group.labelKey)}
</span>
</div>
{filtered.map((s) => (
@@ -121,6 +135,7 @@ type SettingsContentProps = {
};
export const SettingsContent = ({ sections }: SettingsContentProps) => {
const { t } = useTranslation();
// The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key.
// They agree via the URL now, so neither has to be told about the other — and neither can be the one
// that seeds the slot, which is what made the old defaulting order matter (see §5.6).
@@ -130,7 +145,7 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
if (!section) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p>
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">{t('settings.selectSection')}</p>
</div>
);
}
@@ -139,9 +154,9 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2.5 mb-1">
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2>
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{t(section.titleKey)}</h2>
</div>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{t(section.descriptionKey)}</p>
<div>{section.content}</div>
</div>
);
@@ -166,7 +181,7 @@ export const SettingsRoute = ({ basePath, sections, children }: SettingsRoutePro
type CreateSettingsPanelParams = {
basePath: string;
sidebarIcon: LucideIcon;
sidebarLabel: string;
sidebarLabelKey: AppTranslationKey;
sections?: SettingsSection[];
groups?: SettingsSectionGroup[];
};
@@ -174,7 +189,7 @@ type CreateSettingsPanelParams = {
export const createSettingsPanelComponents = ({
basePath,
sidebarIcon,
sidebarLabel,
sidebarLabelKey,
sections = [],
groups,
}: CreateSettingsPanelParams) => {
@@ -183,7 +198,7 @@ export const createSettingsPanelComponents = ({
<SettingsSidebar
basePath={basePath}
icon={sidebarIcon}
label={sidebarLabel}
labelKey={sidebarLabelKey}
sections={allSections}
groups={groups}
/>
@@ -12,44 +12,56 @@ import { OCRSection } from './ServerSettings/OCRSection';
const groups: SettingsSectionGroup[] = [
{
label: 'AI',
labelKey: 'settings.groups.ai',
icon: Bot,
sections: [
{
key: 'tts',
icon: Volume2,
title: 'Text to Speech',
description: 'TTS provider and voice',
titleKey: 'settings.sections.tts.label',
descriptionKey: 'settings.sections.tts.description',
content: <TTSSection />,
},
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> },
{
key: 'stt',
icon: Mic,
titleKey: 'settings.sections.stt.label',
descriptionKey: 'settings.sections.stt.description',
content: <STTSection />,
},
{
key: 'ocr',
icon: ScanText,
title: 'OCR',
description: 'Vision model for text extraction',
titleKey: 'settings.sections.ocr.label',
descriptionKey: 'settings.sections.ocr.description',
content: <OCRSection />,
},
],
},
{
label: 'Maintenance',
labelKey: 'settings.groups.maintenance',
icon: Hammer,
sections: [
{
key: 'maintenance',
icon: Hammer,
title: 'Rebuild & restart',
description: 'Load changes without a terminal',
titleKey: 'settings.sections.maintenance.label',
descriptionKey: 'settings.sections.maintenance.description',
content: <MaintenanceSection />,
},
],
},
{
label: 'Email',
labelKey: 'settings.groups.email',
icon: Mail,
sections: [
{ key: 'smtp', icon: Mail, title: 'SMTP', description: 'Email delivery provider', content: <SMTPSection /> },
{
key: 'smtp',
icon: Mail,
titleKey: 'settings.sections.smtp.label',
descriptionKey: 'settings.sections.smtp.description',
content: <SMTPSection />,
},
],
},
];
@@ -59,7 +71,7 @@ const BASE_PATH = '/settings/system';
const { Sidebar, Content, allSections } = createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: Settings,
sidebarLabel: 'System',
sidebarLabelKey: 'settings.nav.system',
groups,
});
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/lib/i18n';
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
//
@@ -65,6 +66,7 @@ type CreatedAccount = {
};
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
@@ -118,16 +120,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
<div>
<h3 className="text-sm font-medium">{created.email} created</h3>
<p className="text-xs text-muted-foreground">
Copy what you need before closing — none of it can be shown again.
</p>
<p className="text-xs text-muted-foreground">{t('settings.users.copyBeforeClosing')}</p>
</div>
<div className="space-y-1.5">
<Label>Password</Label>
<Label>{t('settings.users.password')}</Label>
<div className="flex gap-2">
<Input readOnly value={created.password} className="font-mono" />
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copy(created.password, t('settings.users.password'))}
>
<Copy className="h-4 w-4" />
</Button>
</div>
@@ -139,14 +144,14 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
{created.osUser && (
<div className="space-y-1.5">
<Label>Linux account</Label>
<Label>{t('settings.users.linuxAccount')}</Label>
<Input readOnly value={created.osUser} className="font-mono" />
</div>
)}
{created.osSshPublicKey && (
<div className="space-y-1.5">
<Label>Their SSH public key</Label>
<Label>{t('settings.users.sshKey')}</Label>
<div className="flex gap-2">
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
<Button
@@ -162,20 +167,20 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
permission error that says nothing about a missing key. */}
<p className="text-xs text-muted-foreground">
Generated on the machine; the private half never leaves it.{' '}
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row.
<strong>{t('settings.users.addToGitea')}</strong> so they can push. Retrievable later from their row.
</p>
</div>
)}
{created.osUserError && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<div className="font-medium">The account works, but its Linux side did not finish</div>
<div className="font-medium">{t('settings.users.linuxIncomplete')}</div>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
</div>
)}
<Button type="button" onClick={close}>
Done
{t('settings.users.done')}
</Button>
</div>
);
@@ -185,7 +190,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Add account
{t('settings.users.addAccount')}
</Button>
);
}
@@ -194,19 +199,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
<form onSubmit={submit} className="space-y-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-sm font-medium">New account</h3>
<h3 className="text-sm font-medium">{t('settings.users.newAccount')}</h3>
<p className="text-xs text-muted-foreground">
Created active — they can sign in straight away. Tell them the password; it is not recoverable afterwards.
</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label="Cancel">
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label={tUi('button.cancel')}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-user-email">Email</Label>
<Label htmlFor="new-user-email">{t('settings.users.email')}</Label>
<Input
id="new-user-email"
type="email"
@@ -218,7 +223,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-name">Name</Label>
<Label htmlFor="new-user-name">{t('settings.users.name')}</Label>
<Input
id="new-user-name"
autoComplete="off"
@@ -229,7 +234,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-username">Username</Label>
<Label htmlFor="new-user-username">{t('settings.users.username')}</Label>
<Input
id="new-user-username"
autoComplete="off"
@@ -237,11 +242,11 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
onChange={(ev) => set('username')(ev.target.value)}
required
/>
<p className="text-xs text-muted-foreground">Letters, numbers, dots, hyphens and underscores.</p>
<p className="text-xs text-muted-foreground">{t('settings.users.usernameHint')}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-role">Role</Label>
<Label htmlFor="new-user-role">{t('settings.users.role')}</Label>
<Select value={form.role} onValueChange={set('role')}>
<SelectTrigger id="new-user-role">
<SelectValue />
@@ -261,7 +266,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-password">Password</Label>
<Label htmlFor="new-user-password">{t('settings.users.password')}</Label>
<div className="flex gap-2">
<Input
id="new-user-password"
@@ -276,7 +281,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
/>
<Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}>
<Dices className="h-4 w-4" />
<span className="sr-only">Generate a password</span>
<span className="sr-only">{t('settings.users.generatePassword')}</span>
</Button>
<Button
type="button"
@@ -285,29 +290,27 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
disabled={!form.password}
onClick={() => {
void copyToClipboard(form.password);
toast.success('Password copied');
toast.success(t('settings.users.passwordCopied'));
}}
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy the password</span>
<span className="sr-only">{t('settings.users.copyPassword')}</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
At least 12 characters, with upper and lower case, a number and a symbol.
</p>
<p className="text-xs text-muted-foreground">{t('settings.users.passwordHint')}</p>
</div>
{/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does
not replace it, because a key on a laptop is no use to an agent running on the server. */}
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-ssh">
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span>
{t('settings.users.sshKey')} <span className="ml-1 text-xs opacity-60">(optional)</span>
</Label>
<Textarea
id="new-user-ssh"
rows={3}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop"
placeholder={t('settings.users.sshPlaceholder')}
className="font-mono text-xs"
value={form.sshPublicKey}
onChange={(ev) => set('sshPublicKey')(ev.target.value)}
@@ -325,7 +328,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
Create account
</Button>
<Button type="button" variant="ghost" onClick={close} disabled={saving}>
Cancel
{tUi('button.cancel')}
</Button>
</div>
</form>
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
import { PERMISSIONS_QUERY_KEY } from 'hooks/usePermissions';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/lib/i18n';
// What each ROLE may reach. Not each user — see the table comment in schema/permissions.ts for why.
//
@@ -37,6 +38,7 @@ type Level = 'none' | 'read' | 'write';
const PERMISSIONS_KEY = ['ROLE_PERMISSIONS'];
export const PermissionsSection = () => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [role, setRole] = useState<string | null>(null);
@@ -78,7 +80,7 @@ export const PermissionsSection = () => {
// The owner may be editing their own view's inputs — and anyone already signed in needs the dock to
// catch up without a reload.
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_QUERY_KEY });
toast.success(`Saved what ${activeRole}s can reach`);
toast.success(t('settings.users.permissionsSaved', { role: activeRole }));
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not save');
} finally {
@@ -89,12 +91,12 @@ export const PermissionsSection = () => {
if (isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading permissions…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingPermissions')}
</div>
);
}
if (isError || !data) {
return <div className="p-6 text-sm text-destructive">Could not load permissions.</div>;
return <div className="p-6 text-sm text-destructive">{t('settings.users.permissionsFailed')}</div>;
}
return (
@@ -103,7 +105,11 @@ export const PermissionsSection = () => {
hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real
buttons, because switching role mutates a draft rather than navigating. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role">
<div
className="flex items-center gap-1 rounded-lg border p-1"
role="tablist"
aria-label={t('settings.users.role')}
>
{data.roles.map((r) => (
<button
key={r}
@@ -128,9 +134,9 @@ export const PermissionsSection = () => {
</div>
<p className="text-xs text-muted-foreground">
Everything is denied unless granted here. <strong>Read</strong> allows viewing, plus changes to things that are
only ever the person&rsquo;s own — their favourites, their playlists, their devices.
<strong> Full</strong> allows everything within the app.
{t('settings.users.deniedUnlessGranted')} <strong>{t('settings.users.read')}</strong> allows viewing, plus
changes to things that are only ever the person&rsquo;s own — their favourites, their playlists, their devices.
<strong> {t('settings.users.full')}</strong> allows everything within the app.
</p>
<div className="divide-y rounded-lg border">
@@ -151,9 +157,9 @@ export const PermissionsSection = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No access</SelectItem>
<SelectItem value="read">Read</SelectItem>
<SelectItem value="write">Full</SelectItem>
<SelectItem value="none">{t('settings.users.noAccess')}</SelectItem>
<SelectItem value="read">{t('settings.users.read')}</SelectItem>
<SelectItem value="write">{t('settings.users.full')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -17,6 +17,7 @@ import {
} from '@/components/ui/alert-dialog';
import { CreateUserForm } from './CreateUserForm';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/lib/i18n';
type ManagedUser = {
id: number;
@@ -43,6 +44,7 @@ type UsersResponse = {
const USERS_KEY = ['MANAGED_USERS'];
export const UsersSection = () => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [pendingId, setPendingId] = useState<number | null>(null);
@@ -77,11 +79,7 @@ export const UsersSection = () => {
* reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it".
*/
const provisionLinux = async (user: ManagedUser) => {
const key = window.prompt(
`Linux account for ${user.email}.\n\n` +
`Paste an SSH public key to allow them to SSH in, or leave empty to keep the current one.`,
'',
);
const key = window.prompt(t('settings.users.provisionPrompt', { email: user.email }), '');
// Cancel is null; empty string is a deliberate "no change".
if (key === null) return;
@@ -147,13 +145,13 @@ export const UsersSection = () => {
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading accounts…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingAccounts')}
</div>
);
}
if (isError || !data) {
return <div className="text-sm text-destructive">Could not load the accounts.</div>;
return <div className="text-sm text-destructive">{t('settings.users.accountsFailed')}</div>;
}
return (
@@ -173,7 +171,12 @@ export const UsersSection = () => {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{user.name || user.username || user.email}</span>
{user.isOwner && <Crown className="h-3.5 w-3.5 shrink-0 text-duck-teal" aria-label="Server owner" />}
{user.isOwner && (
<Crown
className="h-3.5 w-3.5 shrink-0 text-duck-teal"
aria-label={t('settings.users.serverOwner')}
/>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{user.email}
@@ -212,11 +215,13 @@ export const UsersSection = () => {
size="icon"
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
disabled={busy}
aria-label={user.osUser ? `Repair ${user.email}'s Linux account` : `Create a Linux account`}
aria-label={
user.osUser ? t('settings.users.repairFor', { email: user.email }) : t('settings.users.createLinux')
}
title={
user.osUser
? `Linux account: ${user.osUser} — click to repair or replace their SSH key`
: 'No Linux account — click to create one'
? t('settings.users.linuxAccountFor', { osUser: user.osUser })
: t('settings.users.noLinux')
}
onClick={() => void provisionLinux(user)}
>
@@ -231,11 +236,11 @@ export const UsersSection = () => {
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
aria-label={`Copy ${user.email}'s SSH public key`}
title="Copy their SSH public key (add it to their Gitea account)"
aria-label={t('settings.users.copySshFor', { email: user.email })}
title={t('settings.users.copySshTitle')}
onClick={() => {
void copyToClipboard(user.osSshPublicKey!);
toast.success('Public key copied');
toast.success(t('settings.users.publicKeyCopied'));
}}
>
<KeyRound className="h-4 w-4" />
@@ -250,8 +255,8 @@ export const UsersSection = () => {
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Reset ${user.email}'s password`}
title="Generate a new password — shown once, and signs them out everywhere"
aria-label={t('settings.users.resetPasswordFor', { email: user.email })}
title={t('settings.users.resetPasswordTitle')}
onClick={() => setConfirmReset(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
@@ -264,7 +269,7 @@ export const UsersSection = () => {
className="shrink-0 text-muted-foreground hover:text-destructive"
disabled={user.isOwner || busy}
onClick={() => setConfirmDelete(user)}
aria-label={`Remove ${user.email}`}
aria-label={t('settings.users.removeFor', { email: user.email })}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
@@ -282,19 +287,18 @@ export const UsersSection = () => {
<AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove {confirmDelete?.email}?</AlertDialogTitle>
<AlertDialogDescription>
This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, and
anything an installed plugin keeps for them. It cannot be undone.
</AlertDialogDescription>
<AlertDialogTitle>
{t('settings.users.removeTitle', { email: confirmDelete?.email ?? '' })}
</AlertDialogTitle>
<AlertDialogDescription>{t('settings.users.removeBody')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => confirmDelete && void remove(confirmDelete)}
>
Remove account
{t('settings.users.removeAccount')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -305,23 +309,16 @@ export const UsersSection = () => {
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the password for {confirmReset?.email}?</AlertDialogTitle>
<AlertDialogTitle>{t('settings.users.resetTitle', { email: confirmReset?.email ?? '' })}</AlertDialogTitle>
<AlertDialogDescription>
A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up
afterwards. Their existing password stops working immediately, and they are signed out everywhere.
{confirmReset?.osUser ? (
<>
{' '}
Their Linux account ({confirmReset.osUser}) is not affected: it has no password, and SSH keys are
unchanged.
</>
) : null}
{t('settings.users.resetBody')}
{confirmReset?.osUser ? ` ${t('settings.users.resetLinuxNote', { osUser: confirmReset.osUser })}` : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
Generate a new password
{t('settings.users.generateNewPassword')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -345,11 +342,11 @@ export const UsersSection = () => {
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Copy the new password"
aria-label={t('settings.users.copyNewPassword')}
onClick={() => {
if (!newPassword) return;
void copyToClipboard(newPassword.password).then((ok) =>
ok ? toast.success('Password copied') : toast.error('Could not copy — select it and copy by hand'),
ok ? toast.success(t('settings.users.passwordCopied')) : toast.error(t('settings.users.copyFailed')),
);
}}
>
@@ -358,7 +355,7 @@ export const UsersSection = () => {
</div>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setNewPassword(null)}>Done</AlertDialogAction>
<AlertDialogAction onClick={() => setNewPassword(null)}>{t('settings.users.done')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -15,15 +15,15 @@ const sections: SettingsSection[] = [
{
key: 'users',
icon: Users,
title: 'Accounts',
description: 'Who has access, and as what',
titleKey: 'settings.sections.accounts.label',
descriptionKey: 'settings.sections.accounts.description',
content: <UsersSection />,
},
{
key: 'permissions',
icon: ShieldCheck,
title: 'Permissions',
description: 'What each role can reach',
titleKey: 'settings.sections.permissions.label',
descriptionKey: 'settings.sections.permissions.description',
content: <PermissionsSection />,
},
];
@@ -33,7 +33,7 @@ const BASE_PATH = '/settings/user-management';
const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: UserCog,
sidebarLabel: 'Users',
sidebarLabelKey: 'settings.nav.users',
sections,
});
@@ -22,3 +22,5 @@ export * from './Dashboards';
export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './Minesweeper';
export * from './SendOff';
+30 -5
View File
@@ -1,11 +1,30 @@
import { useEffect } from 'react';
import type { ReactNode } from 'react';
import type { TranslationMap } from 'i18n';
import { I18nProvider } from 'i18n';
import { I18nProvider, localeDirection } from 'i18n';
import { panelCatalogs } from 'officerdev';
import { uiCatalogs } from '@/components/locales';
import { useSettings } from 'state/useSettings';
import en from '../locales/en.json';
import pt from '../locales/pt.json';
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
import appEnGB from '../locales/en-GB.json';
import appPtPT from '../locales/pt-PT.json';
const translations: TranslationMap = { en, pt };
const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
/**
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
*/
const translations: TranslationMap = Object.fromEntries(
SHIPPED_LOCALES.map(({ code }) => [
code,
{
app: appCatalogs[code] ?? {},
panel: panelCatalogs[code] ?? {},
ui: uiCatalogs[code] ?? {},
},
]),
);
type I18nBridgeProps = {
children: ReactNode;
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
const { settings } = useSettings();
const locale = settings.languages.default;
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
// RTL catalog needs no second pass over the tree.
useEffect(() => {
document.documentElement.dir = localeDirection(locale);
}, [locale]);
return (
<I18nProvider translations={translations} locale={locale}>
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
{children}
</I18nProvider>
);

Some files were not shown because too many files have changed in this diff Show More