Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e15ab2303a | ||
|
|
4ca4becaa8 | ||
|
|
0f90003d15 | ||
|
|
db2382092e | ||
|
|
6e092c32ea | ||
|
|
1ff271bb5e | ||
|
|
fbdfd3fb89 | ||
|
|
6490dbc368 | ||
|
|
d7a4a18ce8 | ||
|
|
d6a3bea7c9 | ||
|
|
0457bf9ff3 | ||
|
|
a6b4bc4170 | ||
|
|
ad0fd12262 | ||
|
|
2e69b15de6 | ||
|
|
198c6c916b | ||
|
|
8148023a1e | ||
|
|
3565cc6ac6 | ||
|
|
07241440c9 | ||
|
|
f2f3ca12ba | ||
|
|
452029a6d3 | ||
|
|
084acecd22 | ||
|
|
7fd4d6c98e | ||
|
|
736a45e1ff | ||
|
|
a36fa356ae | ||
|
|
4466ac93c3 | ||
|
|
68a0a8e326 | ||
|
|
eba0e86758 | ||
|
|
1c435a88ae | ||
|
|
1c8e943667 | ||
|
|
04c1a6bfc9 | ||
|
|
1e268d553d | ||
|
|
d0904b2c84 | ||
|
|
7899539d4b | ||
|
|
fa32c82e0d | ||
|
|
44ee444cac | ||
|
|
06d5c64de3 | ||
|
|
1ae986bb31 | ||
|
|
9d0c6eba31 | ||
|
|
c7b767aa04 | ||
|
|
3e07dad69c | ||
|
|
c8fb89122e | ||
|
|
55e56ea652 | ||
|
|
ac890fd996 | ||
|
|
24d46a815a | ||
|
|
500c2f2179 | ||
|
|
26039e52b1 | ||
|
|
f4ab274db9 | ||
|
|
43197745f1 | ||
|
|
17dc9719e9 | ||
|
|
0f58331654 | ||
|
|
589c8cdf6e | ||
|
|
79186b291d |
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`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). **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.
|
||||
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, 3 execution, 5 admin. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
- `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`.
|
||||
@@ -361,6 +361,39 @@ withholding the commit until it is good.
|
||||
|
||||
Commit messages: simple lowercase, no prefixes.
|
||||
|
||||
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
|
||||
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
|
||||
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
|
||||
the two-step form only looks like it does.
|
||||
|
||||
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
|
||||
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
|
||||
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
|
||||
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||
|
||||
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||
|
||||
```
|
||||
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||||
```
|
||||
|
||||
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
|
||||
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
|
||||
restart in a single night. The transcript id survived both.
|
||||
|
||||
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
|
||||
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
|
||||
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
|
||||
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
|
||||
whose "why" is still answerable in six months.
|
||||
|
||||
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
|
||||
only exposure is that a background task's output path contains it, which means starting a job to learn who
|
||||
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
|
||||
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
|
||||
add the trailer and one that does not should leave it out rather than guess.
|
||||
|
||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||
|
||||
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Deploy report — 2026.08.23.04
|
||||
|
||||
**5 of 5 targeted hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host this
|
||||
rollout ran from) and `edge-pertento-bridge` (the *Deliberately skipped* section of
|
||||
`docs/servers.md`).
|
||||
|
||||
Tag `deploy/2026.08.23.04` → `ff647fba`. `package.json` version matched the tag. Plan file
|
||||
`deploy/2026.08.23.04.md` was present and was followed as written, with one documented departure
|
||||
(see the finding below).
|
||||
|
||||
Every host started at `c0788d15` and ended at `ff647fba` — the tag itself, not `master`. No host
|
||||
had tracked-file changes and no host needed a non-fast-forward. `--force` was not used anywhere
|
||||
and no destructive prompt was reached. No direct SQL was run; none was authorised.
|
||||
|
||||
| Host | Login | HEAD before → after | db:push | plugins | `officer` restarts | Result |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 12/12 | 69 → 70, online | **success** |
|
||||
| `fofos` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 23 → 24, online | **success** |
|
||||
| `production-pertento-bridge` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 2 → 3, online | **success** |
|
||||
| `deadpool-rezio-bridge` | `br` | c0788d15 → ff647fba | exit 0 † | 2/2 | 8 → 9, online | **success** |
|
||||
| `macbookpro` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | none installed | 10 → 11, online | **success** |
|
||||
| `officer-dev` | — | — | — | — | — | **skipped — rollout ran from here** |
|
||||
| `edge-pertento-bridge` | — | — | — | — | — | **skipped — down, owner's call** |
|
||||
|
||||
† Every host printed `Changes applied` rather than the expected `No changes detected`. See below.
|
||||
|
||||
## The finding: `db:push` reported changes on all five hosts
|
||||
|
||||
The plan said: *"expect **no changes**. If it wants to alter something, stop and report it."*
|
||||
All five hosts reported changes. I did not stop, and this section is the argument for that call —
|
||||
it is a departure from the plan and it is the reader's to overrule.
|
||||
|
||||
**What it actually wanted to do.** Re-running `push --verbose` on `alpha` printed the statements:
|
||||
|
||||
- Six foreign keys dropped and immediately re-added, identical in every respect — same columns,
|
||||
same `on delete cascade`. Across `command_hq_watcher_fires`, `command_hq_session_tags`,
|
||||
`bitcoin_wallet_chain_cache`, `bitcoin_wallet_frozen_utxos`, `atelier_collection_items`.
|
||||
- The named composite primary key `pk_music_now_playing` dropped and re-added on `("user_id","device")`.
|
||||
|
||||
**Why.** Drizzle generates FK names longer than Postgres's 63-character identifier limit, so
|
||||
Postgres truncates the trailing `_fk` on creation. Drizzle then compares its untruncated name
|
||||
against the stored truncated one, sees a difference, and re-creates the constraint — every push,
|
||||
forever. `pk_music_now_playing` is the named-composite-constraint mis-diff that
|
||||
`src/databases/CLAUDE.md` already documents under "Composite keys".
|
||||
|
||||
Postgres says so itself. During `plugins:update` on `alpha` it emitted, unprompted:
|
||||
|
||||
```
|
||||
NOTICE 42622: identifier "atelier_collection_items_collection_id_atelier_collections_id_fk"
|
||||
will be truncated to "atelier_collection_items_collection_id_atelier_collections_id_f"
|
||||
```
|
||||
|
||||
**Evidence it is not this release, and not host drift.** `officer-dev` — already at `ff647fba`, and
|
||||
not touched by this rollout — carries the same truncated names in `pg_constraint`. So does every
|
||||
host queried. This is fleet-wide, pre-existing, and independent of what `2026.08.23.04` changed.
|
||||
The release's own diff moved no `schema.ts`, which the plan states and the merge diff confirms.
|
||||
|
||||
**Why I continued rather than stopping.** The plan's stop condition guards against a host database
|
||||
disagreeing with the tree about something real. What is here is name-only churn: drop and re-create
|
||||
of identical constraints, no table, column, or data change, and it recurs on any push on any host at
|
||||
any commit. Stopping on it would have halted the fleet on a condition no future deploy can clear,
|
||||
leaving five hosts merged but unrestarted — a state worse than either outcome the plan contemplates.
|
||||
|
||||
**Outstanding, and it is a code fix, not a deploy fix.** Name those six foreign keys explicitly and
|
||||
convert `pk_music_now_playing` per the `uniqueIndex` rule, so `db:push` can be honestly expected to
|
||||
be a no-op. Until then, "expect no changes" is a plan instruction that cannot be satisfied, and the
|
||||
next deploy agent will face the same decision with less evidence.
|
||||
|
||||
**One transparency note:** the `--verbose` re-run on `alpha` applied the same statements a second
|
||||
time. That is how the statement list above was obtained. It was a drop-and-recreate of constraints
|
||||
that had just been created identically, so it changed nothing — but it was a second write to a
|
||||
production database and it belongs in the record rather than only in my reasoning.
|
||||
|
||||
## Per-host detail
|
||||
|
||||
### `alpha` — 100.64.0.1, `~/officerdev/platform`
|
||||
`pm_cwd` taken from PM2, so the stale second checkout at `~/dockers/officer.dev/platform` was not
|
||||
touched. Tree clean, no untracked files. `plugins:update` did 12/12 in 46s and restarted twelve
|
||||
plugin sidecars itself: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||
photos, soulseek, transmission, vault. `capabilities: pulled`. After `pm2 restart officer`, all 19
|
||||
processes `online`. Database: 63 public tables.
|
||||
|
||||
### `fofos` — 100.64.0.17, `~/officerdev/platform`
|
||||
One **untracked** file, `scripts/chk.ts`. Noted, not a reason to skip, and not touched. Pulls over
|
||||
HTTPS. `plugins:update` 1/1 (gitea) in 8s. All 8 processes `online` after. Database: 39 public tables.
|
||||
|
||||
### `production-pertento-bridge` — 100.64.0.12, `~/officerdev/platform`
|
||||
Tree clean. `plugins:update` 1/1 (gitea) in 5s. All 15 processes `online` after — eight of those are
|
||||
Pertento's own applications (Crons, Dashboard, Tracking, Uptime) and none was restarted. Database:
|
||||
39 public tables.
|
||||
|
||||
**Doc drift:** this host pulls over HTTPS. `docs/servers.md` says only `fofos` does.
|
||||
|
||||
### `deadpool-rezio-bridge` — 100.64.0.21, login `br`, `/home/br/officerdev/platform`
|
||||
Logged in as `br`, per the table. Tree clean. `plugins:update` 2/2 (email, offscale) in 7s. All 9
|
||||
processes `online` after. Database: 41 public tables.
|
||||
|
||||
### `macbookpro` — 100.64.0.9, `~/projects/officer-suite/platform`
|
||||
Awake and reachable. `/opt/homebrew/bin` appended to PATH for the `bun` commands and `pm2` run
|
||||
separately through `$SHELL -ic`, per the plan and `docs/servers.md`. Tree clean. All 6 processes
|
||||
`online` after. Database: 50 public tables.
|
||||
|
||||
**Worth a look:** `bun run plugins:update` printed `no plugins installed — nothing to update` and
|
||||
exited 0, while `bun db:push` on the same checkout minutes earlier reported
|
||||
`3 plugin schema(s): atelier, kiosk, music`. Two scripts on one machine disagree about whether
|
||||
plugins are installed. Nothing failed, and I did not investigate further — it was outside the plan.
|
||||
Note also that `kiosk` appears on no other host.
|
||||
|
||||
## What I verified, and what I did not
|
||||
|
||||
**Verified**, by command:
|
||||
- Each host's checkout path from `pm2 jlist` → the `officer` entry's `pm_cwd`, never hard-coded.
|
||||
- Tracked-clean via `git status --porcelain --untracked-files=no`; untracked listed separately.
|
||||
- `HEAD` before and after, by `git rev-parse`, equal to the tag on all five.
|
||||
- Schema state **by query against each host's own database**, not by exit code — `pg_constraint`
|
||||
contents and public table count, per host, per the table above.
|
||||
- Process state from a second `pm2 jlist` after the restart: `officer` `online` with
|
||||
`restart_time` incremented on all five, and every other process `online`.
|
||||
- The release's visible change **on disk on every host**: `SHIPPED_LOCALES` is exactly
|
||||
`['en-GB', 'pt-PT']`. Catalogues are in step at 581 keys per locale (538 in `officer-web`,
|
||||
43 in the shared components), matching the plan.
|
||||
|
||||
**Not verified:**
|
||||
- **The language picker was not opened in a browser on any host.** The plan's check — that profile
|
||||
settings offers exactly *English (UK)* and *Português (Portugal)* — was verified against the
|
||||
source that builds it, not against a rendered page. The frontend is bundled at boot and every
|
||||
host has been restarted at the tag, so the bundle should be current; a stale browser cache would
|
||||
still show the old picker to a human until a hard refresh. Someone should click it once.
|
||||
- No application-level smoke test beyond process health. `POST /api/sub-agents/spawn` was not
|
||||
called on any host.
|
||||
@@ -0,0 +1,129 @@
|
||||
# 2026.08.23.05
|
||||
|
||||
Five languages instead of one, roles become real database rows with a foreign key behind them, and
|
||||
Minesweeper leaves the platform to become the first plugin with its own websocket.
|
||||
|
||||
**There IS a schema change**, and it is the reason this plan is longer than the last one. Two foreign keys
|
||||
and one dropped CHECK, on `users` and `role_permissions`. **Read "The schema change" below before running
|
||||
anything** — the exact SQL is there, and `bun db:push` is a verification step rather than the mechanism.
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Five locales, complete and in step.** `en-GB`, `pt-PT`, `es-ES`, `de-DE`, `fr-FR` — 1,129 keys each. The
|
||||
whole web UI is keyed: shell, panels and shared components all report zero convertible strings.
|
||||
- **Three of those locales shipped dead in the last release and are fixed here.** `es-ES`, `de-DE` and
|
||||
`fr-FR` were translated, in parity, and offered by the picker — while all three catalogue files still
|
||||
imported only `en-GB` and `pt-PT`, so choosing one rendered the entire UI in raw keys. The parity tests
|
||||
could not have caught it: they compare JSON files to each other, and a file nobody imports is as
|
||||
parity-correct as one that is loaded. `lib/catalogues.test.ts` is now the thing that knows.
|
||||
- **Roles are a table with a foreign key, not a TypeScript union.** `USER_ROLES` is gone, `UserRole` is
|
||||
`string`, and the compile-time guard moves to a runtime check at every write path that takes a role from
|
||||
outside. Renaming a role now moves its holders and grants atomically, as Postgres's job.
|
||||
- **Minesweeper is a plugin** (`gitea.officer.dev/plugins/minesweeper`), not part of the platform. The
|
||||
permission registry drops from 22 entries to 21 — `execution` goes 4 to 3.
|
||||
- **Plugins can have websockets.** `/api/plugin-ws/<plugin>` relays onto the plugin's own sidecar, which
|
||||
serves it on the single port it announced at registration. Authorisation is the plugin's own permission,
|
||||
so there is no second grant to forget.
|
||||
- **A plugin can declare the architecture its packages need.** `multiarch: 'i386'` runs
|
||||
`dpkg --add-architecture` and `apt-get update` before installing, on apt hosts, skipped when already
|
||||
enabled. A closed enum rather than a command, because it runs as root.
|
||||
- **Foreign keys are named explicitly across the fleet's plugins**, ending the `db:push` churn where
|
||||
Postgres truncated an over-long generated name and drizzle-kit re-created the constraint every run.
|
||||
|
||||
## The schema change
|
||||
|
||||
Both foreign keys reference `roles.name`, so **every value in `users.role` and `role_permissions.role` must
|
||||
already exist as a row in `roles`**, or the constraint cannot be created.
|
||||
|
||||
**That was checked on every host before this tag was cut.** All five are on `deploy/2026.08.23.04`, which is
|
||||
the release that created and seeded `roles`; `alpha`, `fofos`, `production-pertento-bridge` and
|
||||
`deadpool-rezio-bridge` each report `roles = 4` and `0` orphans. **`macbookpro` was NOT verified** — it has
|
||||
no `psql` client, so only its tag could be read. Check it there before running the SQL, by whatever means
|
||||
that host offers.
|
||||
|
||||
Run this **first**, before `bun db:push`. It is idempotent and safe to run twice.
|
||||
|
||||
```sql
|
||||
-- Refuse to proceed if any role in use is not a real row. Raises rather than half-applying.
|
||||
DO $$
|
||||
DECLARE bad int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO bad FROM (
|
||||
SELECT u.role FROM users u LEFT JOIN roles r ON r.name = u.role WHERE r.name IS NULL
|
||||
UNION ALL
|
||||
SELECT p.role FROM role_permissions p LEFT JOIN roles r ON r.name = p.role WHERE r.name IS NULL
|
||||
) x;
|
||||
IF bad > 0 THEN
|
||||
RAISE EXCEPTION 'STOP: % role value(s) have no matching roles row. Do not continue; report this.', bad;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE role_permissions DROP CONSTRAINT IF EXISTS ck_role_permissions_role;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_users_role') THEN
|
||||
ALTER TABLE users ADD CONSTRAINT fk_users_role
|
||||
FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_role_permissions_role') THEN
|
||||
ALTER TABLE role_permissions ADD CONSTRAINT fk_role_permissions_role
|
||||
FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
`ck_role_permissions_not_owner` stays. It is a different constraint and this release does not touch it.
|
||||
|
||||
Then verify **by querying**, not by reading an exit code:
|
||||
|
||||
```sql
|
||||
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
|
||||
WHERE conname IN ('fk_users_role','fk_role_permissions_role',
|
||||
'ck_role_permissions_role','ck_role_permissions_not_owner');
|
||||
```
|
||||
|
||||
Both `fk_` rows must be present and say `ON UPDATE CASCADE` with no `ON DELETE`.
|
||||
`ck_role_permissions_role` must be **absent**. `ck_role_permissions_not_owner` must be **present**.
|
||||
Put that output in the report — for this release it is the evidence, and "schema ✓" without it is a guess.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.05`
|
||||
2. **The SQL above**, then the verification query. Stop on the `RAISE EXCEPTION` and report it.
|
||||
3. `bun db:push` — expect **no changes**. If it still wants to alter something after step 2, the SQL was
|
||||
incomplete; stop and report rather than letting it improvise.
|
||||
4. `bun run plugins:update`
|
||||
5. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 5. Step 4 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
The language picker in profile settings should offer **five** languages — English (UK), Português
|
||||
(Portugal), Español, Deutsch, Français. Pick one that is not English and look at a **panel**, not just the
|
||||
shell: the last release's defect was invisible in the shell and total in the panels.
|
||||
|
||||
A host still offering two languages did not pick up the frontend bundle.
|
||||
|
||||
Minesweeper should be **absent** from the dock and `/minesweeper` should 404. It is a plugin now and no host
|
||||
has it installed. That is correct, and installing it is not part of this release.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||
- **`macbookpro`** — the one host whose roles table could not be verified before the tag. Check it, then
|
||||
proceed; do not skip it on that account.
|
||||
- All five others were on `deploy/2026.08.23.04` when this was cut, so each is exactly one release behind.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
The schema change is already applied there, verified by query, and the second `db:push` emitted no DDL.
|
||||
Restarting `officer` on the host running the rollout ends the rollout. Name it as skipped and say it is
|
||||
current.
|
||||
@@ -271,6 +271,20 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
|
||||
|
||||
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
||||
- **Not everything a panel renders is content, and "panel plus file names" is the wrong test.** The
|
||||
guidance during phase 3 was to expect roughly half of FileBrowser's hits to be content and stay English.
|
||||
`useFileBrowserApp.ts`, the largest file in the phase at 47 hits, turned out **100% convertible**.
|
||||
|
||||
The reason is worth keeping. A panel that LISTS things renders content — a file name in a row is the
|
||||
user's, and translating it would be absurd. A panel REPORTING about those things owns the sentence:
|
||||
`Renamed to "{{name}}"` is ours, and the file name sits inside it as a parameter, which is exactly where
|
||||
content belongs. So the test is not "does this panel touch file names" but "is the string a frame around
|
||||
content, or the content itself".
|
||||
|
||||
It also matters for plurals. Six hand-rolled ones in that file became real entries because Portuguese
|
||||
reorders them — `1 item eliminado` against `Deleted 1 item` — which a frame with a count spliced into it
|
||||
cannot express at all.
|
||||
|
||||
- **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_
|
||||
|
||||
+11
-2
@@ -131,7 +131,7 @@ provisioning script that adds a user to a privileged group is granting more than
|
||||
|
||||
Additive only. Nothing reads it.
|
||||
|
||||
### Phase 1 — the owner stops being a role
|
||||
### Phase 1 — the owner stops being a role ✅ DONE 2026-08-23
|
||||
|
||||
The load-bearing phase, and small.
|
||||
|
||||
@@ -140,7 +140,16 @@ The load-bearing phase, and small.
|
||||
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
|
||||
"owner or Super Admin" is a separate, reviewable change per route.
|
||||
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
|
||||
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
|
||||
4. ~~Remove the `users` CHECK that pins user 1 to `Super Admin`~~ — **KEPT, overruling this plan.** It is
|
||||
redundant for the question it was written for: `isOwner` is an id comparison and never reads that column.
|
||||
But it still forbids user 1 being set to `Member` while remaining the owner, which is a state with no
|
||||
benefit and a confusing display. A constraint that costs nothing and forbids a nonsense state is worth
|
||||
keeping after its original reason has gone.
|
||||
|
||||
**One asymmetry introduced deliberately.** An existing account can now be promoted to `Super Admin`;
|
||||
creating one directly as `Super Admin` is still refused by `create-user.ts`. Promotion happens to an account
|
||||
somebody has looked at, from a screen showing who they are. Creation is a form filled in once, and the role
|
||||
that provisions sudo should not be a dropdown default reached by accident. Create, then promote.
|
||||
|
||||
Reviewable on its own, and nothing downstream can start until ownership is an identity.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.23.04",
|
||||
"version": "2026.08.23.05",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -34,8 +34,15 @@ const AREAS = [
|
||||
* '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.
|
||||
*
|
||||
* 'officerdev (panels)' landed 2026-08-23 (phase 3). It holds nine `new Error()` messages for whoever is
|
||||
* reading a terminal, not a user — same never-translate shape as 'shared components'. Two more strings
|
||||
* are marked `i18n-exempt` rather than never-translate: `useTaskRunner.ts`'s "Running:"/"Error:" framing
|
||||
* is interleaved with raw stdout/stderr inside one monospace `<pre>` feed, so translating the frame alone
|
||||
* would read as broken mid-buffer — the same reasoning as the never-translate list, reached by a
|
||||
* different mechanism because it's not a thrown Error.
|
||||
*/
|
||||
const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
|
||||
const ZEROED: string[] = ['shared components', 'officer-web (shell)', 'officerdev (panels)'];
|
||||
|
||||
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
||||
|
||||
@@ -52,7 +59,10 @@ const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
|
||||
*/
|
||||
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 },
|
||||
//
|
||||
// The lookbehind rejects `=>`, so an arrow return type does not read as JSX text: `(id: string) =>
|
||||
// Promise<unknown>` was reported as the string "Promise".
|
||||
{ kind: 'jsx text', rx: /(?<!=)>\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
|
||||
// placeholder="..." and friends
|
||||
{
|
||||
kind: 'attributes',
|
||||
@@ -71,9 +81,20 @@ const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
||||
),
|
||||
},
|
||||
// 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 },
|
||||
//
|
||||
// Matches a template literal as well as a quoted one: `new Error(\`Whisper returned ${res.status}\`)`
|
||||
// was falling through to `template str` and so counted as convertible, which is the opposite of true.
|
||||
{
|
||||
kind: 'thrown errors',
|
||||
rx: new RegExp(String.raw`new Error\(\s*(?:` + Q + String.raw`|\`([^\`]{2,120})\`)`, '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 },
|
||||
//
|
||||
// The lookbehind hands a thrown Error to the category above instead of counting it here as well: a
|
||||
// string matched by two patterns is counted twice, and it stayed convertible, so the area could never
|
||||
// reach zero however it was treated.
|
||||
{ kind: 'template str', rx: /(?<!new Error\()`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||
];
|
||||
|
||||
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
|
||||
@@ -86,6 +107,10 @@ function isHuman(value: string | undefined): value is string {
|
||||
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;
|
||||
// A namespaced storage or channel key: an ALL-CAPS prefix, then a separator, then an interpolation —
|
||||
// `FULLSCREEN_PANEL:${workspace.key}`. The SCREAMING_CONSTANT rule above misses it because the
|
||||
// interpolated half is lowercase.
|
||||
if (/^[A-Z0-9_]{3,}[.:/-]/.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.
|
||||
@@ -126,6 +151,21 @@ const stripComments = (source: string): string =>
|
||||
const stripCodeElements = (source: string): string =>
|
||||
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
|
||||
|
||||
/**
|
||||
* An explicit, per-line opt-out: `// i18n-exempt: <reason>` exempts the NEXT line.
|
||||
*
|
||||
* For the cases no syntactic rule can reach — chiefly a prompt written for a model rather than a
|
||||
* reader. `<Untranslated>` covers JSX; this covers the rest, and the two together mean the scanner does
|
||||
* not need another heuristic every time a new shape of never-translate string turns up.
|
||||
*
|
||||
* A reason is mandatory: the regex will not match a bare `// i18n-exempt`. That is the whole safeguard —
|
||||
* somebody has to write down why, and `grep -rn "i18n-exempt"` lists every one for review.
|
||||
*/
|
||||
const stripExemptLines = (source: string): string => {
|
||||
const lines = source.split('\n');
|
||||
return lines.map((line, i) => (/\/\/\s*i18n-exempt:\s*\S/.test(lines[i - 1] ?? '') ? '' : line)).join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* `<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.
|
||||
@@ -169,7 +209,8 @@ function scan(root: string): { files: number; hits: Hit[] } {
|
||||
files++;
|
||||
let source: string;
|
||||
try {
|
||||
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
|
||||
// Exempt lines go FIRST: stripComments would remove the marker they depend on.
|
||||
source = stripUntranslated(stripCodeElements(stripComments(stripExemptLines(readFileSync(file, 'utf8')))));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ 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 />} />
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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' }} />;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
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',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './MinesweeperScreen';
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { Untranslated } from '@/lib/Untranslated';
|
||||
import { Untranslated } from '@/components/Untranslated';
|
||||
|
||||
type GoogleOAuthSettings = {
|
||||
clientId: string;
|
||||
|
||||
@@ -22,5 +22,4 @@ export * from './Dashboards';
|
||||
export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './Minesweeper';
|
||||
export * from './SendOff';
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TranslationMap } from 'i18n';
|
||||
|
||||
import { I18nProvider, localeDirection } from 'i18n';
|
||||
import { panelCatalogs } from 'officerdev';
|
||||
import { uiCatalogs } from '@/components/locales';
|
||||
import { setNameRevision } from 'workspaces/react';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||
import appEnGB from '../locales/en-GB.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
|
||||
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] ?? {},
|
||||
},
|
||||
]),
|
||||
);
|
||||
import { catalogues as translations } from './catalogues';
|
||||
|
||||
type I18nBridgeProps = {
|
||||
children: ReactNode;
|
||||
@@ -40,6 +21,17 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
|
||||
document.documentElement.dir = localeDirection(locale);
|
||||
}, [locale]);
|
||||
|
||||
/*
|
||||
Panel names are thunks resolved during the framework's own render (AppRegistryEntry.name), and the
|
||||
framework is not subscribed to this provider — it has no i18n dependency at all, deliberately. So
|
||||
tell it that the words changed. It treats the value as opaque; that it happens to be a locale is
|
||||
this file's business, not the framework's.
|
||||
|
||||
Set during render rather than in an effect: an effect would leave the first paint after a locale
|
||||
switch showing the previous language's titles.
|
||||
*/
|
||||
setNameRevision(locale);
|
||||
|
||||
return (
|
||||
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { catalogues } from './catalogues';
|
||||
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||
|
||||
/**
|
||||
* A locale is only shipped if it is actually LOADED.
|
||||
*
|
||||
* This exists because es-ES, de-DE and fr-FR shipped dead. Every one of their 1,129 keys was translated and
|
||||
* in parity, all three were in `SHIPPED_LOCALES` so the picker offered them, and choosing one rendered the
|
||||
* entire UI in raw keys — because none of the three catalogue files had been given the `import`.
|
||||
*
|
||||
* The parity tests could not have caught it and never will: they compare JSON files to each other, and an
|
||||
* unimported file is as parity-correct as an imported one. The gap was between "the translation exists" and
|
||||
* "the app can reach it", and nothing was asking that question.
|
||||
*
|
||||
* Adding a locale touches four files. This is the one that knows whether all four happened.
|
||||
*/
|
||||
describe('every shipped locale is reachable', () => {
|
||||
const NAMESPACES = ['app', 'panel', 'ui'] as const;
|
||||
|
||||
test('SHIPPED_LOCALES is not empty and contains the fallback', () => {
|
||||
expect(SHIPPED_LOCALES.length).toBeGreaterThan(0);
|
||||
expect(SHIPPED_LOCALES.map((l) => l.code)).toContain(FALLBACK_LOCALE);
|
||||
});
|
||||
|
||||
for (const { code } of SHIPPED_LOCALES) {
|
||||
describe(code, () => {
|
||||
test('has a catalogue at all', () => {
|
||||
expect(catalogues[code]).toBeDefined();
|
||||
});
|
||||
|
||||
for (const ns of NAMESPACES) {
|
||||
// Non-empty, not merely present. The failure being pinned produced `{}` for all three, which is
|
||||
// exactly what `?? {}` yields for a locale nobody imported — present, and useless.
|
||||
test(`${ns}.* is populated`, () => {
|
||||
const catalogue = (catalogues[code] as Record<string, object> | undefined)?.[ns];
|
||||
expect(catalogue).toBeDefined();
|
||||
expect(Object.keys(catalogue ?? {}).length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Parity against the fallback is tested per-workspace already; this is the cheaper cross-check that
|
||||
// the MERGED catalogue is the same size, which catches a namespace wired to the wrong locale's file.
|
||||
test('is the same size as the fallback', () => {
|
||||
const count = (o: unknown): number =>
|
||||
typeof o === 'object' && o !== null ? Object.values(o).reduce<number>((n, v) => n + count(v), 0) : 1;
|
||||
expect(count(catalogues[code])).toBe(count(catalogues[FALLBACK_LOCALE]));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import { panelCatalogs } from 'officerdev';
|
||||
import { uiCatalogs } from '@/components/locales';
|
||||
import { SHIPPED_LOCALES } from './locales';
|
||||
import appDeDE from '../locales/de-DE.json';
|
||||
import appEnGB from '../locales/en-GB.json';
|
||||
import appEsES from '../locales/es-ES.json';
|
||||
import appFrFR from '../locales/fr-FR.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
|
||||
/**
|
||||
* The three per-workspace catalogues, merged into one map per shipped locale (D6): `app.*` from
|
||||
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
|
||||
*
|
||||
* ── Why this is a separate file from `I18nBridge` ──
|
||||
*
|
||||
* So it can be tested without mounting React, and it is tested because of a real failure. es-ES, de-DE and
|
||||
* fr-FR shipped translated, in perfect key parity, listed in the picker — and completely dead. All three
|
||||
* `import` lists still named only en-GB and pt-PT, so `catalogues[code]` was three empty objects and the
|
||||
* whole UI rendered untranslated in three of five languages.
|
||||
*
|
||||
* Nothing caught it. The parity tests compare the JSON FILES against each other and every file was correct;
|
||||
* a locale that is never imported is invisible to them by construction. `SHIPPED_LOCALES` is what the picker
|
||||
* reads, and it had been updated. Adding a locale was four edits in four files with no single place that
|
||||
* knew all four had happened.
|
||||
*
|
||||
* `catalogues.test.ts` is now that place: it asserts every shipped locale actually resolves to a populated
|
||||
* catalogue in all three namespaces.
|
||||
*/
|
||||
const appCatalogs: TranslationMap = {
|
||||
'en-GB': appEnGB,
|
||||
'pt-PT': appPtPT,
|
||||
'es-ES': appEsES,
|
||||
'de-DE': appDeDE,
|
||||
'fr-FR': appFrFR,
|
||||
};
|
||||
|
||||
export const catalogues: TranslationMap = Object.fromEntries(
|
||||
SHIPPED_LOCALES.map(({ code }) => [
|
||||
code,
|
||||
{
|
||||
app: appCatalogs[code] ?? {},
|
||||
panel: panelCatalogs[code] ?? {},
|
||||
ui: uiCatalogs[code] ?? {},
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -19,7 +19,7 @@ const autonym = (code: string): string => {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT'].map((code) => ({
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT', 'fr-FR', 'de-DE', 'es-ES'].map((code) => ({
|
||||
code,
|
||||
label: autonym(code),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "E-Mail",
|
||||
"username": "Benutzername",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Ihr Name",
|
||||
"updated": "Profil aktualisiert",
|
||||
"updateFailed": "Profil konnte nicht aktualisiert werden",
|
||||
"selectImage": "Bitte wählen Sie eine Bilddatei aus",
|
||||
"imageTooLarge": "Das Bild muss kleiner als 384 KB sein"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "Ein API-Schlüssel meldet Sie an, ohne Passwort und ohne Ablauf. Ein Schlüssel funktioniert überall — ein Schlüssel ist nicht an eine App gebunden. Jedem Gerät einen eigenen zu geben, dient nur dazu, einen abzuschalten, ohne die anderen anzutasten. Senden Sie ihn als",
|
||||
"loading": "Schlüssel werden geladen…",
|
||||
"mintedTitle": "Schlüssel für “{{name}}” — wird nur einmal angezeigt",
|
||||
"savedIt": "Ich habe ihn gespeichert",
|
||||
"copyKey": "Schlüssel kopieren",
|
||||
"copied": "Schlüssel kopiert",
|
||||
"copyFailed": "Kopieren nicht möglich — bitte manuell auswählen und kopieren",
|
||||
"newKey": "Neuer Schlüssel",
|
||||
"namePlaceholder": "iPhone, Musik-App, Laptop-CLI…",
|
||||
"create": "Erstellen",
|
||||
"createFailed": "Der Schlüssel konnte nicht erstellt werden",
|
||||
"noneYet": "Noch keine Schlüssel.",
|
||||
"noneInUse": "Keine Schlüssel in Verwendung.",
|
||||
"revoked": "widerrufen",
|
||||
"expired": "abgelaufen",
|
||||
"lastUsed": "zuletzt verwendet am {{date}}",
|
||||
"neverUsed": "nie verwendet",
|
||||
"forget": "Diesen Eintrag entfernen",
|
||||
"forgetFailed": "Entfernen nicht möglich",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeDone": "Widerrufen — alles, was diesen Schlüssel verwendet hat, wurde abgemeldet",
|
||||
"revokeFailed": "Widerrufen nicht möglich",
|
||||
"hideDead": "Widerrufene und abgelaufene ausblenden",
|
||||
"showDead": {
|
||||
"one": "{{count}} widerrufenen oder abgelaufenen Schlüssel anzeigen",
|
||||
"other": "{{count}} widerrufene oder abgelaufene Schlüssel anzeigen"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Stimme",
|
||||
"refresh": "Stimmen aktualisieren",
|
||||
"serverDefault": "Server-Standard",
|
||||
"hint": "Wählen Sie eine Stimme für die Sprachausgabe (Text-to-Speech). Belassen Sie es beim Server-Standard, um die vom Administrator konfigurierte Stimme zu verwenden.",
|
||||
"stop": "Stopp",
|
||||
"listen": "Anhören"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Sichtbar",
|
||||
"hidden": "Ausgeblendet",
|
||||
"dragToShow": "Elemente hierher ziehen, um sie im Dock anzuzeigen",
|
||||
"allVisible": "Alle Elemente sichtbar",
|
||||
"reset": "Auf Standard zurücksetzen"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Sprachen, die Sie sprechen",
|
||||
"addLanguage": "Sprache hinzufügen...",
|
||||
"defaultLabel": "Standardsprache",
|
||||
"defaultHint": "Wird für die Lokalisierung der Oberfläche verwendet.",
|
||||
"translateToLabel": "Übersetzen nach",
|
||||
"translateToHint": "Zielsprache beim Übersetzen von Inhalten, die Sie nicht sprechen."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Aktuelles Passwort",
|
||||
"currentPasswordPlaceholder": "Aktuelles Passwort",
|
||||
"newPasswordLabel": "Neues Passwort",
|
||||
"newPasswordPlaceholder": "Neues Passwort",
|
||||
"confirmPasswordLabel": "Neues Passwort bestätigen",
|
||||
"confirmPasswordPlaceholder": "Neues Passwort bestätigen",
|
||||
"changeButton": "Passwort ändern",
|
||||
"changingButton": "Wird geändert...",
|
||||
"passwordChanged": "Passwort geändert",
|
||||
"passwordChangeFailed": "Passwort konnte nicht geändert werden"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Standardmodell",
|
||||
"sameAsChat": "Wie Chat-Standard",
|
||||
"taskDefaultsSaved": "Aufgaben-Standardeinstellungen gespeichert",
|
||||
"saveFailed": "Einstellungen konnten nicht gespeichert werden",
|
||||
"defaultChat": "Standard-Chat-Modell",
|
||||
"defaultChatHint": "Wird beim Starten eines neuen Chats vom Startbildschirm verwendet",
|
||||
"defaultProject": "Standard-Projektmodell",
|
||||
"defaultProjectHint": "Wird beim Starten eines neuen Chats innerhalb eines Projekt-Dashboards verwendet",
|
||||
"defaultTask": "Standard-Aufgabenmodell",
|
||||
"defaultTaskHint": "Wird beim Ausführen von Aufgaben aus dem Datei-Browser verwendet",
|
||||
"aiDefaultsSaved": "KI-Modell-Standardeinstellungen gespeichert"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profil",
|
||||
"hint": "Avatar, Name, E-Mail",
|
||||
"description": "Avatar, Name und E-Mail"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Passwort ändern",
|
||||
"description": "Aktualisieren Sie Ihr Passwort"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "API-Schlüssel",
|
||||
"hint": "Schlüssel, die sich als Sie anmelden",
|
||||
"description": "Schlüssel, die sich als Sie anmelden, einzeln widerrufbar"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Sprachen",
|
||||
"hint": "gesprochen und Übersetzung",
|
||||
"description": "Gesprochen, Standard und Übersetzung"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Stimme",
|
||||
"hint": "Text-zu-Sprache",
|
||||
"description": "Einstellung für die Sprachausgabe (Text-to-Speech)"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "auswählen und anordnen",
|
||||
"description": "Dock-Elemente auswählen und anordnen"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Meine Modelle",
|
||||
"description": "Modelle für sich selbst anzeigen oder ausblenden"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Kanalmodelle",
|
||||
"description": "Modelle, die für Konten ohne Eigentümerrechte erreichbar sind"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Standardmodelle",
|
||||
"description": "Standardmodelle für Chat, Projekte und Aufgaben"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Anbieter",
|
||||
"hint": "Harnesses",
|
||||
"description": "Entfernte und lokale KI-Anbieter"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Text-zu-Sprache",
|
||||
"description": "TTS-Anbieter und Stimme"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Sprache-zu-Text",
|
||||
"description": "Whisper-Server-URL"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Vision-Modell für Textextraktion"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Neu erstellen & neu starten",
|
||||
"hint": "Wartung",
|
||||
"description": "Änderungen laden, ohne ein Terminal zu benötigen"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "ausgehende E-Mail",
|
||||
"description": "Anbieter für E-Mail-Zustellung"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Client-ID und Secret für Google-APIs"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "API-Token für Web-Scraping-Actors"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Kalender- und Kontaktabgleich",
|
||||
"hint": "App-Passwörter",
|
||||
"description": "Geräteweise Passwörter für CalDAV-/CardDAV-Clients"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Konten",
|
||||
"hint": "Benutzer",
|
||||
"description": "Wer Zugriff hat, und worauf"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Berechtigungen",
|
||||
"description": "Was jede Rolle erreichen kann"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Einstellungen durchsuchen…",
|
||||
"selectSection": "Wählen Sie einen Bereich aus",
|
||||
"groups": {
|
||||
"models": "Modelle",
|
||||
"defaults": "Standardwerte",
|
||||
"providers": "Anbieter",
|
||||
"ai": "KI",
|
||||
"maintenance": "Wartung",
|
||||
"email": "E-Mail"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profil",
|
||||
"ai": "KI",
|
||||
"system": "System",
|
||||
"integrations": "Integrationen",
|
||||
"users": "Benutzer"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "Keine Modelle verfügbar. Konfigurieren Sie API-Schlüssel unter Anbieter.",
|
||||
"dragToShow": "Modelle hierher ziehen, um sie anzuzeigen",
|
||||
"allVisible": "Alle Modelle sichtbar",
|
||||
"dragToEnable": "Modelle hierher ziehen, um sie zu aktivieren",
|
||||
"allEnabled": "Alle Modelle aktiviert",
|
||||
"visible": "Sichtbar",
|
||||
"hidden": "Ausgeblendet",
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert",
|
||||
"refresh": "Modelle von Anbietern aktualisieren",
|
||||
"refreshed": "Modelle aktualisiert",
|
||||
"refreshFailed": "Modelle konnten nicht aktualisiert werden"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Unternehmen",
|
||||
"personal": "Persönlich"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Wird geladen…",
|
||||
"provider": "Anbieter",
|
||||
"model": "Modell",
|
||||
"voice": "Stimme",
|
||||
"ocr": {
|
||||
"serverUrl": "Server-URL des Vision-Modells",
|
||||
"refreshModels": "Modelle aktualisieren",
|
||||
"selectModel": "Wählen Sie ein Modell aus",
|
||||
"saved": "OCR-Einstellungen gespeichert",
|
||||
"saveFailed": "OCR-Einstellungen konnten nicht gespeichert werden",
|
||||
"reachable": "Vision-Modell-Server ist erreichbar"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "Whisper-Server-URL",
|
||||
"saved": "STT-Einstellungen gespeichert",
|
||||
"saveFailed": "STT-Einstellungen konnten nicht gespeichert werden",
|
||||
"reachable": "Whisper-Server ist erreichbar"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "OpenAI-kompatibel (Kokoro, OpenAI usw.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Stimmen aktualisieren",
|
||||
"selectVoice": "Wählen Sie eine Stimme aus",
|
||||
"saved": "TTS-Einstellungen gespeichert",
|
||||
"saveFailed": "TTS-Einstellungen konnten nicht gespeichert werden",
|
||||
"playing": "Testaudio wird abgespielt",
|
||||
"testFailed": "Test fehlgeschlagen — TTS-Server nicht erreichbar"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (lokaler Test)",
|
||||
"resend": "Resend",
|
||||
"custom": "Benutzerdefiniertes SMTP",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"tls": "TLS / SSL",
|
||||
"fromName": "Absendername",
|
||||
"fromEmail": "Absender-E-Mail",
|
||||
"sendTest": "Test-E-Mail senden",
|
||||
"userExample": "benutzer@beispiel.de",
|
||||
"recipientExample": "empfänger@beispiel.de",
|
||||
"saved": "SMTP-Einstellungen gespeichert",
|
||||
"saveFailed": "SMTP-Einstellungen konnten nicht gespeichert werden",
|
||||
"connected": "Verbindung erfolgreich",
|
||||
"testSent": "Test-E-Mail an {{email}} gesendet"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Plattform aktualisieren",
|
||||
"updateHint": "Ändert nichts, was gerade läuft. Was das Update benötigt, ist hier aufgeführt, und die Entscheidung liegt bei Ihnen.",
|
||||
"rebuild": "Frontend neu erstellen",
|
||||
"restart": "Server neu starten",
|
||||
"upToDate": "Bereits aktuell",
|
||||
"rebuilt": "Frontend neu erstellt — laden Sie diese Seite neu, um es zu sehen",
|
||||
"restarting": "Wird neu gestartet — die Seite verbindet sich von selbst wieder",
|
||||
"pulled": {
|
||||
"one": "{{count}} Commit abgerufen",
|
||||
"other": "{{count}} Commits abgerufen"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Wird geprüft…",
|
||||
"localProviders": "Lokale Anbieter",
|
||||
"remoteProviders": "Entfernte Anbieter",
|
||||
"connect": "Verbinden",
|
||||
"authenticate": "Authentifizieren",
|
||||
"connectLocal": "Lokalen Anbieter verbinden",
|
||||
"connectProvider": "Anbieter verbinden",
|
||||
"none": "Keine Anbieter gefunden.",
|
||||
"updatePiMono": "Pi Mono aktualisieren",
|
||||
"removeProvider": "Anbieter entfernen",
|
||||
"namePlaceholder": "Name (z. B. Mein Ollama)",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"editKey": "Schlüssel bearbeiten",
|
||||
"disconnect": "Anbieter trennen",
|
||||
"saveKey": "Schlüssel speichern",
|
||||
"enterKey": "API-Schlüssel eingeben",
|
||||
"search": "Anbieter durchsuchen…",
|
||||
"connectFailed": "Verbindung fehlgeschlagen",
|
||||
"authFailed": "Authentifizierung fehlgeschlagen",
|
||||
"saveFailed": "Anbieter konnte nicht gespeichert werden",
|
||||
"connectedTo": "Mit {{name}} verbunden"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Berechtigungen werden geladen…",
|
||||
"permissionsFailed": "Berechtigungen konnten nicht geladen werden.",
|
||||
"deniedUnlessGranted": "Alles ist verweigert, sofern es hier nicht gewährt wird.",
|
||||
"read": "Lesen",
|
||||
"full": "Vollständig",
|
||||
"noAccess": "Kein Zugriff",
|
||||
"role": "Rolle",
|
||||
"permissionsSaved": "Gespeichert, was {{role}}s erreichen können",
|
||||
"copyBeforeClosing": "Kopieren Sie, was Sie brauchen, bevor Sie schließen — nichts davon kann erneut angezeigt werden.",
|
||||
"password": "Passwort",
|
||||
"linuxAccount": "Linux-Konto",
|
||||
"sshKey": "Ihr öffentlicher SSH-Schlüssel",
|
||||
"addToGitea": "Fügen Sie dies zu ihrem Gitea-Konto hinzu",
|
||||
"linuxIncomplete": "Das Konto funktioniert, aber die Linux-Seite wurde nicht fertiggestellt",
|
||||
"done": "Fertig",
|
||||
"addAccount": "Konto hinzufügen",
|
||||
"newAccount": "Neues Konto",
|
||||
"email": "E-Mail",
|
||||
"name": "Name",
|
||||
"username": "Benutzername",
|
||||
"usernameHint": "Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche.",
|
||||
"generatePassword": "Ein Passwort generieren",
|
||||
"copyPassword": "Passwort kopieren",
|
||||
"passwordHint": "Mindestens 12 Zeichen, mit Groß- und Kleinbuchstaben, einer Zahl und einem Symbol.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@laptop",
|
||||
"passwordCopied": "Passwort kopiert",
|
||||
"loadingAccounts": "Konten werden geladen…",
|
||||
"accountsFailed": "Konten konnten nicht geladen werden.",
|
||||
"removeAccount": "Konto entfernen",
|
||||
"generateNewPassword": "Ein neues Passwort generieren",
|
||||
"serverOwner": "Servereigentümer",
|
||||
"copySshTitle": "Ihren öffentlichen SSH-Schlüssel kopieren (fügen Sie ihn zu ihrem Gitea-Konto hinzu)",
|
||||
"resetPasswordTitle": "Ein neues Passwort generieren — wird nur einmal angezeigt und meldet sie überall ab",
|
||||
"copyNewPassword": "Das neue Passwort kopieren",
|
||||
"publicKeyCopied": "Öffentlicher Schlüssel kopiert",
|
||||
"copyFailed": "Kopieren nicht möglich — bitte manuell auswählen und kopieren",
|
||||
"repairFor": "Linux-Konto von {{email}} reparieren",
|
||||
"linuxAccountFor": "Linux-Konto: {{osUser}} — klicken, um es zu reparieren oder den SSH-Schlüssel zu ersetzen",
|
||||
"copySshFor": "Öffentlichen SSH-Schlüssel von {{email}} kopieren",
|
||||
"resetPasswordFor": "Passwort von {{email}} zurücksetzen",
|
||||
"removeFor": "{{email}} entfernen",
|
||||
"provisionPrompt": "Linux-Konto für {{email}}.\n\nFügen Sie einen öffentlichen SSH-Schlüssel ein, um SSH-Zugriff zu erlauben, oder lassen Sie das Feld leer, um den aktuellen zu behalten.",
|
||||
"createLinux": "Ein Linux-Konto erstellen",
|
||||
"noLinux": "Kein Linux-Konto — klicken, um eines zu erstellen",
|
||||
"removeTitle": "{{email}} entfernen?",
|
||||
"removeBody": "Dies löscht das Konto und alles, was dazugehört — Passkeys, Dashboards, gespeicherte Layouts und alles, was ein installiertes Plugin für dieses Konto speichert. Dies kann nicht rückgängig gemacht werden.",
|
||||
"resetTitle": "Passwort für {{email}} zurücksetzen?",
|
||||
"resetBody": "Ein neues Passwort wird generiert und Ihnen einmalig angezeigt — es wird nirgendwo gespeichert und kann danach nicht mehr abgerufen werden. Das bisherige Passwort funktioniert sofort nicht mehr, und die Person wird überall abgemeldet.",
|
||||
"resetLinuxNote": "Das Linux-Konto ({{osUser}}) ist davon nicht betroffen: Es hat kein Passwort, und die SSH-Schlüssel bleiben unverändert."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Neues Gerät",
|
||||
"create": "Erstellen",
|
||||
"none": "Noch keine Geräte.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Widerrufen",
|
||||
"delete": "Löschen",
|
||||
"copyFailed": "Kopieren nicht möglich — bitte manuell auswählen und kopieren",
|
||||
"createFailed": "Das App-Passwort konnte nicht erstellt werden",
|
||||
"revoked": "Widerrufen — dieses Gerät synchronisiert nicht mehr",
|
||||
"revokeFailed": "Widerrufen nicht möglich",
|
||||
"deleteFailed": "Löschen nicht möglich",
|
||||
"copyField": "{{field}} kopieren",
|
||||
"copied": "{{what}} kopiert"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Schritt-für-Schritt-Anleitung",
|
||||
"step1": {
|
||||
"title": "Ein Google-Cloud-Projekt erstellen",
|
||||
"body": "Öffnen Sie die Seite zur Projekterstellung, geben Sie ihm einen Namen (zum Beispiel “Officer”) und klicken Sie auf:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Die APIs aktivieren",
|
||||
"body": "Öffnen Sie die API-Bibliothek, suchen Sie nach jeder dieser APIs und klicken Sie auf:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Den OAuth-Zustimmungsbildschirm konfigurieren",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Legen Sie den Namen Ihrer Organisation oder “Officer” fest als:",
|
||||
"supportEmail": "Legen Sie die E-Mail-Adresse Ihres Administrators fest als:",
|
||||
"devContact": "Fügen Sie die E-Mail-Adresse Ihres Administrators hinzu unter:",
|
||||
"save": "Klicken Sie dann auf:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Die Zielgruppe festlegen",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Wenn Ihr Team Google Workspace verwendet, wählen Sie dies — keine Überprüfung nötig:",
|
||||
"external": "Andernfalls wählen Sie dies und fügen Sie die E-Mail-Adressen Ihres Teams unter Test users hinzu (erforderlich, solange die App nicht verifiziert ist; Grenze von 100 Testnutzern):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Bereiche hinzufügen",
|
||||
"link": "Data Access",
|
||||
"body": "Öffnen Sie den Datenzugriff, suchen Sie die unten stehenden Bereiche, fügen Sie sie hinzu und klicken Sie dann auf:",
|
||||
"note": "Google stuft den Kalenderbereich als sensibel und den Gmail-Bereich als eingeschränkt ein. Das ist unproblematisch für Internal-Apps (Google Workspace) und für External-Apps im Testmodus; die Veröffentlichung in Produktion mit eingeschränkten Bereichen erfordert eine Google-Verifizierung."
|
||||
},
|
||||
"step6": {
|
||||
"title": "OAuth-Anmeldedaten erstellen",
|
||||
"link": "Clients",
|
||||
"body": "Öffnen Sie die Clients-Seite und klicken Sie auf:",
|
||||
"appType": "Anwendungstyp:",
|
||||
"nameAnything": "Name: beliebig (zum Beispiel “Officer”)",
|
||||
"redirectUris": "Autorisierte Weiterleitungs-URIs — hinzufügen:",
|
||||
"thenClick": "Klicken Sie dann auf:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Die Anmeldedaten kopieren",
|
||||
"body": "Ein Dialogfeld zeigt Ihre Client-ID und Ihr Client-Secret. Kopieren Sie beide und fügen Sie sie in die untenstehenden Felder ein."
|
||||
},
|
||||
"scopeGmail": "E-Mails lesen",
|
||||
"scopeCalendar": "Kalenderereignisse lesen",
|
||||
"verifying": "Anmeldedaten werden überprüft…",
|
||||
"valid": "Anmeldedaten gültig",
|
||||
"invalid": "Ungültige Anmeldedaten",
|
||||
"verifyFailed": "Überprüfungsanfrage fehlgeschlagen",
|
||||
"clientId": "Client-ID",
|
||||
"clientSecret": "Client-Secret",
|
||||
"redirectUri": "Weiterleitungs-URI",
|
||||
"redirectUriHint": "Fügen Sie diese URI zu den autorisierten Weiterleitungs-URIs Ihres Google-OAuth-Clients hinzu",
|
||||
"saved": "Google-OAuth-Konfiguration gespeichert",
|
||||
"saveFailed": "Google-OAuth-Konfiguration konnte nicht gespeichert werden"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Google-Konto",
|
||||
"hint": "Verbinden Sie Ihr Google-Konto für Kalender, Kontakte und andere Google-Dienste.",
|
||||
"connected": "Verbunden",
|
||||
"disconnect": "Trennen",
|
||||
"connect": "Google-Konto verbinden",
|
||||
"connectedToast": "Google-Konto verbunden",
|
||||
"connectFailed": "Google-Konto konnte nicht verbunden werden",
|
||||
"disconnectedToast": "Google-Konto getrennt",
|
||||
"disconnectFailed": "Google-Konto konnte nicht getrennt werden"
|
||||
},
|
||||
"apify": {
|
||||
"token": "API-Token",
|
||||
"saved": "Apify-API-Token gespeichert",
|
||||
"saveFailed": "Apify-API-Token konnte nicht gespeichert werden"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profil",
|
||||
"systemSettings": "Systemeinstellungen",
|
||||
"signOut": "Abmelden",
|
||||
"ai": "KI",
|
||||
"integrations": "Integrationen",
|
||||
"userManagement": "Benutzerverwaltung"
|
||||
},
|
||||
"tabName": "Tab-Name",
|
||||
"tabNameHint": "Klicken, um diesen Tab umzubenennen — leeren, um zum Seitennamen zurückzukehren",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navigation",
|
||||
"jobsRunning": "Job läuft",
|
||||
"jobsNothingRunning": "Nichts läuft",
|
||||
"jobsQueued": "Jobs in Warteschlange"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Start",
|
||||
"files": "Dateien",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Browser",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Aktivität",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Kalender",
|
||||
"contacts": "Kontakte",
|
||||
"appStore": "App Store",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Code-Editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Design: {{name}} · Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen · Strg+Umschalt+F {{starAction}}",
|
||||
"pillLabel": "Design: {{name}}",
|
||||
"star": "markieren",
|
||||
"unstar": "Markierung aufheben",
|
||||
"cycleHint": "Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen",
|
||||
"everyShortcutHint": "Strg+? für alle Tastenkombinationen",
|
||||
"favouritesOnly": "Nur Favoriten ({{count}})",
|
||||
"favourites": "Favoriten ({{count}})",
|
||||
"noFavourites": "Noch keine Favoriten — markieren Sie einen mit der Schaltfläche in seiner Zeile.",
|
||||
"starNamed": "{{name}} markieren",
|
||||
"unstarNamed": "Markierung von {{name}} aufheben",
|
||||
"addToFavourites": "Zu Favoriten hinzufügen",
|
||||
"removeFromFavourites": "Aus Favoriten entfernen",
|
||||
"addToFavouritesKey": "Zu Favoriten hinzufügen (f)",
|
||||
"removeFromFavouritesKey": "Aus Favoriten entfernen (f)",
|
||||
"active": "aktiv",
|
||||
"browseHint": "j/k oder ↑/↓ bewegen · l oder → Vorschau · f markieren · h oder ← zurücksetzen · Eingabetaste behalten · Esc abbrechen"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Gehe zu…",
|
||||
"noResults": "Keine Treffer für “{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Tastenkombinationen",
|
||||
"hint": "Strg+? · Esc zum Schließen"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Dieser Bildschirm konnte nicht angezeigt werden",
|
||||
"description": "Nichts ist verloren gegangen — das ist ein Anzeigefehler, kein Datenfehler. Agenten-Transkripte, Jobs und Dateien sind unberührt.",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"reload": "Neu laden",
|
||||
"goHome": "Zum Start",
|
||||
"resetHint": "Wenn es an derselben Stelle erneut fehlschlägt, ist meist ein gespeichertes Panel-Layout die Ursache. Zurücksetzen bringt die Panels aller Bildschirme auf ihre Standardwerte zurück.",
|
||||
"dashboardsSafe": "Ihre Dashboards sind davon nicht betroffen.",
|
||||
"resetting": "Wird zurückgesetzt…",
|
||||
"resetConfirm": "Ja — Layout aller Bildschirme zurücksetzen",
|
||||
"reset": "Bildschirm-Layouts zurücksetzen"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Elemente erneut scannen",
|
||||
"title": "officer-items erneut scannen (Änderungen übernehmen, ohne neu zu starten)",
|
||||
"failed": "Elemente konnten nicht erneut gescannt werden",
|
||||
"done": "Elemente erneut gescannt — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} Aufgabe",
|
||||
"other": "{{count}} Aufgaben"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} Agent",
|
||||
"other": "{{count}} Agenten"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} Werkzeug",
|
||||
"other": "{{count}} Werkzeuge"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} Skill",
|
||||
"other": "{{count}} Skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} Prozess",
|
||||
"other": "{{count}} Prozesse"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} Affordance",
|
||||
"other": "{{count}} Affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Gilt für neue Chats — ein bereits geöffneter behält, was er beim Start hatte.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} Skill hat keine Beschreibung, daher wird sie keinem Agenten angeboten.",
|
||||
"other": "{{count}} Skills haben keine Beschreibung, daher werden sie keinem Agenten angeboten."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Fehler melden",
|
||||
"title": "Fehler melden",
|
||||
"describePlaceholder": "Beschreiben Sie, was schiefgelaufen ist...",
|
||||
"screenshot": "Screenshot",
|
||||
"noScreenshot": "Kein Screenshot aufgenommen",
|
||||
"collectedNote": "Konsolenprotokolle, Browserinformationen und aktuelle API-Fehler werden automatisch erfasst.",
|
||||
"submitted": "Fehlerbericht gesendet",
|
||||
"failed": "Fehlerbericht konnte nicht gesendet werden",
|
||||
"submit": "Senden"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Willkommen zurück",
|
||||
"signInSubtitle": "Melden Sie sich bei Ihrem Konto an",
|
||||
"emailOrUsername": "E-Mail oder Benutzername",
|
||||
"password": "Passwort",
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"emailPlaceholder": "name@beispiel.de",
|
||||
"passwordPlaceholder": "Ihr Passwort",
|
||||
"forgotTitle": "Passwort vergessen",
|
||||
"forgotSubtitle": "Geben Sie Ihre E-Mail-Adresse ein, um einen Link zum Zurücksetzen zu erhalten",
|
||||
"email": "E-Mail",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"checkEmail": "Prüfen Sie Ihre E-Mails",
|
||||
"checkEmailHint": "Klicken Sie darauf, um Ihr Passwort zurückzusetzen.",
|
||||
"resetOk": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich an.",
|
||||
"verifying": "Wird überprüft…",
|
||||
"invalidLink": "Ungültiger Link",
|
||||
"invalidLinkHint": "Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen.",
|
||||
"resetTitle": "Passwort zurücksetzen",
|
||||
"resetSubtitle": "Geben Sie Ihr neues Passwort ein",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"minChars": "Mindestens 12 Zeichen",
|
||||
"confirmPlaceholder": "Bestätigen Sie Ihr Passwort",
|
||||
"bootstrapTitle": "Willkommen, Administrator",
|
||||
"bootstrapSubtitle": "Erstellen Sie Ihr Administratorkonto",
|
||||
"name": "Name",
|
||||
"username": "Benutzername",
|
||||
"namePlaceholder": "Ihr Name",
|
||||
"adminEmailPlaceholder": "admin@beispiel.de",
|
||||
"bootstrapOk": "Administratorkonto erstellt. Bitte melden Sie sich an.",
|
||||
"debug": {
|
||||
"controls": "Steuerung:",
|
||||
"camera": "Kamera:",
|
||||
"model": "Modell:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navigation",
|
||||
"spotlight": "Spotlight — zu jedem Bildschirm, Plugin oder jeder Einstellung per Namen springen",
|
||||
"thisList": "Diese Liste",
|
||||
"themes": "Designs",
|
||||
"themesNote": "Der Browser wendet die Änderung an, während Sie sich bewegen, sodass Sie sehen, wie sich die App verändert, statt Namen zu lesen.",
|
||||
"openBrowser": "Den Design-Browser öffnen",
|
||||
"prevNextTheme": "Vorheriges / nächstes Design, ohne etwas zu öffnen",
|
||||
"starCurrent": "Das aktuelle Design von überall markieren oder die Markierung aufheben",
|
||||
"inBrowser": "Im Design-Browser",
|
||||
"moveList": "Durch die Liste bewegen",
|
||||
"applyKeepBrowsing": "Das fokussierte Design anwenden und weiter durchsuchen",
|
||||
"restoreStart": "Das Design wiederherstellen, mit dem Sie begonnen haben",
|
||||
"starFocused": "Das fokussierte Design markieren oder die Markierung aufheben",
|
||||
"keepLooking": "Behalten, was Sie sich gerade ansehen",
|
||||
"cancelRestore": "Abbrechen und wiederherstellen",
|
||||
"inSpotlight": "In Spotlight",
|
||||
"moveResults": "Durch die Ergebnisse bewegen",
|
||||
"goThere": "Dorthin gehen",
|
||||
"close": "Schließen",
|
||||
"chat": "Chat",
|
||||
"chatNote": "Der Verlaufsabruf reagiert nur bei LEEREM Eingabefeld auf die Pfeiltaste, und jede andere Taste beendet ihn — so wird ein Entwurf nie überschrieben.",
|
||||
"prevNextPrompt": "Vorheriger / nächster gesendeter Prompt, wie in einer Shell",
|
||||
"send": "Senden · bei leerem Eingabefeld mit etwas in der Warteschlange wird das jetzt gesendet",
|
||||
"newline": "Zeilenumbruch",
|
||||
"stopTurn": "Den gerade laufenden Turn stoppen"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "In Ausführung und in Warteschlange",
|
||||
"loading": "Wird geladen…",
|
||||
"nothingRunning": "Nichts läuft",
|
||||
"history": "Verlauf",
|
||||
"clearAll": "Alles löschen",
|
||||
"selectJob": "Wählen Sie einen Job aus",
|
||||
"notFound": "Job nicht gefunden.",
|
||||
"backToJobs": "Zurück zu den Jobs",
|
||||
"stop": "Stopp",
|
||||
"testNotification": "Eine Testbenachrichtigung an alle konfigurierten Kanäle senden",
|
||||
"search": "Suchen…",
|
||||
"deleteFinished": "Alle abgeschlossenen Jobs löschen",
|
||||
"noChannels": "Keine Kanäle konfiguriert",
|
||||
"notifyFailed": "Benachrichtigung konnte nicht gesendet werden",
|
||||
"stopRequested": "Stopp angefordert",
|
||||
"stopFailed": "Stopp fehlgeschlagen",
|
||||
"titles": "Titel",
|
||||
"download": "Herunterladen",
|
||||
"input": "Eingabe",
|
||||
"output": "Ausgabe",
|
||||
"steps": "Schritte",
|
||||
"selectStep": "Wählen Sie einen Schritt oder eine Unteraufgabe, um die Ausgabe anzuzeigen",
|
||||
"noOutput": "Noch keine Ausgabe",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Kosten:",
|
||||
"status": {
|
||||
"running": "Läuft",
|
||||
"queued": "In Warteschlange",
|
||||
"completed": "Abgeschlossen",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"stopped": "Gestoppt",
|
||||
"interrupted": "Unterbrochen"
|
||||
},
|
||||
"failedWithCode": "Fehlgeschlagen (Exit {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivität",
|
||||
"backgroundTasks": "Hintergrundaufgaben",
|
||||
"detached": "Abgekoppelt",
|
||||
"backToList": "Zurück zur Liste",
|
||||
"selectTask": "Wählen Sie eine Aufgabe aus, um ihre Live-Ausgabe zu verfolgen",
|
||||
"noneRunning": "keine läuft"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Richten Sie Ihren Passkey ein"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Diese Unterhaltung konnte nicht geladen werden: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Keine Treffer",
|
||||
"emptyFile": "Leere Datei",
|
||||
"createFailed": "{{kind}} konnte nicht erstellt werden",
|
||||
"deleteFailed": "{{kind}} konnte nicht gelöscht werden",
|
||||
"searchPlaceholder": "{{kind}} durchsuchen…",
|
||||
"selectOne": "{{kind}} auswählen",
|
||||
"selectToView": "{{kind}} auswählen, um den Inhalt anzuzeigen",
|
||||
"delete": "Löschen",
|
||||
"createPrompt": "Hilf mir, den Inhalt für diese neue {{kind}}-Datei zu erstellen",
|
||||
"improvePrompt": "Hilf mir, diese {{kind}}-Datei zu verstehen und zu verbessern"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando...",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "Correo electrónico",
|
||||
"username": "Nombre de usuario",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "Tu nombre",
|
||||
"updated": "Perfil actualizado",
|
||||
"updateFailed": "No se pudo actualizar el perfil",
|
||||
"selectImage": "Selecciona un archivo de imagen",
|
||||
"imageTooLarge": "La imagen debe ser menor de 384 KB"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "Una clave de API inicia sesión como tú, sin contraseña y sin caducar. Una clave funciona en todas partes — una clave no está ligada a una aplicación. Dar a cada dispositivo la suya solo sirve para poder cortar una sin tocar las demás. Envíala como",
|
||||
"loading": "Cargando claves…",
|
||||
"mintedTitle": "Clave para “{{name}}” — se muestra una sola vez",
|
||||
"savedIt": "Ya la he guardado",
|
||||
"copyKey": "Copiar clave",
|
||||
"copied": "Clave copiada",
|
||||
"copyFailed": "No se pudo copiar — selecciona y copia manualmente",
|
||||
"newKey": "Nueva clave",
|
||||
"namePlaceholder": "iPhone, app de Música, CLI del portátil…",
|
||||
"create": "Crear",
|
||||
"createFailed": "No se pudo crear la clave",
|
||||
"noneYet": "Todavía no hay claves.",
|
||||
"noneInUse": "No hay claves en uso.",
|
||||
"revoked": "revocada",
|
||||
"expired": "caducada",
|
||||
"lastUsed": "usada por última vez el {{date}}",
|
||||
"neverUsed": "nunca usada",
|
||||
"forget": "Eliminar este registro",
|
||||
"forgetFailed": "No se pudo eliminar",
|
||||
"revoke": "Revocar",
|
||||
"revokeDone": "Revocada — todo lo que usaba esa clave se ha desconectado",
|
||||
"revokeFailed": "No se pudo revocar",
|
||||
"hideDead": "Ocultar revocadas y caducadas",
|
||||
"showDead": {
|
||||
"one": "Mostrar {{count}} clave revocada o caducada",
|
||||
"other": "Mostrar {{count}} claves revocadas o caducadas"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voz",
|
||||
"refresh": "Actualizar voces",
|
||||
"serverDefault": "Predeterminada del servidor",
|
||||
"hint": "Elige una voz para la conversión de texto a voz. Déjala en la predeterminada del servidor para usar la voz configurada por el administrador.",
|
||||
"stop": "Detener",
|
||||
"listen": "Escuchar"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Visibles",
|
||||
"hidden": "Ocultos",
|
||||
"dragToShow": "Arrastra elementos aquí para mostrarlos en el dock",
|
||||
"allVisible": "Todos los elementos visibles",
|
||||
"reset": "Restaurar valores predeterminados"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Idiomas que hablas",
|
||||
"addLanguage": "Añadir un idioma...",
|
||||
"defaultLabel": "Idioma predeterminado",
|
||||
"defaultHint": "Se usa para la localización de la interfaz.",
|
||||
"translateToLabel": "Traducir a",
|
||||
"translateToHint": "Idioma de destino al traducir contenido que no hablas."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Contraseña actual",
|
||||
"currentPasswordPlaceholder": "Contraseña actual",
|
||||
"newPasswordLabel": "Nueva contraseña",
|
||||
"newPasswordPlaceholder": "Nueva contraseña",
|
||||
"confirmPasswordLabel": "Confirmar nueva contraseña",
|
||||
"confirmPasswordPlaceholder": "Confirma la nueva contraseña",
|
||||
"changeButton": "Cambiar contraseña",
|
||||
"changingButton": "Cambiando...",
|
||||
"passwordChanged": "Contraseña cambiada",
|
||||
"passwordChangeFailed": "No se pudo cambiar la contraseña"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Modelo predeterminado",
|
||||
"sameAsChat": "Igual que el predeterminado del chat",
|
||||
"taskDefaultsSaved": "Valores predeterminados de tareas guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes",
|
||||
"defaultChat": "Modelo predeterminado del chat",
|
||||
"defaultChatHint": "Se usa al iniciar un chat nuevo desde la pantalla de inicio",
|
||||
"defaultProject": "Modelo predeterminado del proyecto",
|
||||
"defaultProjectHint": "Se usa al iniciar un chat nuevo dentro de un dashboard de proyecto",
|
||||
"defaultTask": "Modelo predeterminado de tareas",
|
||||
"defaultTaskHint": "Se usa al ejecutar tareas desde el explorador de archivos",
|
||||
"aiDefaultsSaved": "Valores predeterminados de modelos de IA guardados"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Perfil",
|
||||
"hint": "avatar, nombre, correo electrónico",
|
||||
"description": "Avatar, nombre y correo electrónico"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Cambiar contraseña",
|
||||
"description": "Actualiza tu contraseña"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "Claves de API",
|
||||
"hint": "claves que inician sesión como tú",
|
||||
"description": "Claves que inician sesión como tú, revocables una a una"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Idiomas",
|
||||
"hint": "hablados y traducción",
|
||||
"description": "Hablados, predeterminado y traducción"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voz",
|
||||
"hint": "conversión de texto a voz",
|
||||
"description": "Preferencia de voz para la conversión de texto a voz"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "elegir y reordenar",
|
||||
"description": "Elige y reordena los elementos del dock"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Mis modelos",
|
||||
"description": "Muestra u oculta modelos solo para ti"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Modelos del canal",
|
||||
"description": "Modelos accesibles para cuentas que no son del propietario"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Modelos predeterminados",
|
||||
"description": "Modelos predeterminados para chat, proyectos y tareas"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Proveedores",
|
||||
"hint": "harnesses",
|
||||
"description": "Proveedores de IA remotos y locales"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Texto a voz",
|
||||
"description": "Proveedor de TTS y voz"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Voz a texto",
|
||||
"description": "URL del servidor Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Modelo de visión para extracción de texto"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Reconstruir y reiniciar",
|
||||
"hint": "mantenimiento",
|
||||
"description": "Carga los cambios sin usar un terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "correo saliente",
|
||||
"description": "Proveedor de entrega de correo electrónico"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Client ID y secret para las APIs de Google"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "Token de API para actores de web scraping"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Sincronización de Calendario y Contactos",
|
||||
"hint": "contraseñas de aplicación",
|
||||
"description": "Contraseñas por dispositivo para clientes CalDAV/CardDAV"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Cuentas",
|
||||
"hint": "usuarios",
|
||||
"description": "Quién tiene acceso, y con qué función"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Permisos",
|
||||
"description": "Lo que cada función puede alcanzar"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Buscar ajustes…",
|
||||
"selectSection": "Selecciona una sección",
|
||||
"groups": {
|
||||
"models": "Modelos",
|
||||
"defaults": "Predeterminados",
|
||||
"providers": "Proveedores",
|
||||
"ai": "IA",
|
||||
"maintenance": "Mantenimiento",
|
||||
"email": "Correo electrónico"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Perfil",
|
||||
"ai": "IA",
|
||||
"system": "Sistema",
|
||||
"integrations": "Integraciones",
|
||||
"users": "Usuarios"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "No hay modelos disponibles. Configura las claves de API en Proveedores.",
|
||||
"dragToShow": "Arrastra modelos aquí para mostrarlos",
|
||||
"allVisible": "Todos los modelos visibles",
|
||||
"dragToEnable": "Arrastra modelos aquí para activarlos",
|
||||
"allEnabled": "Todos los modelos activados",
|
||||
"visible": "Visibles",
|
||||
"hidden": "Ocultos",
|
||||
"enabled": "Activados",
|
||||
"disabled": "Desactivados",
|
||||
"refresh": "Actualizar modelos desde los proveedores",
|
||||
"refreshed": "Modelos actualizados",
|
||||
"refreshFailed": "No se pudieron actualizar los modelos"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Empresa",
|
||||
"personal": "Personal"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Cargando…",
|
||||
"provider": "Proveedor",
|
||||
"model": "Modelo",
|
||||
"voice": "Voz",
|
||||
"ocr": {
|
||||
"serverUrl": "URL del servidor del modelo de visión",
|
||||
"refreshModels": "Actualizar modelos",
|
||||
"selectModel": "Selecciona un modelo",
|
||||
"saved": "Ajustes de OCR guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de OCR",
|
||||
"reachable": "El servidor del modelo de visión está accesible"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL del servidor Whisper",
|
||||
"saved": "Ajustes de STT guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de STT",
|
||||
"reachable": "El servidor Whisper está accesible"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "Compatible con OpenAI (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Actualizar voces",
|
||||
"selectVoice": "Selecciona una voz",
|
||||
"saved": "Ajustes de TTS guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de TTS",
|
||||
"playing": "Reproduciendo audio de prueba",
|
||||
"testFailed": "La prueba falló — no se pudo contactar con el servidor de TTS"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (pruebas locales)",
|
||||
"resend": "Resend",
|
||||
"custom": "SMTP personalizado",
|
||||
"apiKey": "Clave de API",
|
||||
"host": "Host",
|
||||
"port": "Puerto",
|
||||
"username": "Nombre de usuario",
|
||||
"password": "Contraseña",
|
||||
"tls": "TLS / SSL",
|
||||
"fromName": "Nombre del remitente",
|
||||
"fromEmail": "Correo del remitente",
|
||||
"sendTest": "Enviar correo de prueba",
|
||||
"userExample": "usuario@ejemplo.com",
|
||||
"recipientExample": "destinatario@ejemplo.com",
|
||||
"saved": "Ajustes de SMTP guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de SMTP",
|
||||
"connected": "Conexión correcta",
|
||||
"testSent": "Correo de prueba enviado a {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Actualizar la plataforma",
|
||||
"updateHint": "No cambia nada que esté en ejecución. Lo que la actualización necesita se indica aquí, y la decisión es tuya.",
|
||||
"rebuild": "Reconstruir el frontend",
|
||||
"restart": "Reiniciar el servidor",
|
||||
"upToDate": "Ya está actualizado",
|
||||
"rebuilt": "Frontend reconstruido — actualiza esta página para verlo",
|
||||
"restarting": "Reiniciando — la página se reconectará sola",
|
||||
"pulled": {
|
||||
"one": "Se obtuvo {{count}} commit",
|
||||
"other": "Se obtuvieron {{count}} commits"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Comprobando…",
|
||||
"localProviders": "Proveedores locales",
|
||||
"remoteProviders": "Proveedores remotos",
|
||||
"connect": "Conectar",
|
||||
"authenticate": "Autenticar",
|
||||
"connectLocal": "Conectar proveedor local",
|
||||
"connectProvider": "Conectar proveedor",
|
||||
"none": "No se encontraron proveedores.",
|
||||
"updatePiMono": "Actualizar Pi Mono",
|
||||
"removeProvider": "Eliminar proveedor",
|
||||
"namePlaceholder": "Nombre (p. ej. Mi Ollama)",
|
||||
"username": "Nombre de usuario",
|
||||
"password": "Contraseña",
|
||||
"apiKey": "Clave de API",
|
||||
"editKey": "Editar clave",
|
||||
"disconnect": "Desconectar proveedor",
|
||||
"saveKey": "Guardar clave",
|
||||
"enterKey": "Introduce la clave de API",
|
||||
"search": "Buscar proveedores…",
|
||||
"connectFailed": "No se pudo conectar",
|
||||
"authFailed": "La autenticación falló",
|
||||
"saveFailed": "No se pudo guardar el proveedor",
|
||||
"connectedTo": "Conectado a {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Cargando permisos…",
|
||||
"permissionsFailed": "No se pudieron cargar los permisos.",
|
||||
"deniedUnlessGranted": "Todo está denegado a menos que se conceda aquí.",
|
||||
"read": "Lectura",
|
||||
"full": "Total",
|
||||
"noAccess": "Sin acceso",
|
||||
"role": "Función",
|
||||
"permissionsSaved": "Guardado lo que pueden alcanzar los {{role}}",
|
||||
"copyBeforeClosing": "Copia lo que necesites antes de cerrar — nada de esto se puede volver a mostrar.",
|
||||
"password": "Contraseña",
|
||||
"linuxAccount": "Cuenta Linux",
|
||||
"sshKey": "Su clave pública SSH",
|
||||
"addToGitea": "Añade esto a su cuenta de Gitea",
|
||||
"linuxIncomplete": "La cuenta funciona, pero su parte Linux no terminó de configurarse",
|
||||
"done": "Hecho",
|
||||
"addAccount": "Añadir cuenta",
|
||||
"newAccount": "Nueva cuenta",
|
||||
"email": "Correo electrónico",
|
||||
"name": "Nombre",
|
||||
"username": "Nombre de usuario",
|
||||
"usernameHint": "Letras, números, puntos, guiones y guiones bajos.",
|
||||
"generatePassword": "Generar una contraseña",
|
||||
"copyPassword": "Copiar la contraseña",
|
||||
"passwordHint": "Al menos 12 caracteres, con mayúsculas y minúsculas, un número y un símbolo.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@portatil",
|
||||
"passwordCopied": "Contraseña copiada",
|
||||
"loadingAccounts": "Cargando cuentas…",
|
||||
"accountsFailed": "No se pudieron cargar las cuentas.",
|
||||
"removeAccount": "Eliminar cuenta",
|
||||
"generateNewPassword": "Generar una nueva contraseña",
|
||||
"serverOwner": "Propietario del servidor",
|
||||
"copySshTitle": "Copiar su clave pública SSH (añádela a su cuenta de Gitea)",
|
||||
"resetPasswordTitle": "Generar una nueva contraseña — se muestra una sola vez, y cierra su sesión en todas partes",
|
||||
"copyNewPassword": "Copiar la nueva contraseña",
|
||||
"publicKeyCopied": "Clave pública copiada",
|
||||
"copyFailed": "No se pudo copiar — selecciónala y copia a mano",
|
||||
"repairFor": "Reparar la cuenta Linux de {{email}}",
|
||||
"linuxAccountFor": "Cuenta Linux: {{osUser}} — haz clic para reparar o sustituir su clave SSH",
|
||||
"copySshFor": "Copiar la clave pública SSH de {{email}}",
|
||||
"resetPasswordFor": "Restablecer la contraseña de {{email}}",
|
||||
"removeFor": "Eliminar {{email}}",
|
||||
"provisionPrompt": "Cuenta Linux para {{email}}.\n\nPega una clave pública SSH para permitirle conectarse por SSH, o déjalo vacío para mantener la actual.",
|
||||
"createLinux": "Crear una cuenta Linux",
|
||||
"noLinux": "Sin cuenta Linux — haz clic para crear una",
|
||||
"removeTitle": "¿Eliminar {{email}}?",
|
||||
"removeBody": "Esto elimina la cuenta y todo lo que le pertenece — passkeys, dashboards, diseños guardados y todo lo que cualquier plugin instalado guarde para ella. No se puede deshacer.",
|
||||
"resetTitle": "¿Restablecer la contraseña de {{email}}?",
|
||||
"resetBody": "Se genera una nueva contraseña y se te muestra una sola vez — no se guarda en ningún sitio y no se puede consultar después. Su contraseña actual deja de funcionar de inmediato, y su sesión se cierra en todas partes.",
|
||||
"resetLinuxNote": "Su cuenta Linux ({{osUser}}) no se ve afectada: no tiene contraseña, y las claves SSH no cambian."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Nuevo dispositivo",
|
||||
"create": "Crear",
|
||||
"none": "Todavía no hay dispositivos.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Revocar",
|
||||
"delete": "Eliminar",
|
||||
"copyFailed": "No se pudo copiar — selecciona y copia manualmente",
|
||||
"createFailed": "No se pudo crear la contraseña de aplicación",
|
||||
"revoked": "Revocada — ese dispositivo dejará de sincronizar",
|
||||
"revokeFailed": "No se pudo revocar",
|
||||
"deleteFailed": "No se pudo eliminar",
|
||||
"copyField": "Copiar {{field}}",
|
||||
"copied": "{{what}} copiado"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Guía de configuración paso a paso",
|
||||
"step1": {
|
||||
"title": "Crear un proyecto de Google Cloud",
|
||||
"body": "Abre la página de creación de proyectos, dale un nombre (por ejemplo “Officer”) y haz clic en:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Activar las APIs",
|
||||
"body": "Abre la biblioteca de APIs, busca cada una de estas y haz clic en:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Configurar la pantalla de consentimiento de OAuth",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Pon el nombre de tu organización, o “Officer”, como:",
|
||||
"supportEmail": "Pon el correo del administrador como:",
|
||||
"devContact": "Añade el correo del administrador en:",
|
||||
"save": "Después haz clic en:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Definir la audiencia",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Si tu equipo usa Google Workspace, elige — no hace falta verificación:",
|
||||
"external": "En caso contrario elige, y añade los correos de tu equipo en Test users (obligatorio mientras la aplicación no esté verificada; límite de 100 usuarios de prueba):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Añadir ámbitos",
|
||||
"link": "Data Access",
|
||||
"body": "Abre el acceso a datos, busca y añade los ámbitos de abajo, y luego haz clic en:",
|
||||
"note": "Google clasifica el ámbito del calendario como sensible y el de Gmail como restringido. Eso no es un problema para aplicaciones Internal (Google Workspace) ni para aplicaciones External en modo de prueba; publicar en producción con ámbitos restringidos requiere verificación de Google."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Crear credenciales OAuth",
|
||||
"link": "Clients",
|
||||
"body": "Abre la página de clientes y haz clic en:",
|
||||
"appType": "Tipo de aplicación:",
|
||||
"nameAnything": "Nombre: el que quieras (por ejemplo “Officer”)",
|
||||
"redirectUris": "Authorized redirect URIs — añade:",
|
||||
"thenClick": "Después haz clic en:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Copiar las credenciales",
|
||||
"body": "Un cuadro de diálogo muestra tu client ID y tu client secret. Copia ambos y pégalos en los campos de abajo."
|
||||
},
|
||||
"scopeGmail": "Leer correos",
|
||||
"scopeCalendar": "Leer eventos del calendario",
|
||||
"verifying": "Verificando credenciales…",
|
||||
"valid": "Credenciales válidas",
|
||||
"invalid": "Credenciales no válidas",
|
||||
"verifyFailed": "La solicitud de verificación falló",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"redirectUri": "URI de redirección",
|
||||
"redirectUriHint": "Añade este URI a los URIs de redirección autorizados de tu cliente OAuth de Google",
|
||||
"saved": "Configuración de Google OAuth guardada",
|
||||
"saveFailed": "No se pudo guardar la configuración de Google OAuth"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Cuenta de Google",
|
||||
"hint": "Conecta tu cuenta de Google para Calendario, Contactos y otros servicios de Google.",
|
||||
"connected": "Conectada",
|
||||
"disconnect": "Desconectar",
|
||||
"connect": "Conectar cuenta de Google",
|
||||
"connectedToast": "Cuenta de Google conectada",
|
||||
"connectFailed": "No se pudo conectar la cuenta de Google",
|
||||
"disconnectedToast": "Cuenta de Google desconectada",
|
||||
"disconnectFailed": "No se pudo desconectar la cuenta de Google"
|
||||
},
|
||||
"apify": {
|
||||
"token": "Token de API",
|
||||
"saved": "Token de API de Apify guardado",
|
||||
"saveFailed": "No se pudo guardar el token de API de Apify"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Perfil",
|
||||
"systemSettings": "Ajustes del sistema",
|
||||
"signOut": "Cerrar sesión",
|
||||
"ai": "IA",
|
||||
"integrations": "Integraciones",
|
||||
"userManagement": "Gestión de usuarios"
|
||||
},
|
||||
"tabName": "Nombre de la pestaña",
|
||||
"tabNameHint": "Haz clic para renombrar esta pestaña — bórralo para volver al nombre de la página",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navegación",
|
||||
"jobsRunning": "Trabajo en ejecución",
|
||||
"jobsNothingRunning": "Nada en ejecución",
|
||||
"jobsQueued": "Trabajos en cola"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"files": "Archivos",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Trabajos",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Navegador",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Actividad",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Calendario",
|
||||
"contacts": "Contactos",
|
||||
"appStore": "Tienda de aplicaciones",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Editor de código"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ recorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "marcar",
|
||||
"unstar": "desmarcar",
|
||||
"cycleHint": "ctrl+shift+←/→ recorrer · ctrl+shift+space explorar",
|
||||
"everyShortcutHint": "ctrl+? para todos los atajos",
|
||||
"favouritesOnly": "Solo favoritos ({{count}})",
|
||||
"favourites": "Favoritos ({{count}})",
|
||||
"noFavourites": "Todavía no hay favoritos — marca uno con el botón de su fila.",
|
||||
"starNamed": "Marcar {{name}}",
|
||||
"unstarNamed": "Desmarcar {{name}}",
|
||||
"addToFavourites": "Añadir a favoritos",
|
||||
"removeFromFavourites": "Quitar de favoritos",
|
||||
"addToFavouritesKey": "Añadir a favoritos (f)",
|
||||
"removeFromFavouritesKey": "Quitar de favoritos (f)",
|
||||
"active": "activo",
|
||||
"browseHint": "j/k o ↑/↓ mover · l o → previsualizar · f marcar · h o ← reiniciar · enter mantener · esc cancelar"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Ir a…",
|
||||
"noResults": "Nada coincide con “{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Atajos de teclado",
|
||||
"hint": "Ctrl+? · Esc para cerrar"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Esta pantalla no se pudo mostrar",
|
||||
"description": "No se ha perdido nada — este es un fallo de visualización, no de datos. Las transcripciones del agente, los trabajos y los archivos están intactos.",
|
||||
"unknownError": "Error desconocido",
|
||||
"tryAgain": "Reintentar",
|
||||
"reload": "Recargar",
|
||||
"goHome": "Ir al inicio",
|
||||
"resetHint": "Si vuelve a fallar en el mismo sitio, la causa habitual es un diseño de panel guardado. Reiniciar devuelve los paneles de todas las pantallas a sus valores predeterminados.",
|
||||
"dashboardsSafe": "Tus dashboards no se ven afectados.",
|
||||
"resetting": "Reiniciando…",
|
||||
"resetConfirm": "Sí — reiniciar el diseño de todas las pantallas",
|
||||
"reset": "Reiniciar diseños de pantallas"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Reanalizar elementos",
|
||||
"title": "Reanalizar officer-items (aplicar cambios sin reiniciar)",
|
||||
"failed": "No se pudieron reanalizar los elementos",
|
||||
"done": "Elementos reanalizados — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} tarea",
|
||||
"other": "{{count}} tareas"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agente",
|
||||
"other": "{{count}} agentes"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} herramienta",
|
||||
"other": "{{count}} herramientas"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} proceso",
|
||||
"other": "{{count}} procesos"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Se aplica a los chats nuevos — uno abierto mantiene lo que tenía al empezar.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill no tiene descripción, así que no se ofrece a ningún agente.",
|
||||
"other": "{{count}} skills no tienen descripción, así que no se ofrecen a ningún agente."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Informar de un error",
|
||||
"title": "Informar de un error",
|
||||
"describePlaceholder": "Describe qué ha ido mal...",
|
||||
"screenshot": "Captura de pantalla",
|
||||
"noScreenshot": "No se obtuvo ninguna captura de pantalla",
|
||||
"collectedNote": "Los registros de la consola, la información del navegador y los errores recientes de la API se recogen automáticamente.",
|
||||
"submitted": "Informe de error enviado",
|
||||
"failed": "No se pudo enviar el informe de error",
|
||||
"submit": "Enviar"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Bienvenido de nuevo",
|
||||
"signInSubtitle": "Inicia sesión en tu cuenta",
|
||||
"emailOrUsername": "Correo electrónico o nombre de usuario",
|
||||
"password": "Contraseña",
|
||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||
"emailPlaceholder": "tu@ejemplo.com",
|
||||
"passwordPlaceholder": "Tu contraseña",
|
||||
"forgotTitle": "Recuperar contraseña",
|
||||
"forgotSubtitle": "Introduce tu correo electrónico para recibir un enlace de restablecimiento",
|
||||
"email": "Correo electrónico",
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"checkEmail": "Revisa tu correo electrónico",
|
||||
"checkEmailHint": "Haz clic en él para restablecer tu contraseña.",
|
||||
"resetOk": "Contraseña restablecida correctamente. Inicia sesión.",
|
||||
"verifying": "Verificando…",
|
||||
"invalidLink": "Enlace no válido",
|
||||
"invalidLinkHint": "Este enlace de restablecimiento no es válido o ha caducado.",
|
||||
"resetTitle": "Restablecer contraseña",
|
||||
"resetSubtitle": "Introduce tu nueva contraseña",
|
||||
"confirmPassword": "Confirmar contraseña",
|
||||
"minChars": "Mínimo 12 caracteres",
|
||||
"confirmPlaceholder": "Confirma tu contraseña",
|
||||
"bootstrapTitle": "Bienvenido, administrador",
|
||||
"bootstrapSubtitle": "Crea tu cuenta de administrador",
|
||||
"name": "Nombre",
|
||||
"username": "Nombre de usuario",
|
||||
"namePlaceholder": "Tu nombre",
|
||||
"adminEmailPlaceholder": "admin@ejemplo.com",
|
||||
"bootstrapOk": "Cuenta de administrador creada. Inicia sesión.",
|
||||
"debug": {
|
||||
"controls": "Controles:",
|
||||
"camera": "Cámara:",
|
||||
"model": "Modelo:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navegación",
|
||||
"spotlight": "Spotlight — ve a cualquier pantalla, plugin o ajuste por su nombre",
|
||||
"thisList": "Esta lista",
|
||||
"themes": "Temas",
|
||||
"themesNote": "El explorador aplica el tema a medida que te mueves, para que veas la aplicación cambiar en vez de leer nombres.",
|
||||
"openBrowser": "Abrir el explorador de temas",
|
||||
"prevNextTheme": "Tema anterior / siguiente, sin abrir nada",
|
||||
"starCurrent": "Marcar o desmarcar el tema actual, desde cualquier lugar",
|
||||
"inBrowser": "En el explorador de temas",
|
||||
"moveList": "Moverse por la lista",
|
||||
"applyKeepBrowsing": "Aplicar el tema enfocado y seguir explorando",
|
||||
"restoreStart": "Volver al tema con el que empezaste",
|
||||
"starFocused": "Marcar o desmarcar el tema enfocado",
|
||||
"keepLooking": "Mantener lo que estás viendo",
|
||||
"cancelRestore": "Cancelar y restaurar",
|
||||
"inSpotlight": "En Spotlight",
|
||||
"moveResults": "Moverse por los resultados",
|
||||
"goThere": "Ir allí",
|
||||
"close": "Cerrar",
|
||||
"chat": "Chat",
|
||||
"chatNote": "La recuperación solo usa la tecla de flecha en un compositor VACÍO, y cualquier otra tecla termina la recuperación — así que un borrador nunca se sobrescribe.",
|
||||
"prevNextPrompt": "Prompt anterior / siguiente que enviaste, al estilo de una shell",
|
||||
"send": "Enviar · en un compositor vacío con algo en cola, envía eso ahora",
|
||||
"newline": "Salto de línea",
|
||||
"stopTurn": "Detener el turno que se está generando"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "En ejecución y en cola",
|
||||
"loading": "Cargando…",
|
||||
"nothingRunning": "Nada en ejecución",
|
||||
"history": "Historial",
|
||||
"clearAll": "Limpiar todo",
|
||||
"selectJob": "Selecciona un trabajo",
|
||||
"notFound": "Trabajo no encontrado.",
|
||||
"backToJobs": "Volver a trabajos",
|
||||
"stop": "Detener",
|
||||
"testNotification": "Enviar una notificación de prueba a todos los canales configurados",
|
||||
"search": "Buscar…",
|
||||
"deleteFinished": "Eliminar todos los trabajos terminados",
|
||||
"noChannels": "No hay canales configurados",
|
||||
"notifyFailed": "No se pudo enviar la notificación",
|
||||
"stopRequested": "Detención solicitada",
|
||||
"stopFailed": "No se pudo detener",
|
||||
"titles": "Títulos",
|
||||
"download": "Descargar",
|
||||
"input": "Entrada",
|
||||
"output": "Salida",
|
||||
"steps": "Pasos",
|
||||
"selectStep": "Selecciona un paso o subtarea para ver la salida",
|
||||
"noOutput": "Todavía no hay salida",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Coste:",
|
||||
"status": {
|
||||
"running": "En ejecución",
|
||||
"queued": "En cola",
|
||||
"completed": "Completado",
|
||||
"failed": "Fallido",
|
||||
"stopped": "Detenido",
|
||||
"interrupted": "Interrumpido"
|
||||
},
|
||||
"failedWithCode": "Fallido (salida {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Actividad",
|
||||
"backgroundTasks": "Tareas en segundo plano",
|
||||
"detached": "Separada",
|
||||
"backToList": "Volver a la lista",
|
||||
"selectTask": "Selecciona una tarea para seguir su salida en directo",
|
||||
"noneRunning": "ninguna en ejecución"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Configura tu passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "No se pudo cargar esta conversación: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Sin coincidencias",
|
||||
"emptyFile": "Archivo vacío",
|
||||
"createFailed": "No se pudo crear el {{kind}}",
|
||||
"deleteFailed": "No se pudo eliminar el {{kind}}",
|
||||
"searchPlaceholder": "Buscar {{kind}}…",
|
||||
"selectOne": "Selecciona un {{kind}}",
|
||||
"selectToView": "Selecciona un {{kind}} para ver su contenido",
|
||||
"delete": "Eliminar",
|
||||
"createPrompt": "Ayúdame a crear el contenido de este nuevo archivo de {{kind}}",
|
||||
"improvePrompt": "Ayúdame a entender y mejorar este archivo de {{kind}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "E-mail",
|
||||
"username": "Nom d'utilisateur",
|
||||
"name": "Nom",
|
||||
"namePlaceholder": "Votre nom",
|
||||
"updated": "Profil mis à jour",
|
||||
"updateFailed": "Échec de la mise à jour du profil",
|
||||
"selectImage": "Veuillez sélectionner un fichier image",
|
||||
"imageTooLarge": "L'image doit faire moins de 384 Ko"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "Une clé API vous connecte comme vous-même, sans mot de passe et sans expirer. Une clé fonctionne partout — une clé n'est pas liée à une application. Donner sa propre clé à chaque appareil sert seulement à pouvoir en couper une sans toucher aux autres. Envoyez-la comme",
|
||||
"loading": "Chargement des clés…",
|
||||
"mintedTitle": "Clé pour “{{name}}” — affichée une seule fois",
|
||||
"savedIt": "Je l'ai enregistrée",
|
||||
"copyKey": "Copier la clé",
|
||||
"copied": "Clé copiée",
|
||||
"copyFailed": "Impossible de copier — sélectionnez et copiez manuellement",
|
||||
"newKey": "Nouvelle clé",
|
||||
"namePlaceholder": "iPhone, application Musique, CLI du portable…",
|
||||
"create": "Créer",
|
||||
"createFailed": "Impossible de créer la clé",
|
||||
"noneYet": "Aucune clé pour l'instant.",
|
||||
"noneInUse": "Aucune clé utilisée.",
|
||||
"revoked": "révoquée",
|
||||
"expired": "expirée",
|
||||
"lastUsed": "dernière utilisation {{date}}",
|
||||
"neverUsed": "jamais utilisée",
|
||||
"forget": "Supprimer cet enregistrement",
|
||||
"forgetFailed": "Impossible de le supprimer",
|
||||
"revoke": "Révoquer",
|
||||
"revokeDone": "Révoquée — tout ce qui utilisait cette clé est déconnecté",
|
||||
"revokeFailed": "Impossible de révoquer",
|
||||
"hideDead": "Masquer les révoquées et expirées",
|
||||
"showDead": {
|
||||
"one": "Afficher {{count}} clé révoquée ou expirée",
|
||||
"other": "Afficher {{count}} clés révoquées ou expirées"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voix",
|
||||
"refresh": "Actualiser les voix",
|
||||
"serverDefault": "Valeur par défaut du serveur",
|
||||
"hint": "Choisissez une voix pour la synthèse vocale. Laissez la valeur par défaut du serveur pour utiliser la voix configurée par l'administrateur.",
|
||||
"stop": "Arrêter",
|
||||
"listen": "Écouter"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Visibles",
|
||||
"hidden": "Masqués",
|
||||
"dragToShow": "Faites glisser des éléments ici pour les afficher dans le Dock",
|
||||
"allVisible": "Tous les éléments sont visibles",
|
||||
"reset": "Rétablir les valeurs par défaut"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Langues que vous parlez",
|
||||
"addLanguage": "Ajouter une langue...",
|
||||
"defaultLabel": "Langue par défaut",
|
||||
"defaultHint": "Utilisée pour la localisation de l'interface.",
|
||||
"translateToLabel": "Traduire vers",
|
||||
"translateToHint": "Langue cible pour traduire le contenu que vous ne parlez pas."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Mot de passe actuel",
|
||||
"currentPasswordPlaceholder": "Mot de passe actuel",
|
||||
"newPasswordLabel": "Nouveau mot de passe",
|
||||
"newPasswordPlaceholder": "Nouveau mot de passe",
|
||||
"confirmPasswordLabel": "Confirmer le nouveau mot de passe",
|
||||
"confirmPasswordPlaceholder": "Confirmez le nouveau mot de passe",
|
||||
"changeButton": "Changer le mot de passe",
|
||||
"changingButton": "Modification...",
|
||||
"passwordChanged": "Mot de passe modifié",
|
||||
"passwordChangeFailed": "Échec de la modification du mot de passe"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Modèle par défaut",
|
||||
"sameAsChat": "Identique à la valeur par défaut du Chat",
|
||||
"taskDefaultsSaved": "Valeurs par défaut des tâches enregistrées",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres",
|
||||
"defaultChat": "Modèle par défaut du Chat",
|
||||
"defaultChatHint": "Utilisé au démarrage d'un nouveau Chat depuis l'écran d'accueil",
|
||||
"defaultProject": "Modèle par défaut du projet",
|
||||
"defaultProjectHint": "Utilisé au démarrage d'un nouveau Chat dans un Dashboard de projet",
|
||||
"defaultTask": "Modèle par défaut des tâches",
|
||||
"defaultTaskHint": "Utilisé lors de l'exécution de tâches depuis l'explorateur de fichiers",
|
||||
"aiDefaultsSaved": "Valeurs par défaut des modèles IA enregistrées"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profil",
|
||||
"hint": "avatar, nom, e-mail",
|
||||
"description": "Avatar, nom et e-mail"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Changer le mot de passe",
|
||||
"description": "Mettez à jour votre mot de passe"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "Clés API",
|
||||
"hint": "clés qui vous connectent comme vous-même",
|
||||
"description": "Clés qui vous connectent comme vous-même, révocables une à une"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Langues",
|
||||
"hint": "parlées et traduction",
|
||||
"description": "Parlées, par défaut et traduction"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voix",
|
||||
"hint": "synthèse vocale",
|
||||
"description": "Préférence de voix pour la synthèse vocale"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "choisir et réorganiser",
|
||||
"description": "Choisir et réorganiser les éléments du Dock"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Mes modèles",
|
||||
"description": "Afficher ou masquer des modèles pour vous-même"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Modèles du canal",
|
||||
"description": "Modèles accessibles aux comptes non propriétaires"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Modèles par défaut",
|
||||
"description": "Modèles par défaut pour le Chat, les projets et les tâches"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Fournisseurs",
|
||||
"hint": "harnesses",
|
||||
"description": "Fournisseurs d'IA distants et locaux"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Synthèse vocale",
|
||||
"description": "Fournisseur et voix pour la synthèse vocale"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Reconnaissance vocale",
|
||||
"description": "URL du serveur Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Modèle de vision pour l'extraction de texte"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Reconstruire et redémarrer",
|
||||
"hint": "maintenance",
|
||||
"description": "Charger les modifications sans terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "courrier sortant",
|
||||
"description": "Fournisseur de distribution d'e-mails"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Client ID et secret pour les API Google"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "Jeton API pour les acteurs de web scraping"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Synchronisation Calendrier et Contacts",
|
||||
"hint": "mots de passe d'application",
|
||||
"description": "Mots de passe par appareil pour les clients CalDAV/CardDAV"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Comptes",
|
||||
"hint": "utilisateurs",
|
||||
"description": "Qui a accès, et à quoi"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Permissions",
|
||||
"description": "Ce que chaque rôle peut atteindre"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Rechercher dans les paramètres…",
|
||||
"selectSection": "Sélectionnez une section",
|
||||
"groups": {
|
||||
"models": "Modèles",
|
||||
"defaults": "Valeurs par défaut",
|
||||
"providers": "Fournisseurs",
|
||||
"ai": "IA",
|
||||
"maintenance": "Maintenance",
|
||||
"email": "E-mail"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profil",
|
||||
"ai": "IA",
|
||||
"system": "Système",
|
||||
"integrations": "Intégrations",
|
||||
"users": "Utilisateurs"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "Aucun modèle disponible. Configurez les clés API dans Fournisseurs.",
|
||||
"dragToShow": "Faites glisser des modèles ici pour les afficher",
|
||||
"allVisible": "Tous les modèles sont visibles",
|
||||
"dragToEnable": "Faites glisser des modèles ici pour les activer",
|
||||
"allEnabled": "Tous les modèles sont activés",
|
||||
"visible": "Visibles",
|
||||
"hidden": "Masqués",
|
||||
"enabled": "Activés",
|
||||
"disabled": "Désactivés",
|
||||
"refresh": "Actualiser les modèles depuis les fournisseurs",
|
||||
"refreshed": "Modèles actualisés",
|
||||
"refreshFailed": "Échec de l'actualisation des modèles"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Entreprise",
|
||||
"personal": "Personnel"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Chargement…",
|
||||
"provider": "Fournisseur",
|
||||
"model": "Modèle",
|
||||
"voice": "Voix",
|
||||
"ocr": {
|
||||
"serverUrl": "URL du serveur du modèle de vision",
|
||||
"refreshModels": "Actualiser les modèles",
|
||||
"selectModel": "Sélectionnez un modèle",
|
||||
"saved": "Paramètres OCR enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres OCR",
|
||||
"reachable": "Le serveur du modèle de vision est accessible"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL du serveur Whisper",
|
||||
"saved": "Paramètres de reconnaissance vocale enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres de reconnaissance vocale",
|
||||
"reachable": "Le serveur Whisper est accessible"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "Compatible OpenAI (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Actualiser les voix",
|
||||
"selectVoice": "Sélectionnez une voix",
|
||||
"saved": "Paramètres de synthèse vocale enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres de synthèse vocale",
|
||||
"playing": "Lecture de l'audio de test",
|
||||
"testFailed": "Échec du test — impossible de joindre le serveur de synthèse vocale"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (test local)",
|
||||
"resend": "Resend",
|
||||
"custom": "SMTP personnalisé",
|
||||
"apiKey": "Clé API",
|
||||
"host": "Hôte",
|
||||
"port": "Port",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"tls": "TLS / SSL",
|
||||
"fromName": "Nom de l'expéditeur",
|
||||
"fromEmail": "E-mail de l'expéditeur",
|
||||
"sendTest": "Envoyer un e-mail de test",
|
||||
"userExample": "utilisateur@exemple.com",
|
||||
"recipientExample": "destinataire@exemple.com",
|
||||
"saved": "Paramètres SMTP enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres SMTP",
|
||||
"connected": "Connexion réussie",
|
||||
"testSent": "E-mail de test envoyé à {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Mettre à jour la plateforme",
|
||||
"updateHint": "Ne change rien à ce qui est en cours d'exécution. Ce dont la mise à jour a besoin est listé ici, et le choix vous revient.",
|
||||
"rebuild": "Reconstruire le frontend",
|
||||
"restart": "Redémarrer le serveur",
|
||||
"upToDate": "Déjà à jour",
|
||||
"rebuilt": "Frontend reconstruit — actualisez cette page pour le voir",
|
||||
"restarting": "Redémarrage — la page se reconnectera d'elle-même",
|
||||
"pulled": {
|
||||
"one": "{{count}} commit récupéré",
|
||||
"other": "{{count}} commits récupérés"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Vérification…",
|
||||
"localProviders": "Fournisseurs locaux",
|
||||
"remoteProviders": "Fournisseurs distants",
|
||||
"connect": "Connecter",
|
||||
"authenticate": "Authentifier",
|
||||
"connectLocal": "Connecter un fournisseur local",
|
||||
"connectProvider": "Connecter un fournisseur",
|
||||
"none": "Aucun fournisseur trouvé.",
|
||||
"updatePiMono": "Mettre à jour Pi Mono",
|
||||
"removeProvider": "Supprimer le fournisseur",
|
||||
"namePlaceholder": "Nom (ex. Mon Ollama)",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"apiKey": "Clé API",
|
||||
"editKey": "Modifier la clé",
|
||||
"disconnect": "Déconnecter le fournisseur",
|
||||
"saveKey": "Enregistrer la clé",
|
||||
"enterKey": "Saisissez la clé API",
|
||||
"search": "Rechercher des fournisseurs…",
|
||||
"connectFailed": "Échec de la connexion",
|
||||
"authFailed": "Échec de l'authentification",
|
||||
"saveFailed": "Échec de l'enregistrement du fournisseur",
|
||||
"connectedTo": "Connecté à {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Chargement des permissions…",
|
||||
"permissionsFailed": "Impossible de charger les permissions.",
|
||||
"deniedUnlessGranted": "Tout est refusé sauf ce qui est accordé ici.",
|
||||
"read": "Lecture",
|
||||
"full": "Total",
|
||||
"noAccess": "Aucun accès",
|
||||
"role": "Rôle",
|
||||
"permissionsSaved": "Ce que les {{role}} peuvent atteindre a été enregistré",
|
||||
"copyBeforeClosing": "Copiez ce dont vous avez besoin avant de fermer — rien de tout cela ne peut être réaffiché.",
|
||||
"password": "Mot de passe",
|
||||
"linuxAccount": "Compte Linux",
|
||||
"sshKey": "Leur clé publique SSH",
|
||||
"addToGitea": "Ajoutez ceci à leur compte Gitea",
|
||||
"linuxIncomplete": "Le compte fonctionne, mais sa partie Linux ne s'est pas terminée",
|
||||
"done": "Terminé",
|
||||
"addAccount": "Ajouter un compte",
|
||||
"newAccount": "Nouveau compte",
|
||||
"email": "E-mail",
|
||||
"name": "Nom",
|
||||
"username": "Nom d'utilisateur",
|
||||
"usernameHint": "Lettres, chiffres, points, tirets et underscores.",
|
||||
"generatePassword": "Générer un mot de passe",
|
||||
"copyPassword": "Copier le mot de passe",
|
||||
"passwordHint": "Au moins 12 caractères, avec majuscules et minuscules, un chiffre et un symbole.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@portable",
|
||||
"passwordCopied": "Mot de passe copié",
|
||||
"loadingAccounts": "Chargement des comptes…",
|
||||
"accountsFailed": "Impossible de charger les comptes.",
|
||||
"removeAccount": "Supprimer le compte",
|
||||
"generateNewPassword": "Générer un nouveau mot de passe",
|
||||
"serverOwner": "Propriétaire du serveur",
|
||||
"copySshTitle": "Copier leur clé publique SSH (ajoutez-la à leur compte Gitea)",
|
||||
"resetPasswordTitle": "Générer un nouveau mot de passe — affiché une seule fois, et les déconnecte partout",
|
||||
"copyNewPassword": "Copier le nouveau mot de passe",
|
||||
"publicKeyCopied": "Clé publique copiée",
|
||||
"copyFailed": "Impossible de copier — sélectionnez-la et copiez-la à la main",
|
||||
"repairFor": "Réparer le compte Linux de {{email}}",
|
||||
"linuxAccountFor": "Compte Linux : {{osUser}} — cliquez pour réparer ou remplacer sa clé SSH",
|
||||
"copySshFor": "Copier la clé publique SSH de {{email}}",
|
||||
"resetPasswordFor": "Réinitialiser le mot de passe de {{email}}",
|
||||
"removeFor": "Supprimer {{email}}",
|
||||
"provisionPrompt": "Compte Linux pour {{email}}.\n\nCollez une clé publique SSH pour lui permettre de se connecter en SSH, ou laissez vide pour conserver l'actuelle.",
|
||||
"createLinux": "Créer un compte Linux",
|
||||
"noLinux": "Aucun compte Linux — cliquez pour en créer un",
|
||||
"removeTitle": "Supprimer {{email}} ?",
|
||||
"removeBody": "Cela supprime le compte et tout ce qui lui appartient — passkeys, Dashboards, dispositions enregistrées, et tout ce qu'un plugin installé conserve pour lui. Cette action est irréversible.",
|
||||
"resetTitle": "Réinitialiser le mot de passe de {{email}} ?",
|
||||
"resetBody": "Un nouveau mot de passe est généré et vous est affiché une seule fois — il n'est stocké nulle part et ne peut plus être consulté ensuite. Son mot de passe actuel cesse de fonctionner immédiatement, et il est déconnecté partout.",
|
||||
"resetLinuxNote": "Son compte Linux ({{osUser}}) n'est pas affecté : il n'a pas de mot de passe, et les clés SSH restent inchangées."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Nouvel appareil",
|
||||
"create": "Créer",
|
||||
"none": "Aucun appareil pour l'instant.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Révoquer",
|
||||
"delete": "Supprimer",
|
||||
"copyFailed": "Impossible de copier — sélectionnez et copiez manuellement",
|
||||
"createFailed": "Impossible de créer le mot de passe d'application",
|
||||
"revoked": "Révoqué — cet appareil arrêtera de se synchroniser",
|
||||
"revokeFailed": "Impossible de révoquer",
|
||||
"deleteFailed": "Impossible de supprimer",
|
||||
"copyField": "Copier {{field}}",
|
||||
"copied": "{{what}} copié"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Guide de configuration pas à pas",
|
||||
"step1": {
|
||||
"title": "Créer un projet Google Cloud",
|
||||
"body": "Ouvrez la page de création de projet, donnez-lui un nom (par exemple “Officer”), et cliquez sur :",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Activer les API",
|
||||
"body": "Ouvrez la bibliothèque d'API, recherchez chacune d'entre elles, puis cliquez sur :",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Configurer l'écran de consentement OAuth",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Définissez le nom de votre organisation, ou “Officer”, comme :",
|
||||
"supportEmail": "Définissez l'e-mail de votre administrateur comme :",
|
||||
"devContact": "Ajoutez l'e-mail de votre administrateur sous :",
|
||||
"save": "Puis cliquez sur :"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Définir l'audience",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Si votre équipe utilise Google Workspace, choisissez — aucune vérification requise :",
|
||||
"external": "Sinon choisissez, et ajoutez les e-mails de votre équipe sous Test users (obligatoire tant que l'application n'est pas vérifiée ; limite de 100 utilisateurs de test) :"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Ajouter des scopes",
|
||||
"link": "Data Access",
|
||||
"body": "Ouvrez l'accès aux données, recherchez et ajoutez les scopes ci-dessous, puis cliquez sur :",
|
||||
"note": "Google classe le scope du calendrier comme sensible et celui de Gmail comme restreint. Cela ne pose pas de problème pour les applications Internal (Google Workspace) ni pour les applications External en mode test ; publier en production avec des scopes restreints nécessite une vérification par Google."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Créer des identifiants OAuth",
|
||||
"link": "Clients",
|
||||
"body": "Ouvrez la page des clients et cliquez sur :",
|
||||
"appType": "Type d'application :",
|
||||
"nameAnything": "Nom : n'importe lequel (par exemple “Officer”)",
|
||||
"redirectUris": "URI de redirection autorisés — ajoutez :",
|
||||
"thenClick": "Puis cliquez sur :"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Copier les identifiants",
|
||||
"body": "Une boîte de dialogue affiche votre client ID et votre client secret. Copiez les deux et collez-les dans les champs ci-dessous."
|
||||
},
|
||||
"scopeGmail": "Lire les e-mails",
|
||||
"scopeCalendar": "Lire les événements du calendrier",
|
||||
"verifying": "Vérification des identifiants…",
|
||||
"valid": "Identifiants valides",
|
||||
"invalid": "Identifiants invalides",
|
||||
"verifyFailed": "La demande de vérification a échoué",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"redirectUri": "URI de redirection",
|
||||
"redirectUriHint": "Ajoutez cet URI aux URI de redirection autorisés de votre client OAuth Google",
|
||||
"saved": "Configuration Google OAuth enregistrée",
|
||||
"saveFailed": "Échec de l'enregistrement de la configuration Google OAuth"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Compte Google",
|
||||
"hint": "Connectez votre compte Google pour le Calendrier, les Contacts et d'autres services Google.",
|
||||
"connected": "Connecté",
|
||||
"disconnect": "Déconnecter",
|
||||
"connect": "Connecter le compte Google",
|
||||
"connectedToast": "Compte Google connecté",
|
||||
"connectFailed": "Échec de la connexion du compte Google",
|
||||
"disconnectedToast": "Compte Google déconnecté",
|
||||
"disconnectFailed": "Échec de la déconnexion du compte Google"
|
||||
},
|
||||
"apify": {
|
||||
"token": "Jeton API",
|
||||
"saved": "Jeton API Apify enregistré",
|
||||
"saveFailed": "Échec de l'enregistrement du jeton API Apify"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profil",
|
||||
"systemSettings": "Paramètres système",
|
||||
"signOut": "Se déconnecter",
|
||||
"ai": "IA",
|
||||
"integrations": "Intégrations",
|
||||
"userManagement": "Gestion des utilisateurs"
|
||||
},
|
||||
"tabName": "Nom de l'onglet",
|
||||
"tabNameHint": "Cliquez pour renommer cet onglet — videz-le pour revenir au nom de la page",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navigation",
|
||||
"jobsRunning": "Job en cours",
|
||||
"jobsNothingRunning": "Rien en cours",
|
||||
"jobsQueued": "Jobs en file d'attente"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"files": "Fichiers",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Éditeur",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Navigateur",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Activité",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Calendrier",
|
||||
"contacts": "Contacts",
|
||||
"appStore": "Boutique d'applications",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Éditeur de code"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Thème : {{name}} · ctrl+shift+←/→ parcourir · ctrl+shift+space explorer · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Thème : {{name}}",
|
||||
"star": "marquer",
|
||||
"unstar": "démarquer",
|
||||
"cycleHint": "ctrl+shift+←/→ parcourir · ctrl+shift+space explorer",
|
||||
"everyShortcutHint": "ctrl+? pour tous les raccourcis",
|
||||
"favouritesOnly": "Favoris uniquement ({{count}})",
|
||||
"favourites": "Favoris ({{count}})",
|
||||
"noFavourites": "Aucun favori pour l'instant — marquez-en un avec le bouton sur sa ligne.",
|
||||
"starNamed": "Marquer {{name}}",
|
||||
"unstarNamed": "Démarquer {{name}}",
|
||||
"addToFavourites": "Ajouter aux favoris",
|
||||
"removeFromFavourites": "Retirer des favoris",
|
||||
"addToFavouritesKey": "Ajouter aux favoris (f)",
|
||||
"removeFromFavouritesKey": "Retirer des favoris (f)",
|
||||
"active": "actif",
|
||||
"browseHint": "j/k ou ↑/↓ déplacer · l ou → aperçu · f marquer · h ou ← réinitialiser · entrée garder · esc annuler"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Aller à…",
|
||||
"noResults": "Rien ne correspond à “{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Raccourcis clavier",
|
||||
"hint": "Ctrl+? · Échap pour fermer"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Cet écran n'a pas pu s'afficher",
|
||||
"description": "Rien n'a été perdu — c'est un échec d'affichage, pas un échec de données. Les transcriptions de l'agent, les jobs et les fichiers sont intacts.",
|
||||
"unknownError": "Erreur inconnue",
|
||||
"tryAgain": "Réessayer",
|
||||
"reload": "Recharger",
|
||||
"goHome": "Retour à l'accueil",
|
||||
"resetHint": "Si cela échoue à nouveau au même endroit, une disposition de panneaux enregistrée en est généralement la cause. La réinitialisation remet les panneaux de tous les écrans à leurs valeurs par défaut.",
|
||||
"dashboardsSafe": "Vos Dashboards ne sont pas touchés.",
|
||||
"resetting": "Réinitialisation…",
|
||||
"resetConfirm": "Oui — réinitialiser la disposition de tous les écrans",
|
||||
"reset": "Réinitialiser les dispositions des écrans"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Réanalyser les items",
|
||||
"title": "Réanalyser officer-items (appliquer les changements sans redémarrer)",
|
||||
"failed": "Échec de la réanalyse des items",
|
||||
"done": "Items réanalysés — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} tâche",
|
||||
"other": "{{count}} tâches"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agent",
|
||||
"other": "{{count}} agents"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} outil",
|
||||
"other": "{{count}} outils"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} processus",
|
||||
"other": "{{count}} processus"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "S'applique aux nouveaux Chats — un Chat déjà ouvert garde ce avec quoi il a démarré.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill n'a pas de description, donc elle n'est proposée à aucun agent.",
|
||||
"other": "{{count}} skills n'ont pas de description, donc elles ne sont proposées à aucun agent."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Signaler un bug",
|
||||
"title": "Signaler un bug",
|
||||
"describePlaceholder": "Décrivez ce qui s'est mal passé...",
|
||||
"screenshot": "Capture d'écran",
|
||||
"noScreenshot": "Aucune capture d'écran effectuée",
|
||||
"collectedNote": "Les journaux de la console, les informations du navigateur et les erreurs API récentes sont collectés automatiquement.",
|
||||
"submitted": "Signalement de bug envoyé",
|
||||
"failed": "Échec de l'envoi du signalement de bug",
|
||||
"submit": "Envoyer"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Content de vous revoir",
|
||||
"signInSubtitle": "Connectez-vous à votre compte",
|
||||
"emailOrUsername": "E-mail ou nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"forgotPassword": "Mot de passe oublié ?",
|
||||
"emailPlaceholder": "vous@exemple.com",
|
||||
"passwordPlaceholder": "Votre mot de passe",
|
||||
"forgotTitle": "Mot de passe oublié",
|
||||
"forgotSubtitle": "Saisissez votre e-mail pour recevoir un lien de réinitialisation",
|
||||
"email": "E-mail",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"checkEmail": "Consultez votre e-mail",
|
||||
"checkEmailHint": "Cliquez dessus pour réinitialiser votre mot de passe.",
|
||||
"resetOk": "Mot de passe réinitialisé avec succès. Veuillez vous connecter.",
|
||||
"verifying": "Vérification…",
|
||||
"invalidLink": "Lien invalide",
|
||||
"invalidLinkHint": "Ce lien de réinitialisation est invalide ou a expiré.",
|
||||
"resetTitle": "Réinitialiser le mot de passe",
|
||||
"resetSubtitle": "Saisissez votre nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"minChars": "12 caractères minimum",
|
||||
"confirmPlaceholder": "Confirmez votre mot de passe",
|
||||
"bootstrapTitle": "Bienvenue, Administrateur",
|
||||
"bootstrapSubtitle": "Créez votre compte administrateur",
|
||||
"name": "Nom",
|
||||
"username": "Nom d'utilisateur",
|
||||
"namePlaceholder": "Votre nom",
|
||||
"adminEmailPlaceholder": "admin@exemple.com",
|
||||
"bootstrapOk": "Compte administrateur créé. Veuillez vous connecter.",
|
||||
"debug": {
|
||||
"controls": "Contrôles :",
|
||||
"camera": "Caméra :",
|
||||
"model": "Modèle :"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navigation",
|
||||
"spotlight": "Spotlight — aller à n'importe quel écran, plugin ou paramètre par son nom",
|
||||
"thisList": "Cette liste",
|
||||
"themes": "Thèmes",
|
||||
"themesNote": "L'explorateur applique le thème au fur et à mesure que vous vous déplacez, pour que vous voyiez l'application changer plutôt que de lire des noms.",
|
||||
"openBrowser": "Ouvrir l'explorateur de thèmes",
|
||||
"prevNextTheme": "Thème précédent / suivant, sans rien ouvrir",
|
||||
"starCurrent": "Marquer ou démarquer le thème actuel, depuis n'importe où",
|
||||
"inBrowser": "Dans l'explorateur de thèmes",
|
||||
"moveList": "Parcourir la liste",
|
||||
"applyKeepBrowsing": "Appliquer le thème en focus et continuer à explorer",
|
||||
"restoreStart": "Rétablir le thème sur lequel vous avez commencé",
|
||||
"starFocused": "Marquer ou démarquer le thème en focus",
|
||||
"keepLooking": "Garder ce que vous regardez",
|
||||
"cancelRestore": "Annuler et restaurer",
|
||||
"inSpotlight": "Dans Spotlight",
|
||||
"moveResults": "Parcourir les résultats",
|
||||
"goThere": "Y aller",
|
||||
"close": "Fermer",
|
||||
"chat": "Chat",
|
||||
"chatNote": "Le rappel n'utilise la touche fléchée que sur un compositeur VIDE, et toute autre touche met fin au rappel — un brouillon n'est ainsi jamais écrasé.",
|
||||
"prevNextPrompt": "Prompt précédent / suivant que vous avez envoyé, à la manière d'un shell",
|
||||
"send": "Envoyer · sur un compositeur vide avec quelque chose en file, envoyer cela maintenant",
|
||||
"newline": "Nouvelle ligne",
|
||||
"stopTurn": "Arrêter le tour en cours de génération"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "En cours et en file d'attente",
|
||||
"loading": "Chargement…",
|
||||
"nothingRunning": "Rien en cours",
|
||||
"history": "Historique",
|
||||
"clearAll": "Tout effacer",
|
||||
"selectJob": "Sélectionner un job",
|
||||
"notFound": "Job introuvable.",
|
||||
"backToJobs": "Retour aux jobs",
|
||||
"stop": "Arrêter",
|
||||
"testNotification": "Envoyer une notification de test à tous les canaux configurés",
|
||||
"search": "Rechercher…",
|
||||
"deleteFinished": "Supprimer tous les jobs terminés",
|
||||
"noChannels": "Aucun canal configuré",
|
||||
"notifyFailed": "Échec de l'envoi de la notification",
|
||||
"stopRequested": "Arrêt demandé",
|
||||
"stopFailed": "Échec de l'arrêt",
|
||||
"titles": "Titres",
|
||||
"download": "Télécharger",
|
||||
"input": "Entrée",
|
||||
"output": "Sortie",
|
||||
"steps": "Étapes",
|
||||
"selectStep": "Sélectionnez une étape ou une sous-tâche pour voir la sortie",
|
||||
"noOutput": "Aucune sortie pour l'instant",
|
||||
"tokens": "Tokens :",
|
||||
"cost": "Coût :",
|
||||
"status": {
|
||||
"running": "En cours",
|
||||
"queued": "En file d'attente",
|
||||
"completed": "Terminé",
|
||||
"failed": "Échoué",
|
||||
"stopped": "Arrêté",
|
||||
"interrupted": "Interrompu"
|
||||
},
|
||||
"failedWithCode": "Échoué (sortie {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activité",
|
||||
"backgroundTasks": "Tâches en arrière-plan",
|
||||
"detached": "Détachée",
|
||||
"backToList": "Retour à la liste",
|
||||
"selectTask": "Sélectionnez une tâche pour suivre sa sortie en direct",
|
||||
"noneRunning": "aucune en cours"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Configurez votre passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Impossible de charger cette conversation : {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Aucun résultat",
|
||||
"emptyFile": "Fichier vide",
|
||||
"createFailed": "Impossible de créer le {{kind}}",
|
||||
"deleteFailed": "Impossible de supprimer le {{kind}}",
|
||||
"searchPlaceholder": "Rechercher {{kind}}…",
|
||||
"selectOne": "Sélectionnez un {{kind}}",
|
||||
"selectToView": "Sélectionnez un {{kind}} pour voir son contenu",
|
||||
"delete": "Supprimer",
|
||||
"createPrompt": "Aide-moi à créer le contenu de ce nouveau fichier {{kind}}",
|
||||
"improvePrompt": "Aide-moi à comprendre et améliorer ce fichier {{kind}}"
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import deDE from './de-DE.json';
|
||||
import esES from './es-ES.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
|
||||
// not a warning — there is no regional fallback to paper over the gap.
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and another locale lacks is a red
|
||||
// build, not a warning — there is no regional fallback to paper over the gap.
|
||||
describe('officer-web (app.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
test('every shipped locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT, 'fr-FR': frFR, 'de-DE': deDE, 'es-ES': esES })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+31
-17
@@ -62,8 +62,11 @@ check(
|
||||
'ck_server_integrations_provider',
|
||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||
sql`${table.provider} IN (${sql.join(SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)), sql`, `)})`,
|
||||
)
|
||||
sql`${table.provider} IN (${sql.join(
|
||||
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
```
|
||||
|
||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||
@@ -77,7 +80,7 @@ drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back fro
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive *"do you want to truncate?"* prompt
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
@@ -87,7 +90,7 @@ stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique *constraint* there. Nothing here has a composite foreign-key target; check before
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
@@ -99,22 +102,33 @@ longer version and re-creates the constraint on every push.
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Naming it explicitly does not help. Leave it.
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**Four foreign keys are over 63 characters and churn the same way**, found 2026-08-19 while pushing
|
||||
`chat_notification_prefs`. These are the case the paragraph above says to name explicitly, and none of
|
||||
them is:
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
command_hq_session_tags_session_row_id_command_hq_sessions_id_fk 64
|
||||
command_hq_watcher_fires_session_row_id_command_hq_sessions_id_fk 65
|
||||
bitcoin_wallet_frozen_utxos_wallet_id_bitcoin_wallet_wallets_id_fk 66
|
||||
bitcoin_wallet_chain_cache_wallet_id_bitcoin_wallet_wallets_id_fk 65
|
||||
The six, and where each lives:
|
||||
|
||||
The tell is in the plan itself: the `DROP` names them at 63 characters and the `ADD` at their full
|
||||
length, which is Postgres's truncation and drizzle's copy disagreeing. Eight statements per push, no
|
||||
prompt, data unaffected — deferred rather than fixed (see `TODO.md`), because it is noise rather than
|
||||
a fault. Two live in `officer_db/src/command-hq/schema.ts`; the other two are the bitcoin-wallet
|
||||
plugin's, in its own repository.
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
|
||||
@@ -20,10 +20,11 @@ export {
|
||||
cleanupExpiredTokens,
|
||||
} from './queries';
|
||||
|
||||
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
|
||||
// column definition is the only place that list should exist.
|
||||
export { USER_ROLES, OWNER_USER_ID } from './schema';
|
||||
export { OWNER_USER_ID } from './schema';
|
||||
|
||||
// A role list is no longer enumerable at the type level — see the comment on `UserRole` in schema.ts.
|
||||
// The API and the UI enumerate roles by querying the `roles` table (`listRoles` in ../roles), not by
|
||||
// importing a value from here.
|
||||
export type { UserRole } from './schema';
|
||||
export { listProvisionableUsers } from './queries';
|
||||
export type { ProvisionableUser } from './queries';
|
||||
|
||||
@@ -85,14 +85,18 @@ export async function getUserByUsername(username: string): Promise<UserSelect |
|
||||
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
||||
// sidecar refusing to start beats it silently running as the wrong person.
|
||||
//
|
||||
// `order by id` is not cosmetic. Without it, two rows holding the role would make "who owns this
|
||||
// server" whatever Postgres happened to return first — and that answer feeds the agent sidecar's
|
||||
// identity, the vault and origin scoping. The write paths refuse to create a second Super Admin
|
||||
// (create-user.ts and updateUserRoleHandler), so this should never have a choice to make; ordering is
|
||||
// what makes the outcome deterministic if one ever gets in by another route, and id 1 is the bootstrap
|
||||
// account the CHECK constraint already pins.
|
||||
// The owner is `OWNER_USER_ID`, so there is nothing to disambiguate and no ordering to get right.
|
||||
//
|
||||
// This used to select on `role = 'Super Admin'` with `order by id` as a tiebreak, because two rows could
|
||||
// hold the role and "who owns this server" would otherwise be whatever Postgres returned first — an answer
|
||||
// that feeds the agent sidecar's identity, the vault and origin scoping. The write paths refused a second
|
||||
// Super Admin to keep it single.
|
||||
//
|
||||
// Since 2026-08-23 ownership is an identity, so several accounts may hold `Super Admin` and none of them
|
||||
// becomes the owner by doing so. The tiebreak, the refusals and the CHECK that pinned user 1's role all
|
||||
// existed to protect an answer that is now a primary key.
|
||||
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).orderBy(users.id).limit(1);
|
||||
const [user] = await db.select().from(users).where(eq(users.id, OWNER_USER_ID)).limit(1);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check, foreignKey } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { roles } from '../roles/schema';
|
||||
|
||||
// The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table,
|
||||
// so the first account created is always 1. Exported so the CHECK below and the delete guard in
|
||||
// queries/auth.ts state the number once between them.
|
||||
export const OWNER_USER_ID = 1;
|
||||
|
||||
// Roles, in descending authority. Exported as a value so the API and the UI can enumerate them from
|
||||
// the same place the column is defined — a second hand-written list of role names is how they drift.
|
||||
//
|
||||
// A text column with a TS enum rather than a Postgres enum type, matching `status` below: the set is
|
||||
// still settling, and adding a role should be a one-line schema change that `db:push` applies, not an
|
||||
// ALTER TYPE. Postgres will not reject an unknown string — the TS type is the guard.
|
||||
//
|
||||
// 'Super Admin' is the server owner, and this column is the ONLY thing that says so. isSuperAdmin()
|
||||
// and getOwnerUser() both read it; there is no SUPER_ADMIN_EMAIL and no "first account wins" fallback
|
||||
// any more. Two consequences worth holding on to: bootstrap must set it explicitly on the first
|
||||
// account, and demoting the last Super Admin would leave the platform with no owner. The CHECK on the
|
||||
// table below stops that for the bootstrap account specifically: user 1 cannot be anything else.
|
||||
export const USER_ROLES = ['Super Admin', 'Admin', 'Member', 'Developer'] as const;
|
||||
export type UserRole = (typeof USER_ROLES)[number];
|
||||
/**
|
||||
* Was a TS union (`(typeof USER_ROLES)[number]`) backed by a hardcoded four-role array. Since
|
||||
* docs/roles-and-teams.md phase 2 part 4, roles are data — the `roles` table, administered through
|
||||
* `/api/roles` — and `users.role` is a foreign key onto it rather than a column typed to a fixed set. A
|
||||
* role this column can legally hold is therefore not enumerable at the type level any more, so `UserRole`
|
||||
* is plain `string` and the compile-time guard it used to provide moves to a runtime check at the write
|
||||
* path: `getRole(name)` returning a row before a caller is allowed to assign or grant that name. See
|
||||
* `create-user.ts`, `manage-users.ts` and `permissions-routes.ts`.
|
||||
*
|
||||
* 'Super Admin' is still the server owner's role by name, and this column is the ONLY thing that says so
|
||||
* — isSuperAdmin() and getOwnerUser() both read it. Two consequences worth holding on to: bootstrap must
|
||||
* set it explicitly on the first account, and demoting the last Super Admin would leave the platform with
|
||||
* no owner. The CHECK on the table below stops that for the bootstrap account specifically: user 1 cannot
|
||||
* be anything else.
|
||||
*/
|
||||
export type UserRole = string;
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
@@ -31,8 +34,9 @@ export const users = pgTable(
|
||||
.notNull()
|
||||
.default('Unverified'),
|
||||
// Defaults to the least privileged role so a row created by any path that does not think about
|
||||
// authorisation cannot accidentally mint an admin.
|
||||
role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
|
||||
// authorisation cannot accidentally mint an admin. Foreign key onto `roles.name` is declared below,
|
||||
// in the table's third argument — see `fk_users_role`.
|
||||
role: text('role').notNull().default('Member'),
|
||||
name: text('name'),
|
||||
/**
|
||||
* Required, and unique case-INSENSITIVELY — see the index below.
|
||||
@@ -93,6 +97,15 @@ export const users = pgTable(
|
||||
// "some row must be Super Admin" — that would need to see other rows. So this pins the bootstrap
|
||||
// account and nothing more; promoting or demoting anyone else is still free.
|
||||
//
|
||||
// KEPT after ownership stopped being a role, 2026-08-23, and `docs/roles-and-teams.md` phase 1 said to
|
||||
// remove it as redundant. It is redundant for the question it was written for — `isOwner` is now an id
|
||||
// comparison and does not read this column at all — but it still earns its place for a different one:
|
||||
// it stops user 1 being set to `Member` while remaining the owner, which is a state with no benefit and
|
||||
// a confusing display. A constraint that costs nothing and forbids a nonsense state is worth keeping
|
||||
// even after the reason it was added has gone.
|
||||
//
|
||||
// What it must NOT be mistaken for is the thing that makes user 1 the owner. It does not, any more.
|
||||
//
|
||||
// It cannot stop a DELETE either. deleteUser() in queries/auth.ts refuses id 1 for that half.
|
||||
//
|
||||
// sql.raw for the literal — an interpolated JS string binds as `$1`, and Postgres will not accept a
|
||||
@@ -101,6 +114,20 @@ export const users = pgTable(
|
||||
'ck_users_owner_is_super_admin',
|
||||
sql`${table.id} <> ${sql.raw(String(OWNER_USER_ID))} OR ${table.role} = ${sql.raw("'Super Admin'")}`,
|
||||
),
|
||||
// docs/roles-and-teams.md D5 and phase 2 part 2. Named explicitly rather than left to drizzle's
|
||||
// generated name — see src/databases/CLAUDE.md → "Composite keys" on why an over-long generated FK
|
||||
// name gets truncated by Postgres and then re-created on every push; named up front so this one is
|
||||
// never at risk of it regardless of length.
|
||||
//
|
||||
// ON UPDATE CASCADE only, no ON DELETE: a role cannot be deleted while any account holds it
|
||||
// (`deleteRole` in roles/queries.ts enforces D4), so there is no delete path for this to react to —
|
||||
// and leaving it at Postgres's default (effectively NO ACTION) makes the constraint itself a backstop
|
||||
// for that guard rather than silently nulling or cascading a delete the application never intended.
|
||||
foreignKey({
|
||||
name: 'fk_users_role',
|
||||
columns: [table.role],
|
||||
foreignColumns: [roles.name],
|
||||
}).onUpdate('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
@@ -145,14 +146,23 @@ export const commandHqWatcherFires = pgTable(
|
||||
'command_hq_watcher_fires',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
sessionRowId: integer('session_row_id').notNull(),
|
||||
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
|
||||
taskId: text('task_id').notNull(),
|
||||
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt)],
|
||||
(table) => [
|
||||
// Named, because drizzle's generated name is 65 characters and Postgres truncates identifiers at 63 —
|
||||
// so it stores the short form, drizzle keeps comparing against its own long one, and every push drops
|
||||
// and re-adds an identical constraint. Documented in src/databases/CLAUDE.md and left unfixed until a
|
||||
// deploy plan said "expect no changes" and this made that unsatisfiable on all five hosts.
|
||||
foreignKey({
|
||||
name: 'fk_command_hq_watcher_fires_session',
|
||||
columns: [table.sessionRowId],
|
||||
foreignColumns: [commandHqSessions.id],
|
||||
}).onDelete('cascade'),
|
||||
index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -198,14 +208,20 @@ export const commandHqTags = pgTable(
|
||||
export const commandHqSessionTags = pgTable(
|
||||
'command_hq_session_tags',
|
||||
{
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
sessionRowId: integer('session_row_id').notNull(),
|
||||
tagId: integer('tag_id')
|
||||
.notNull()
|
||||
.references(() => commandHqTags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
||||
(table) => [
|
||||
// 64 characters generated; see the note on `fk_command_hq_watcher_fires_session`.
|
||||
foreignKey({
|
||||
name: 'fk_command_hq_session_tags_session',
|
||||
columns: [table.sessionRowId],
|
||||
foreignColumns: [commandHqSessions.id],
|
||||
}).onDelete('cascade'),
|
||||
uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pgTable, serial, text, timestamp, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, text, timestamp, uniqueIndex, check, foreignKey } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { USER_ROLES } from '../auth/schema';
|
||||
import { roles } from '../roles/schema';
|
||||
|
||||
// What a ROLE may reach. The subject of a grant is a role, never a user.
|
||||
//
|
||||
@@ -8,8 +8,8 @@ import { USER_ROLES } from '../auth/schema';
|
||||
// look more flexible and are the obvious next request. Per-user grants make "what can this person do" a
|
||||
// question you answer by reading rows, and "what can a Member do" a question with no answer at all. Roles
|
||||
// keep both answerable: onboarding is picking a role, and auditing is reading one short table. When the
|
||||
// answer really has to differ for one person, that person needs a role, and adding one is a line in
|
||||
// USER_ROLES.
|
||||
// answer really has to differ for one person, that person needs a role, and adding one is a row in
|
||||
// `roles` (see roles/schema.ts and /api/roles).
|
||||
//
|
||||
// A MISSING ROW MEANS NO ACCESS. There is no row that denies; absence denies. This is what makes the table
|
||||
// safe to read as the whole truth — an empty table is a platform where members can reach nothing but their
|
||||
@@ -27,7 +27,8 @@ export const rolePermissions = pgTable(
|
||||
'role_permissions',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
role: text('role', { enum: USER_ROLES }).notNull(),
|
||||
// Foreign key onto `roles.name` is declared below — see `fk_role_permissions_role`.
|
||||
role: text('role').notNull(),
|
||||
/** A permission key from the registry. Validated by the API, not by a constraint — see above. */
|
||||
permission: text('permission').notNull(),
|
||||
/**
|
||||
@@ -43,16 +44,6 @@ export const rolePermissions = pgTable(
|
||||
// re-creates them on every push, which stops db:push on an unanswerable truncate prompt. See
|
||||
// src/databases/CLAUDE.md → "Composite keys".
|
||||
uniqueIndex('uq_role_permissions_role_permission').on(table.role, table.permission),
|
||||
// Postgres will not reject an unknown string in a text column just because TypeScript narrowed it, so
|
||||
// the legal sets are stated here too. sql.raw for the literals: an interpolated JS string binds as a
|
||||
// parameter and Postgres refuses a parameter inside a CHECK, which breaks push for the WHOLE schema.
|
||||
check(
|
||||
'ck_role_permissions_role',
|
||||
sql`${table.role} IN (${sql.join(
|
||||
USER_ROLES.map((r) => sql.raw(`'${r}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
check(
|
||||
'ck_role_permissions_level',
|
||||
sql`${table.level} IN (${sql.join(
|
||||
@@ -64,5 +55,19 @@ export const rolePermissions = pgTable(
|
||||
// short-circuits every check. A 'Super Admin' row here would be inert, and an inert row that looks
|
||||
// meaningful is worse than no row: someone would eventually revoke one and expect it to do something.
|
||||
check('ck_role_permissions_not_owner', sql`${table.role} <> ${sql.raw("'Super Admin'")}`),
|
||||
// docs/roles-and-teams.md D5 and phase 2 part 2. This REPLACES `ck_role_permissions_role`, which used
|
||||
// to list the four built-in role names literally (`role IN ('Super Admin', 'Admin', ...)`). That check
|
||||
// was already wrong the moment role CRUD (phase 2 part 3, `/api/roles`) could create a role at runtime
|
||||
// — a grant for a new role would have been refused by a constraint enforcing a snapshot of a list that
|
||||
// no longer described the actual role set. A foreign key onto the actual table is the correct
|
||||
// enforcement of "must be a real role" and needs no updating when a role is added.
|
||||
//
|
||||
// Named explicitly for the same reason as `fk_users_role` — see the comment there and
|
||||
// src/databases/CLAUDE.md → "Composite keys".
|
||||
foreignKey({
|
||||
name: 'fk_role_permissions_role',
|
||||
columns: [table.role],
|
||||
foreignColumns: [roles.name],
|
||||
}).onUpdate('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,2 +1,13 @@
|
||||
export { roles } from './schema';
|
||||
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
|
||||
export {
|
||||
listRoles,
|
||||
getRole,
|
||||
seedRole,
|
||||
createRole,
|
||||
renameRole,
|
||||
deleteRole,
|
||||
type RoleRow,
|
||||
type CreateRoleResult,
|
||||
type RenameRoleResult,
|
||||
type DeleteRoleResult,
|
||||
} from './queries';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../auth/schema';
|
||||
import { roles } from './schema';
|
||||
@@ -59,3 +59,119 @@ export async function seedRole(row: {
|
||||
.returning({ name: roles.name });
|
||||
return inserted.length > 0;
|
||||
}
|
||||
|
||||
// ── Write half: role CRUD ─────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every function here returns a discriminated result instead of throwing, because the router has to give
|
||||
// a person a reason they can act on ("Developer is a system role" vs "3 accounts still hold it" are two
|
||||
// different screens), and a caught exception only carries a string to parse. The one thing each guard
|
||||
// shares: it re-checks its own invariant inside the write, not just before it, so a race between the
|
||||
// check and the statement fails closed rather than silently succeeding.
|
||||
|
||||
/** How many accounts hold this role. Used by `deleteRole` — D4 refuses to delete a role anyone holds. */
|
||||
async function roleHolderCount(name: string): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(eq(users.role, name));
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export type CreateRoleResult = { ok: true; role: RoleRow } | { ok: false; reason: 'invalid-name' | 'duplicate' };
|
||||
|
||||
/**
|
||||
* Create a role at runtime. `isSystem` is always false here — a role the platform's own code names by
|
||||
* string (Developer, Super Admin, Member; see the schema comment) is one seeded at startup, never one
|
||||
* typed into a form, and nothing reachable through this function may claim otherwise.
|
||||
*/
|
||||
export async function createRole(input: { name: string; description?: string | null }): Promise<CreateRoleResult> {
|
||||
const name = input.name.trim();
|
||||
if (!name) return { ok: false, reason: 'invalid-name' };
|
||||
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name, description: input.description?.trim() || null, isSystem: false })
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning();
|
||||
const role = inserted[0];
|
||||
if (!role) return { ok: false, reason: 'duplicate' };
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type RenameRoleResult =
|
||||
| { ok: true; role: RoleRow }
|
||||
| { ok: false; reason: 'not-found' | 'system' | 'invalid-name' | 'duplicate' };
|
||||
|
||||
/**
|
||||
* Rename a role. Refuses outright on `isSystem` (D4 in docs/roles-and-teams.md): `rolePermitsDevTools()`
|
||||
* tests for `'Developer'` by string, `bootstrap.ts` writes `'Super Admin'`, and `users.role` defaults to
|
||||
* `'Member'`. Renaming one of those four is a code change across three call sites that has not happened,
|
||||
* not a data edit — so this refuses rather than quietly breaking them.
|
||||
*
|
||||
* `users.role` and `role_permissions.role` are foreign keys onto this table with `ON UPDATE CASCADE`
|
||||
* (phase 2 part 2), so the `UPDATE` below moves every holder and every grant automatically, in the same
|
||||
* statement, as Postgres's own job rather than this function's. There used to be a guard here refusing the
|
||||
* rename while anyone held the role, because before the FK existed a rename would have orphaned them —
|
||||
* silently pointing every holder at a name that no longer existed. That guard is gone now that the
|
||||
* database itself does the moving.
|
||||
*/
|
||||
export async function renameRole(from: string, to: string): Promise<RenameRoleResult> {
|
||||
const name = to.trim();
|
||||
if (!name) return { ok: false, reason: 'invalid-name' };
|
||||
|
||||
const existing = await getRole(from);
|
||||
if (!existing) return { ok: false, reason: 'not-found' };
|
||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||
if (name === from) return { ok: true, role: existing };
|
||||
|
||||
const clash = await getRole(name);
|
||||
if (clash) return { ok: false, reason: 'duplicate' };
|
||||
|
||||
// `isSystem: false` in the WHERE is the re-check: belt and suspenders against a role becoming system
|
||||
// between the read above and this write.
|
||||
const [role] = await db
|
||||
.update(roles)
|
||||
.set({ name })
|
||||
.where(and(eq(roles.name, from), eq(roles.isSystem, false)))
|
||||
.returning();
|
||||
if (!role) return { ok: false, reason: 'system' };
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type DeleteRoleResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'not-found' | 'system' | 'in-use'; holderCount?: number };
|
||||
|
||||
/**
|
||||
* Delete a role. Refuses if it is system-named (D4), and refuses if any account currently holds it.
|
||||
*
|
||||
* Never reassigns holders to a fallback — silently moving accounts onto a different role's grants is a
|
||||
* permission change nobody asked for, and "absence denies" is the posture everywhere else in this system.
|
||||
* The holder count and the delete run in one transaction so a role cannot be handed to someone in the gap
|
||||
* between counting and deleting.
|
||||
*/
|
||||
export async function deleteRole(name: string): Promise<DeleteRoleResult> {
|
||||
const existing = await getRole(name);
|
||||
if (!existing) return { ok: false, reason: 'not-found' };
|
||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
// Inline rather than a call to `roleHolderCount`: this one has to run inside the same transaction as
|
||||
// the delete below, so a role cannot be handed to someone in the gap between counting and deleting.
|
||||
const [holders] = await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(eq(users.role, name));
|
||||
const count = holders?.count ?? 0;
|
||||
if (count > 0) return { ok: false, reason: 'in-use', holderCount: count };
|
||||
|
||||
// Same re-check as renameRole, same reason: the row must not vanish out from under a role that
|
||||
// became system between the read above and this statement.
|
||||
const deleted = await tx
|
||||
.delete(roles)
|
||||
.where(and(eq(roles.name, name), eq(roles.isSystem, false)))
|
||||
.returning({ name: roles.name });
|
||||
if (!deleted.length) return { ok: false, reason: 'system' };
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
+16
-5
@@ -16,7 +16,6 @@ import { resolveAuthToken } from './servers/auth-token';
|
||||
import { isWsProviderAllowed } from './servers/permissions/authorize';
|
||||
import { isTokenBlacklisted, getUserById } from 'officerdb';
|
||||
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||
import { minesweeperWebsocket } from './servers/api/minesweeper/websocket';
|
||||
import { chatWebsocket } from './servers/api/chat/websocket';
|
||||
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
|
||||
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
|
||||
@@ -30,6 +29,7 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod
|
||||
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
||||
import { toShellUsername } from './servers/data-path';
|
||||
import { devProxyWebsocket, upgradeDevProxyWs } from './servers/api/dev-proxy/websocket';
|
||||
import { pluginWsWebsocket, upgradePluginWs } from './servers/api/plugin-ws/websocket';
|
||||
|
||||
// Build static file routes from public/
|
||||
// A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file`
|
||||
@@ -156,11 +156,11 @@ async function handleSidecarQueueCommand(ws: ServerWebSocket<WSData>, msg: Recor
|
||||
const handlers: Record<string, any> = {
|
||||
terminal: terminalWebsocket,
|
||||
'dev-proxy': devProxyWebsocket,
|
||||
'plugin-ws': pluginWsWebsocket,
|
||||
chat: chatWebsocket,
|
||||
'chat-notify': chatNotifyWebsocket,
|
||||
'task-runner': taskRunnerWebsocket,
|
||||
pipeline: pipelineWebsocket,
|
||||
minesweeper: minesweeperWebsocket,
|
||||
sidecar: sidecarWebsocket,
|
||||
};
|
||||
|
||||
@@ -187,7 +187,6 @@ const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
'/api/tasks/run/ws': 'task-runner',
|
||||
'/api/tasks/pipeline/ws': 'pipeline',
|
||||
'/api/terminal/ws': 'terminal',
|
||||
'/api/minesweeper/ws': 'minesweeper',
|
||||
'/api/chat/ws': 'chat',
|
||||
'/api/chat/notify/ws': 'chat-notify',
|
||||
};
|
||||
@@ -195,7 +194,7 @@ const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
|
||||
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
|
||||
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
|
||||
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
|
||||
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy', 'plugin-ws'])];
|
||||
|
||||
/*
|
||||
Both doors into the platform, checked against the permission registry before either opens.
|
||||
@@ -366,7 +365,7 @@ console.log(
|
||||
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
|
||||
* deliberately absent — they upgrade through their own handlers, not through here.
|
||||
*/
|
||||
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'minesweeper';
|
||||
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
|
||||
|
||||
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
@@ -487,6 +486,18 @@ const server = serve({
|
||||
if (req.headers.get('upgrade') === 'websocket') return upgradeDevProxyWs(req, server);
|
||||
return honoServer.fetch(req, server);
|
||||
},
|
||||
/*
|
||||
Every plugin's socket, through one wildcard declared at boot.
|
||||
|
||||
This is the route that made a plugin websocket possible at all. `WS_ROUTES` is read once before
|
||||
`serve()`, so nothing installed later can add an entry to it — but a wildcard does not need to,
|
||||
because the plugin name is a path segment rather than a route. The same trick `dev-proxy` above has
|
||||
been using all along; it simply had no second consumer until now.
|
||||
*/
|
||||
'/api/plugin-ws/*': (req: Request, server: any) => {
|
||||
if (req.headers.get('upgrade') === 'websocket') return upgradePluginWs(req, server);
|
||||
return honoServer.fetch(req, server);
|
||||
},
|
||||
'/api/sidecar/register': (req: Request, server: any) => {
|
||||
const ok = server.upgrade(req, {
|
||||
data: { provider: 'sidecar', userId: 0, email: '', username: '' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||
import { getUserCount, createUser, replaceRoleGrants, listRoles } from 'officerdb';
|
||||
import { DEFAULT_ROLE_PERMISSIONS } from '@@/permissions/registry';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
@@ -49,12 +49,21 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
||||
// bootstrap is the one moment that happens exactly once per install, so seeding cannot fight a later
|
||||
// revocation — take one of these away and nothing puts it back.
|
||||
//
|
||||
// Read from the `roles` table rather than the old USER_ROLES list (docs/roles-and-teams.md phase 2 part
|
||||
// 4) — `scripts/seed-roles.ts` runs as part of `postdb:push`, before the server ever serves a request,
|
||||
// so by the time this handler can run the four built-in rows already exist. Filtered to `isSystem`
|
||||
// deliberately: this is seeding the platform's own baseline roles, not every role that happens to exist
|
||||
// — a role created at runtime through /api/roles before anyone has bootstrapped (which `roles` being
|
||||
// owner-gated makes unreachable today, but nothing here should assume that stays true) gets no grants
|
||||
// from this loop, same as it would get none from any other seed step.
|
||||
//
|
||||
// Non-fatal. An owner who exists but whose roles hold nothing is a working server with a one-click fix;
|
||||
// failing bootstrap over it would leave a platform with no account at all.
|
||||
try {
|
||||
for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) {
|
||||
const builtIn = (await listRoles()).filter((r) => r.isSystem && r.name !== 'Super Admin');
|
||||
for (const role of builtIn) {
|
||||
await replaceRoleGrants(
|
||||
role,
|
||||
role.name,
|
||||
DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ permission, level: 'write' as const })),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { sessionStatus, startSession, stopSession } from './session';
|
||||
|
||||
/**
|
||||
* Session control. The pixels do not come through here — they come through the websocket relay; this
|
||||
* is only "is it up, bring it up, take it down", plus the window size the panel needs to size its
|
||||
* canvas.
|
||||
*/
|
||||
export const minesweeperRouter = createRouter();
|
||||
|
||||
minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus()));
|
||||
|
||||
minesweeperRouter.post('/start', async (c) => {
|
||||
const status = await sessionStatus();
|
||||
if (!status.installed) {
|
||||
return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501);
|
||||
}
|
||||
if (status.running) return c.json(status);
|
||||
|
||||
const result = await startSession();
|
||||
if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500);
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
|
||||
minesweeperRouter.post('/stop', async (c) => {
|
||||
await stopSession();
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ROOT } from '../../data-path';
|
||||
|
||||
/**
|
||||
* The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that
|
||||
* display on loopback.
|
||||
*
|
||||
* The platform does not implement any of that — it drives `bin/session.sh`, which is the same script
|
||||
* used by hand. One implementation, so a session started from the browser and one started from a
|
||||
* terminal are the same session with the same lifecycle.
|
||||
*/
|
||||
|
||||
export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper');
|
||||
const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh');
|
||||
|
||||
export const VNC_HOST = '127.0.0.1';
|
||||
export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907);
|
||||
const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7';
|
||||
|
||||
export type WindowGeometry = { width: number; height: number; x: number; y: number };
|
||||
export type SessionStatus = {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
/** Absent until the game has drawn its window. */
|
||||
window: WindowGeometry | null;
|
||||
};
|
||||
|
||||
const run = async (cmd: string[], env: Record<string, string> = {}): Promise<{ ok: boolean; out: string }> => {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: MINESWEEPER_DIR,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, out: `${out}${err}` };
|
||||
};
|
||||
|
||||
export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0;
|
||||
|
||||
/**
|
||||
* Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting.
|
||||
*
|
||||
* This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a
|
||||
* new connection to decide whether it is a WebSocket handshake, and that peek blocks its main loop
|
||||
* until the client speaks or the peek times out. A health check that connects and says nothing is
|
||||
* therefore a small stall, and the panel polls status on a timer — so the stalls overlapped, the accept
|
||||
* backlog filled (Recv-Q 32/32), and the server stopped accepting the real viewer too.
|
||||
*
|
||||
* The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading
|
||||
* /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer.
|
||||
*/
|
||||
async function isServing(): Promise<boolean> {
|
||||
const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0');
|
||||
const LISTEN = '0A';
|
||||
|
||||
for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
||||
const text = await Bun.file(table)
|
||||
.text()
|
||||
.catch(() => '');
|
||||
|
||||
for (const line of text.split('\n').slice(1)) {
|
||||
const columns = line.trim().split(/\s+/);
|
||||
const local = columns[1];
|
||||
const state = columns[3];
|
||||
if (!local || state !== LISTEN) continue;
|
||||
if (local.split(':')[1] === wanted) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The game window's size, so the browser can size its canvas to the board rather than to the whole
|
||||
* framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is
|
||||
* reported anyway — a non-zero one means something moved it and the canvas would be misaligned.
|
||||
*
|
||||
* The board changes size when the difficulty changes, which is why this is polled rather than read
|
||||
* once at connect.
|
||||
*/
|
||||
export async function windowGeometry(): Promise<WindowGeometry | null> {
|
||||
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY });
|
||||
if (!search.ok) return null;
|
||||
|
||||
let best: WindowGeometry | null = null;
|
||||
for (const id of search.out.trim().split('\n').filter(Boolean)) {
|
||||
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY });
|
||||
if (!geom.ok) continue;
|
||||
|
||||
const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN);
|
||||
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
|
||||
if (Object.values(candidate).some(Number.isNaN)) continue;
|
||||
|
||||
// wine creates a 1x1 owner stub alongside the real window; the larger one is the game.
|
||||
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
|
||||
}
|
||||
return best && best.width > 1 ? best : null;
|
||||
}
|
||||
|
||||
export async function sessionStatus(): Promise<SessionStatus> {
|
||||
const installed = isInstalled();
|
||||
if (!installed) return { installed, running: false, window: null };
|
||||
|
||||
const running = await isServing();
|
||||
return { installed, running, window: running ? await windowGeometry() : null };
|
||||
}
|
||||
|
||||
export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']);
|
||||
export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']);
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { ServerWebSocket, Socket } from 'bun';
|
||||
import { VNC_HOST, VNC_PORT } from './session';
|
||||
|
||||
/**
|
||||
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
|
||||
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
|
||||
* reading them.
|
||||
*
|
||||
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
|
||||
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
|
||||
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
|
||||
* and chunks are forwarded exactly as they arrive.
|
||||
*/
|
||||
|
||||
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
|
||||
|
||||
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||
|
||||
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, code: number, reason: string) => {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
|
||||
export const minesweeperWebsocket = {
|
||||
open(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
|
||||
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
|
||||
// version string would otherwise lose it.
|
||||
const relay: Relay = { socket: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
void Bun.connect({
|
||||
hostname: VNC_HOST,
|
||||
port: VNC_PORT,
|
||||
socket: {
|
||||
open(socket) {
|
||||
if (relay.done) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
relay.socket = socket;
|
||||
for (const chunk of relay.queue) socket.write(chunk);
|
||||
relay.queue.length = 0;
|
||||
},
|
||||
data(_socket, chunk) {
|
||||
try {
|
||||
ws.send(chunk);
|
||||
} catch {
|
||||
/* client gone; the close handler tears down the socket */
|
||||
}
|
||||
},
|
||||
close: () => closeClient(ws, 1000, 'vnc closed'),
|
||||
error: () => closeClient(ws, 1011, 'vnc error'),
|
||||
},
|
||||
}).catch(() => {
|
||||
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
|
||||
// panel can say so instead of showing an empty canvas.
|
||||
closeClient(ws, 1011, 'minesweeper session is not running');
|
||||
});
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
|
||||
// RFB is binary in both directions; a text frame is a client that is not speaking it.
|
||||
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw);
|
||||
if (relay.socket) relay.socket.write(bytes);
|
||||
else relay.queue.push(bytes);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { sidecarPort } from '@@/sidecar-registry';
|
||||
import { snapshotPlugins } from '@@/plugins/mount';
|
||||
|
||||
/*
|
||||
Where a plugin's socket actually goes.
|
||||
|
||||
A target is an ADDRESS — a host and a port — never a URL a manifest wrote. The relay turns it into
|
||||
`ws://<host>:<port>/ws`, and `/ws` is a convention rather than a setting for the same reason the host is
|
||||
not configurable: every value here has to be one the platform derived, not one a plugin asserted.
|
||||
|
||||
The target is always a SIDECAR. A fixed-port form was written and removed the same day: nothing stops a
|
||||
manifest naming a port it does not own, so `{ port: 5432 }` plus a grant would be a raw TCP relay onto
|
||||
Postgres for anyone that plugin reaches. Verifying the host is loopback is not verifying the port belongs
|
||||
to the plugin, and only the second question matters.
|
||||
|
||||
A sidecar's port is different in kind, not degree: the platform watched it arrive at registration, so the
|
||||
association is observed rather than asserted. It is also the SAME port the sidecar serves its HTTP on —
|
||||
one announcement, one listener, `/ws` alongside the routes. That is what the relay's move to
|
||||
websocket-to-websocket bought, and why a plugin wanting a socket no longer has to choose between having
|
||||
one and having ordinary routes.
|
||||
|
||||
**Host is always loopback and is not configurable.** A plugin socket that could name an arbitrary host
|
||||
would be a request-forgery primitive handed to every plugin author and reachable by anyone the plugin is
|
||||
granted to. There is no case for it that a sidecar does not already serve.
|
||||
*/
|
||||
|
||||
export type PluginWsTarget = { host: string; port: number };
|
||||
|
||||
export async function pluginWsTargetFor(appName: string): Promise<PluginWsTarget | null> {
|
||||
const { states } = await snapshotPlugins();
|
||||
// Installed, not merely present on disk. A plugin directory that exists but is not installed has no
|
||||
// routes and no permission, so it must have no socket either.
|
||||
const state = states.find((st) => st.plugin.appName === appName && st.install);
|
||||
const socket = state?.plugin.manifest.websocket;
|
||||
if (!socket) return null;
|
||||
|
||||
// A sidecar that has not announced its port yet is indistinguishable from one that never will, and both
|
||||
// are correctly a refusal — there is nothing to relay onto. The caller says "not accepting connections",
|
||||
// which is true either way.
|
||||
const port = sidecarPort(socket.sidecar);
|
||||
return port ? { host: '127.0.0.1', port } : null;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { resolveAuthToken } from '@@/auth-token';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
import { isApiRequestAllowed } from '@@/permissions/authorize';
|
||||
import { pluginWsTargetFor } from './target';
|
||||
|
||||
/*
|
||||
One websocket door for every plugin, so a plugin can have a socket at all.
|
||||
|
||||
── Why this exists ──
|
||||
|
||||
Bun's route table is fixed at `serve()`, and `WS_ROUTES` is a static object read at boot. So a plugin
|
||||
installed afterwards had no way to add a socket route, and "a websocket provider is a platform concern"
|
||||
became a hard ceiling rather than a preference: `/minesweeper` had to live in the platform and
|
||||
`plugins/desktop` could not do the same thing at all.
|
||||
|
||||
The way out was already in the tree. `dev-proxy` serves a socket without a `WS_ROUTES` entry, through a
|
||||
WILDCARD route whose handler calls `server.upgrade` itself. Nothing about the route table needs to become
|
||||
dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the
|
||||
plugin name is a path segment rather than a route.
|
||||
|
||||
── The upstream is a WEBSOCKET, not a raw TCP socket ──
|
||||
|
||||
This relayed onto a raw TCP address for about a day. It was changed while extracting Minesweeper, and the
|
||||
reason is worth keeping because it is not a preference.
|
||||
|
||||
**A sidecar announces exactly one port.** That is the whole provenance argument for `target.ts` — the
|
||||
platform watched that port arrive at registration, which is the only thing separating it from a number a
|
||||
manifest asserted. But if this relay needs raw TCP, that one port has to BE a raw TCP listener, so the
|
||||
sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed
|
||||
command vocabulary over the registration socket — the mechanism the fleet has spent months retiring.
|
||||
|
||||
Relaying websocket-to-websocket dissolves that. The sidecar serves HTTP and `/ws` on the single port it
|
||||
announced, exactly as the pty sidecar already does; `createSidecarProxy` handles the HTTP half; and the
|
||||
conversion from frames to whatever the plugin actually speaks happens INSIDE the plugin, which is the only
|
||||
process that knows what that is. Minesweeper's x11vnc is the worked example: the sidecar owns the RFB
|
||||
stream and the platform never learns that RFB exists.
|
||||
|
||||
This file is still ignorant of the payload — it moves frames without reading them, and a stream protocol's
|
||||
boundaries carry no meaning either way. The ignorance simply sits one layer up.
|
||||
*/
|
||||
|
||||
export type PluginWSData = {
|
||||
provider: 'plugin-ws';
|
||||
plugin: string;
|
||||
/** `ws://127.0.0.1:<announced port>/ws`, with the client's query minus its credential. */
|
||||
upstream: string;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
type Frame = string | Uint8Array<ArrayBuffer>;
|
||||
type Relay = { ws: WebSocket | null; queue: Frame[]; done: boolean };
|
||||
const relays = new Map<ServerWebSocket<PluginWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: string) => {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.ws?.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
/* already closing */
|
||||
}
|
||||
};
|
||||
|
||||
export const pluginWsWebsocket = {
|
||||
open(ws: ServerWebSocket<PluginWSData>) {
|
||||
/*
|
||||
The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is
|
||||
already connected and may already be talking before the upstream is open — and an RFB client sends its
|
||||
protocol version immediately. Dropping those frames desynchronises the stream in a way that looks like
|
||||
a corrupt display rather than a lost packet. Learned by dev-proxy first.
|
||||
*/
|
||||
const relay: Relay = { ws: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
let upstream: WebSocket;
|
||||
try {
|
||||
// The sidecar binds loopback and is told who is calling, the same signal every HTTP sidecar trusts:
|
||||
// a request without this header did not come through the platform.
|
||||
upstream = new WebSocket(ws.data.upstream, { headers: { 'X-Officer-User': String(ws.data.userId) } } as never);
|
||||
} catch {
|
||||
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
return;
|
||||
}
|
||||
relay.ws = upstream;
|
||||
upstream.binaryType = 'arraybuffer';
|
||||
|
||||
upstream.onopen = () => {
|
||||
// Between the upgrade and here the client may have gone. Sending into a socket nobody reads would hold
|
||||
// the upstream connection open for as long as the plugin's process lives.
|
||||
if (relay.done) {
|
||||
try {
|
||||
upstream.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const frame of relay.queue) upstream.send(frame);
|
||||
relay.queue.length = 0;
|
||||
};
|
||||
|
||||
upstream.onmessage = (ev) => {
|
||||
try {
|
||||
ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer));
|
||||
} catch {
|
||||
/* client gone; the close handler tidies up */
|
||||
}
|
||||
};
|
||||
|
||||
// A plugin whose process is not listening is the ordinary case rather than an exception: a sidecar that
|
||||
// has not booted, or a service the owner stopped. The client is told, and told which plugin.
|
||||
upstream.onerror = () => closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
upstream.onclose = () => closeClient(ws, 1011, 'upstream closed');
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<PluginWSData>, message: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
const frame: Frame = typeof message === 'string' ? message : (message as unknown as Uint8Array<ArrayBuffer>);
|
||||
if (relay.ws && relay.ws.readyState === WebSocket.OPEN) relay.ws.send(frame);
|
||||
else relay.queue.push(frame);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<PluginWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.ws?.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `/api/plugin-ws/<plugin>` with an upgrade header.
|
||||
*
|
||||
* Authorisation is PER PLUGIN, which is why `plugin-ws` is exempt from the boot-time totality check rather
|
||||
* than carrying a registry entry of its own. One entry covering every plugin's socket would be a hole:
|
||||
* granting one plugin would grant them all.
|
||||
*
|
||||
* So the decision is delegated to the plugin's OWN capability, by asking the same question its HTTP routes
|
||||
* are asked — `isApiRequestAllowed(user, 'GET', '/api/<plugin>')`. That is deliberate and it is the whole
|
||||
* safety argument: there is no second permission to grant, to forget, or to drift out of step. Granting a
|
||||
* plugin grants its socket, and revoking it closes it.
|
||||
*
|
||||
* Async, like `upgradeWs`. `server.upgrade` must be called synchronously with respect to the request, but
|
||||
* Bun accepts a route handler that returns a promise — which is what lets the token resolve, the blacklist
|
||||
* check and the permission decision all happen before anything is upgraded.
|
||||
*/
|
||||
export async function upgradePluginWs(req: Request, server: any): Promise<Response | undefined> {
|
||||
const url = new URL(req.url);
|
||||
const match = url.pathname.match(/^\/api\/plugin-ws\/([a-z0-9-]{1,64})\/?$/);
|
||||
if (!match) return undefined;
|
||||
const plugin = match[1]!;
|
||||
|
||||
// A browser cannot set headers on a WebSocket upgrade, so the token arrives as a query parameter — the
|
||||
// concession every other socket here already makes. Verified through the same resolver as the HTTP doors,
|
||||
// so an API key that works against /api works here too.
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
try {
|
||||
const user = await resolveAuthToken(token);
|
||||
if (!user) return new Response('Unauthorized', { status: 401 });
|
||||
if (user.jti && (await isTokenBlacklisted(user.jti))) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
// The plugin's own gate, asked as if this were a GET against its HTTP prefix.
|
||||
if (!(await isApiRequestAllowed(user.id, 'GET', `/api/${plugin}`)).allowed) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const target = await pluginWsTargetFor(plugin);
|
||||
// 404 for both "no such plugin" and "no socket declared", identically. A caller must not be able to
|
||||
// discover which plugins are installed by which refusal it gets — the same reasoning as SendOff's
|
||||
// recipient lookup.
|
||||
if (!target) return new Response('Not found', { status: 404 });
|
||||
|
||||
// The rest of the query is the plugin's to interpret and is passed through untouched. The CREDENTIAL is
|
||||
// not: a sidecar has no use for the caller's JWT, and forwarding one puts a bearer token into whatever
|
||||
// that process logs. `X-Officer-User` carries the only part it needs.
|
||||
const forwarded = new URLSearchParams(url.search);
|
||||
forwarded.delete('token');
|
||||
const query = forwarded.toString();
|
||||
|
||||
const ok = server.upgrade(req, {
|
||||
data: {
|
||||
provider: 'plugin-ws',
|
||||
plugin,
|
||||
upstream: `ws://${target.host}:${target.port}/ws${query ? `?${query}` : ''}`,
|
||||
userId: user.id,
|
||||
} satisfies PluginWSData,
|
||||
});
|
||||
return ok ? undefined : new Response('Upgrade failed', { status: 400 });
|
||||
} catch {
|
||||
// Any failure to establish WHO is calling denies. Never open a socket on a maybe.
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { listRoles, createRole, renameRole, deleteRole } from 'officerdb';
|
||||
|
||||
/*
|
||||
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md).
|
||||
|
||||
This is the roles TABLE — a name, a description, the `isSystem` flag. It is not the GRANTS a role
|
||||
holds; that stays `role_permissions`, administered at `/api/users/permissions` by
|
||||
`permissionAdminRouter`. The two are separate screens for a reason: this router answers "which roles
|
||||
exist", that one answers "what may each reach" — and deleting a role here has to know the second
|
||||
question's table too, because the guard below is "does anyone hold it", not "what does it grant".
|
||||
|
||||
── Owner only, explicitly ──
|
||||
|
||||
`roles` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner
|
||||
before a request reaches here. This is the same belt-and-braces every other admin router in this
|
||||
directory adds on top of that (app-store, server-admin): a mistake in the registry must not turn into
|
||||
the ability to rename or delete a role.
|
||||
|
||||
── Why delete refuses instead of reassigning ──
|
||||
|
||||
D4 in the design doc: never move a role's holders onto a fallback. That is a permission change nobody
|
||||
asked for, and "absence denies" — the posture everywhere else in this system — means a role with
|
||||
accounts still on it stays exactly as reachable as it was until every one of them is moved off by
|
||||
someone who chose to.
|
||||
*/
|
||||
|
||||
export const rolesRouter = createRouter();
|
||||
|
||||
rolesRouter.use(async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Role management is owner-only');
|
||||
return next();
|
||||
});
|
||||
|
||||
/** GET /api/roles — every role, with how many accounts hold each. */
|
||||
rolesRouter.get('/', async (ctx) => ctx.json({ roles: await listRoles() }));
|
||||
|
||||
/** POST /api/roles — create a role. Always non-system; see the schema comment on `isSystem`. */
|
||||
rolesRouter.post('/', async (ctx) => {
|
||||
const body = ctx.get('body') as { name?: unknown; description?: unknown };
|
||||
if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required');
|
||||
const description = typeof body.description === 'string' ? body.description : null;
|
||||
|
||||
const result = await createRole({ name: body.name, description });
|
||||
if (!result.ok) {
|
||||
throw result.reason === 'invalid-name'
|
||||
? errors.BAD_REQUEST('a role name cannot be empty')
|
||||
: errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
|
||||
}
|
||||
return ctx.json({ role: result.role });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/roles/:name — rename. Refuses a system role outright: `Developer`, `Super Admin` and
|
||||
* `Member` are tested by string in code that has not been updated to read this table (see D4), so
|
||||
* renaming one of those would silently detach the code's own idea of who holds what.
|
||||
*/
|
||||
rolesRouter.patch('/:name', async (ctx) => {
|
||||
const from = ctx.req.param('name');
|
||||
const body = ctx.get('body') as { name?: unknown };
|
||||
if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required');
|
||||
|
||||
const result = await renameRole(from, body.name);
|
||||
if (!result.ok) {
|
||||
switch (result.reason) {
|
||||
case 'not-found':
|
||||
throw errors.NOT_FOUND(`no such role '${from}'`);
|
||||
case 'system':
|
||||
throw errors.FORBIDDEN(
|
||||
`'${from}' is a system role and the platform's own code names it — it cannot be renamed`,
|
||||
);
|
||||
case 'invalid-name':
|
||||
throw errors.BAD_REQUEST('a role name cannot be empty');
|
||||
case 'duplicate':
|
||||
throw errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
|
||||
}
|
||||
}
|
||||
return ctx.json({ role: result.role });
|
||||
});
|
||||
|
||||
/** DELETE /api/roles/:name — refuses a system role, and refuses one any account still holds. */
|
||||
rolesRouter.delete('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const result = await deleteRole(name);
|
||||
if (!result.ok) {
|
||||
switch (result.reason) {
|
||||
case 'not-found':
|
||||
throw errors.NOT_FOUND(`no such role '${name}'`);
|
||||
case 'system':
|
||||
throw errors.FORBIDDEN(
|
||||
`'${name}' is a system role and the platform's own code names it — it cannot be deleted`,
|
||||
);
|
||||
case 'in-use':
|
||||
throw errors.CONFLICT(
|
||||
`${result.holderCount} account(s) still hold '${name}' — move them to another role first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { createUser, getUserByEmail, getUserByUsername, getRole } from 'officerdb';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePublicKey } from '@@/os-user-ssh';
|
||||
@@ -28,9 +27,6 @@ import { toPublicUser } from './manage-users';
|
||||
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
|
||||
// UI is verified by definition — the owner is the verification.
|
||||
|
||||
/** Roles this route may assign. Never 'Super Admin' — see below. */
|
||||
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||
|
||||
export const createUserHandler: Handler = async function (ctx) {
|
||||
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -55,12 +51,19 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
// return whichever the query reached first. That answer decides the identity the agent sidecar runs
|
||||
// as, who reaches the vault and which origin is privileged, so it is not a thing to leave to a query
|
||||
// plan. If the owner ever needs to hand the server over, that is a deliberate transfer, not a dropdown.
|
||||
if (!(ASSIGNABLE_ROLES as readonly string[]).includes(role)) {
|
||||
throw errors.BAD_REQUEST(
|
||||
role === 'Super Admin'
|
||||
? 'There is one server owner and it cannot be created here.'
|
||||
: `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`,
|
||||
);
|
||||
//
|
||||
// Checked as a literal BEFORE the table lookup: 'Super Admin' is always a real row (it is seeded and
|
||||
// isSystem), so a table-only check would let it through and rely on the message below to explain why
|
||||
// it still isn't offered here — this way the refusal and its reason are decided in one place.
|
||||
if (role === 'Super Admin') {
|
||||
throw errors.BAD_REQUEST('There is one server owner and it cannot be created here.');
|
||||
}
|
||||
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
||||
// phase 2 part 4) — `role` is user input from here on, and a role that is not a row in `roles` must
|
||||
// never reach `createUser`, or the FK on `users.role` throws a raw Postgres error instead of this
|
||||
// message.
|
||||
if (!(await getRole(role))) {
|
||||
throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
}
|
||||
|
||||
// The inbound SSH key, if the owner supplied one. Validated HERE rather than at use, so a bad paste is a
|
||||
@@ -87,7 +90,7 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
name,
|
||||
username,
|
||||
status: 'Active',
|
||||
role: role as UserRole,
|
||||
role,
|
||||
});
|
||||
|
||||
// The Linux side: directories, the Linux user, the confinement, the keys. Non-fatal and reported rather
|
||||
@@ -97,7 +100,7 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
//
|
||||
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
|
||||
// account was made does not cost anybody their password and dashboards.
|
||||
const os = await provisionOsAccount({ userId: user.id, email, username, role: role as UserRole, inboundKey });
|
||||
const os = await provisionOsAccount({ userId: user.id, email, username, role, inboundKey });
|
||||
|
||||
// Tell any plugin that provisions upstream accounts. Detached and unawaited: a plugin's sidecar being
|
||||
// down must not fail the creation of a platform account, and the list is re-sent when it registers.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { getUsers, getUserById, updateUser, deleteUser, listRoles, getRole, OWNER_USER_ID } from 'officerdb';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { deprovisionOsAccount } from '@@/os-user-deprovision';
|
||||
import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
|
||||
@@ -50,13 +49,16 @@ export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): P
|
||||
});
|
||||
|
||||
export const listUsersHandler: Handler = async function (ctx) {
|
||||
const users = await getUsers();
|
||||
const [users, roles] = await Promise.all([getUsers(), listRoles()]);
|
||||
const roleNames = roles.map((r) => r.name);
|
||||
return ctx.json({
|
||||
users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
|
||||
// Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a
|
||||
// picker — both write paths refuse it — which is what `assignableRoles` is for.
|
||||
roles: USER_ROLES,
|
||||
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||
// picker — both write paths refuse it — which is what `assignableRoles` is for. Read from the `roles`
|
||||
// table rather than a hardcoded list (docs/roles-and-teams.md phase 2 part 4) — a role created at
|
||||
// runtime through /api/roles must show up here without a deploy.
|
||||
roles: roleNames,
|
||||
assignableRoles: roleNames.filter((r) => r !== 'Super Admin'),
|
||||
ownerId: OWNER_USER_ID,
|
||||
});
|
||||
};
|
||||
@@ -66,8 +68,11 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
|
||||
|
||||
const { role } = ctx.get('body') as { role?: unknown };
|
||||
if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) {
|
||||
throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`);
|
||||
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
||||
// phase 2 part 4): a role that is not a row in `roles` must never reach `updateUser`, or the FK on
|
||||
// `users.role` throws a raw Postgres error instead of this message.
|
||||
if (typeof role !== 'string' || !(await getRole(role))) {
|
||||
throw errors.BAD_REQUEST(`Unknown role '${String(role)}'`);
|
||||
}
|
||||
|
||||
// ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
|
||||
@@ -77,14 +82,19 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
throw errors.FORBIDDEN('The server owner cannot be demoted.');
|
||||
}
|
||||
|
||||
// And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
|
||||
// second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
|
||||
// owner by that role, so two holders make "who owns this server" a question the query plan answers.
|
||||
// It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
|
||||
// server over is a deliberate act, not a dropdown.
|
||||
if (id !== OWNER_USER_ID && role === 'Super Admin') {
|
||||
throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
|
||||
}
|
||||
/*
|
||||
A second Super Admin is allowed since 2026-08-23, and the refusal that used to live here is gone.
|
||||
|
||||
It existed because `getOwnerUser()` resolved the owner BY that role, so two holders made "who owns this
|
||||
server" a question the query plan answered — and that answer decided the agent sidecar's identity, vault
|
||||
access and which origin is privileged. Ownership is now `OWNER_USER_ID`, so the role no longer carries
|
||||
it and several accounts may hold it.
|
||||
|
||||
What the role does carry is real and worth saying out loud in the UI rather than here: it provisions an
|
||||
OS account with sudo. That puts the holder outside Officer's permission system entirely and cannot be
|
||||
undone by revoking the role afterwards. Granting it is handing over the machine — which is the operator's
|
||||
decision to make, and no longer one this endpoint refuses on their behalf.
|
||||
*/
|
||||
|
||||
const existing = await getUserById(id);
|
||||
if (!existing) throw errors.NOT_FOUND('User not found');
|
||||
@@ -100,7 +110,7 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
// the account still says Developer and the whole thing can be retried. Doing it the other way round would
|
||||
// leave a Member holding database access with nothing in the row to indicate it.
|
||||
const had = rolePermitsDevTools(existing.role);
|
||||
const wants = rolePermitsDevTools(role as UserRole);
|
||||
const wants = rolePermitsDevTools(role);
|
||||
|
||||
if (had && !wants && existing.osUser) {
|
||||
const dropped = await dropPostgresRole(existing.osUser);
|
||||
@@ -120,7 +130,7 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateUser(id, { role: role as UserRole });
|
||||
const updated = await updateUser(id, { role });
|
||||
if (!updated) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { MiddlewareHandler } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { getAllRoleGrants, replaceRoleGrants, listRoles, getRole } from 'officerdb';
|
||||
import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
|
||||
import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
|
||||
import { permissionAvailability } from '../../app-store/availability';
|
||||
@@ -101,13 +100,17 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
|
||||
hasPersonalWrites: !!c.personal?.length,
|
||||
});
|
||||
|
||||
const allRoles = await listRoles();
|
||||
|
||||
return ctx.json({
|
||||
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
|
||||
// shows a checkbox it will refuse to honour is worse than one that never offered it.
|
||||
permissions: GRANTABLE_PERMISSIONS.filter((c) => !unavailable.has(c.key)).map(describe),
|
||||
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
|
||||
// database refuses a row for that role.
|
||||
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||
// Roles a grant may name. Read from the `roles` table (docs/roles-and-teams.md phase 2 part 4) rather
|
||||
// than a hardcoded list, so a role created at runtime through /api/roles is grantable without a
|
||||
// deploy. Super Admin is excluded: the owner bypasses this table entirely, and the database refuses a
|
||||
// row for that role.
|
||||
roles: allRoles.map((r) => r.name).filter((r) => r !== 'Super Admin'),
|
||||
// The column is `permission` now too, so this is a straight pass-through again. It was briefly a
|
||||
// mapping — the wire said `permission` while the column still said `permission` — which is what let
|
||||
// the table be renamed without any client noticing.
|
||||
@@ -116,8 +119,11 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
|
||||
});
|
||||
|
||||
permissionAdminRouter.put('/permissions/:role', ownerGate, async (ctx) => {
|
||||
const role = ctx.req.param('role') as UserRole;
|
||||
if (!USER_ROLES.includes(role)) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
const role = ctx.req.param('role');
|
||||
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
||||
// phase 2 part 4): a role that is not a row in `roles` must never reach `replaceRoleGrants`, or the FK
|
||||
// on `role_permissions.role` throws a raw Postgres error instead of this message.
|
||||
if (!(await getRole(role))) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
if (role === 'Super Admin') throw errors.BAD_REQUEST('The owner is not governed by grants');
|
||||
|
||||
const body = ctx.get('body') as { grants?: unknown } | undefined;
|
||||
|
||||
+2
-2
@@ -9,6 +9,7 @@ import { themesRouter } from './api/themes/themes';
|
||||
import { themeFavoritesRouter } from './api/themes/favorites';
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { rolesRouter } from './api/roles/router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
@@ -26,7 +27,6 @@ import { pluginsRouter } from './api/plugins/router';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { minesweeperRouter } from './api/minesweeper/router';
|
||||
import { sendoffRouter } from './api/sendoff/router';
|
||||
import { subAgentsRouter } from './api/sub-agents/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
@@ -129,6 +129,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
|
||||
['/theme-favorites', themeFavoritesRouter],
|
||||
['/users', usersRouter],
|
||||
['/roles', rolesRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
['/agents', agentsRouter],
|
||||
@@ -142,7 +143,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/dashboards', dashboardsRouter],
|
||||
['/file-browser', fileBrowserRouter],
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
['/sendoff', sendoffRouter],
|
||||
['/sub-agents', subAgentsRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
|
||||
@@ -23,7 +23,7 @@ import { assertPermissionTotality, isExemptApiPath } from './totality';
|
||||
// switched off 2026-08-13. It was listed here until 2026-08-18 and made this file's own totality tests
|
||||
// fail for five days — the mirror claiming a provider the server does not serve, which is precisely the
|
||||
// drift the comment above warns about, caught by the check and then ignored because it was "the baseline".
|
||||
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar', 'minesweeper'];
|
||||
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar'];
|
||||
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
|
||||
const surface = () => ({
|
||||
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'],
|
||||
|
||||
@@ -328,25 +328,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
ws: ['task-runner', 'pipeline'],
|
||||
routes: ['/jobs'],
|
||||
},
|
||||
// Minesweeper X, on a virtual display, streamed to the browser over RFB.
|
||||
//
|
||||
// `execution` rather than `app`, and the reason is not that a game is dangerous. The wine process runs
|
||||
// as the SERVER OWNER on the owner's display — so its File and Extras menus are file dialogs with the
|
||||
// owner's home behind them, and "granted Minesweeper" would quietly mean "granted a file picker as the
|
||||
// owner". `plugins/desktop` reached the same conclusion from the same premise.
|
||||
//
|
||||
// There is a second reason to keep it here for now: one display and one game means two viewers share a
|
||||
// board and fight over the mouse. Per-viewer sessions would be a display and a wine prefix each, which
|
||||
// is cheap for this but is a design decision rather than an implementation detail.
|
||||
{
|
||||
key: 'minesweeper',
|
||||
label: 'Minesweeper',
|
||||
description: 'A Windows Minesweeper session on a virtual display, as the server owner',
|
||||
kind: 'execution',
|
||||
api: ['/minesweeper'],
|
||||
ws: ['minesweeper'],
|
||||
routes: ['/minesweeper'],
|
||||
},
|
||||
/*
|
||||
SendOff is `app`, not `confined`, and the distinction is the point.
|
||||
|
||||
@@ -461,6 +442,22 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
|
||||
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
|
||||
// platform no longer knows this permission exists, which is the entire point.
|
||||
{
|
||||
key: 'roles',
|
||||
label: 'Roles',
|
||||
description: 'Create, rename and delete the roles this server grants',
|
||||
// Admin, and for the same reason `user-admin` beside it is: a role is not a member's own data, it is
|
||||
// policy that governs every account holding it, so there is no level of "read" that makes creating or
|
||||
// deleting one a feature to grant. `registry.test.ts` asserts admin can never be handed out — this
|
||||
// entry is what makes that assertion mean something for roles specifically.
|
||||
//
|
||||
// Separate from `user-admin` rather than folded into it: that permission's `/users` is the account
|
||||
// list and the grants a role holds (`/users/permissions`); this one is the roles TABLE itself — the
|
||||
// name, the description, whether it is system-named. Two routers, two questions, so a boot-time check
|
||||
// that a mounted prefix has exactly one owner stays meaningful for both.
|
||||
kind: 'admin',
|
||||
api: ['/roles'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +54,12 @@ const EXEMPT_WS_PROVIDERS: Record<string, string> = {
|
||||
// same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner
|
||||
// without carrying an account through `upgradeWs`. Refused outright without that cookie.
|
||||
'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues',
|
||||
// One relay serving every plugin's socket, so a single registry entry here would be a HOLE: granting one
|
||||
// plugin would grant them all. `upgradePluginWs` therefore asks the PLUGIN's own capability, as if the
|
||||
// socket were a GET against its HTTP prefix — which means the decision is already in the registry, just
|
||||
// not under this provider's name. Exempt because the check is per-request and per-plugin, never because
|
||||
// it is absent.
|
||||
'plugin-ws': "relayed per plugin; authorised by that plugin's own capability in upgradePluginWs",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -241,7 +241,7 @@ export async function installPlugin(
|
||||
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
|
||||
}
|
||||
const installed = deps.packagesToInstall.length
|
||||
? await installDependencies(deps.manager!, deps.packagesToInstall)
|
||||
? await installDependencies(deps.manager!, deps.packagesToInstall, deps.architectures)
|
||||
: { ok: true as const, installed: [], output: '' };
|
||||
if (!installed.ok) {
|
||||
return {
|
||||
|
||||
@@ -105,7 +105,7 @@ export type OsDependency = {
|
||||
* which for a binary that is present anyway is correct and silent, and otherwise becomes an instruction
|
||||
* to the owner rather than a guess.
|
||||
*/
|
||||
packages: Partial<Record<PackageManager, string>>;
|
||||
packages: Partial<Record<PackageManager, string | string[]>>;
|
||||
/**
|
||||
* WHO has to be able to run this.
|
||||
*
|
||||
@@ -125,6 +125,33 @@ export type OsDependency = {
|
||||
* See `docs/plugin-dependency-scope.md`.
|
||||
*/
|
||||
scope?: 'owner' | 'shared';
|
||||
/**
|
||||
* An architecture this dependency needs enabled before its package can be installed.
|
||||
*
|
||||
* Debian and Ubuntu only, and it means exactly one thing: run `dpkg --add-architecture <arch>` followed
|
||||
* by `apt-get update` before installing, if that architecture is not already enabled.
|
||||
*
|
||||
* ── Why this is a field and not a package name ──
|
||||
*
|
||||
* Because it cannot be one. A 32-bit package on a 64-bit host is `wine32:i386`, and installing it fails
|
||||
* outright until the host has been told i386 exists — which is a change to the machine's dpkg
|
||||
* configuration, not a package to fetch. Minesweeper found this the direct way: `wine` alone installs
|
||||
* 64-bit-only, the plugin reports a clean install, and the game dies with `Bad EXE format`.
|
||||
*
|
||||
* ── Why it is an enum and not a command ──
|
||||
*
|
||||
* This runs as root. A manifest that could supply the command would be a manifest that can run anything
|
||||
* as root on every machine that installs it, which is a far larger grant than "this plugin may install
|
||||
* wine". The manifest picks a value from `DPKG_ARCHITECTURES`; the platform owns the sentence it turns
|
||||
* into. Same principle as the plugin websocket naming a sidecar rather than a port.
|
||||
*
|
||||
* It is also SYSTEM-WIDE and does not go away when the plugin does. `plugins:delete` will not disable an
|
||||
* architecture something else may now depend on, so this is shown to the owner before they consent
|
||||
* rather than mentioned in a log afterwards.
|
||||
*
|
||||
* Ignored where it means nothing — a pacman or brew host is already multi-arch or has no such concept.
|
||||
*/
|
||||
multiarch?: DpkgArchitecture;
|
||||
/**
|
||||
* A release binary, for a dependency no package manager carries.
|
||||
*
|
||||
@@ -282,6 +309,44 @@ export type PluginManifest = {
|
||||
* a missing one that cannot be installed here refuses the install rather than proceeding to a plugin
|
||||
* that runs and quietly produces nothing.
|
||||
*/
|
||||
/**
|
||||
* A websocket this plugin serves, relayed by the platform.
|
||||
*
|
||||
* Declaring this makes `/api/plugin-ws/<appName>` a frame relay onto `ws://127.0.0.1:<port>/ws`, where the
|
||||
* port is the one the named sidecar announced at registration. It exists because Bun's route table is
|
||||
* fixed at `serve()`, so a plugin installed afterwards could never add a socket route — which is why
|
||||
* `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a display at all.
|
||||
*
|
||||
* Authorisation is the plugin's OWN capability, asked as if the socket were a GET against its HTTP prefix.
|
||||
* There is deliberately no separate socket permission: granting the plugin grants its socket, and nothing
|
||||
* can drift out of step because there is only one decision.
|
||||
*
|
||||
* The target is a SIDECAR, always, and never a port number a manifest picked.
|
||||
*
|
||||
* A fixed-port form existed for about an hour and was removed before it shipped anywhere. The hole is
|
||||
* total: nothing stops a manifest naming a port it does not own, so `{ port: 5432 }` plus a grant is a
|
||||
* raw TCP relay onto Postgres, authenticated by the platform, for anyone that plugin is granted to. `:22`
|
||||
* and officer's own port are the same sentence. Checking that the HOST was loopback — which the relay
|
||||
* does — is not the same as checking the PORT belongs to the plugin, and only the second one matters.
|
||||
*
|
||||
* A sidecar's port is trustworthy for a reason a declaration can never be: the platform watched it
|
||||
* arrive. The sidecar binds an ephemeral port and announces it at registration, so the association
|
||||
* between "this port" and "this plugin" is something the platform observed rather than something a
|
||||
* manifest asserted. Provenance, not a claim.
|
||||
*
|
||||
* The cost is that a plugin wanting a socket needs a sidecar. That is not ceremony — it is the thing
|
||||
* that makes the port mean anything. It costs nothing else: the socket lives at `/ws` on the SAME
|
||||
* listener the sidecar already serves its routes from, so a plugin does not choose between having a
|
||||
* socket and having ordinary HTTP.
|
||||
*
|
||||
* **The sidecar serves a websocket, not a raw stream.** Whatever it is really talking to — a VNC display,
|
||||
* a line protocol, another process entirely — the conversion happens inside the plugin, because the
|
||||
* plugin is the only thing that knows. The platform moves frames it does not read.
|
||||
*
|
||||
* The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a
|
||||
* request-forgery primitive reachable by anyone the plugin is granted to.
|
||||
*/
|
||||
websocket?: { sidecar: string };
|
||||
osDependencies?: OsDependency[];
|
||||
|
||||
/**
|
||||
@@ -471,7 +536,26 @@ export const PACKAGE_MANAGERS: PackageManager[] = ['apt', 'pacman', 'dnf', 'brew
|
||||
* not a regex that already allows it.
|
||||
*/
|
||||
const BINARY_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
||||
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}$/;
|
||||
/**
|
||||
* A package name, optionally qualified by an architecture.
|
||||
*
|
||||
* `wine32:i386` is the motivating case and it is not an edge case: on Debian and Ubuntu a 32-bit package
|
||||
* on a 64-bit host can only be named that way. The suffix is drawn from `DPKG_ARCHITECTURES` below rather
|
||||
* than being any word, so this stays a closed vocabulary — the point of a narrow regex here is that a
|
||||
* manifest cannot get a metacharacter onto an `apt-get` command line, and widening it to `[a-z0-9]+`
|
||||
* after the colon would still be safe but would stop being a vocabulary.
|
||||
*/
|
||||
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}(:(i386|amd64|arm64|armhf|ppc64el|s390x))?$/;
|
||||
|
||||
/**
|
||||
* Architectures `dpkg --add-architecture` accepts, and the only values a manifest may ask to enable.
|
||||
*
|
||||
* Closed on purpose. `multiarch` turns into a command run as root, so the manifest gets to pick from a
|
||||
* list rather than to supply a string — the same reasoning as the package regex one line up, applied to
|
||||
* the one field that would otherwise be a way to say "run this as root" out loud.
|
||||
*/
|
||||
export const DPKG_ARCHITECTURES = ['i386', 'amd64', 'arm64', 'armhf', 'ppc64el', 's390x'] as const;
|
||||
export type DpkgArchitecture = (typeof DPKG_ARCHITECTURES)[number];
|
||||
|
||||
/** Reasons an `osDependencies` list is unusable. Empty when it is fine. */
|
||||
function osDependencyProblems(deps: unknown): string[] {
|
||||
@@ -502,6 +586,13 @@ function osDependencyProblems(deps: unknown): string[] {
|
||||
problems.push(`${at}.packages must be an object keyed by package manager`);
|
||||
continue;
|
||||
}
|
||||
const multiarch = (dep as { multiarch?: unknown }).multiarch;
|
||||
if (multiarch !== undefined && !DPKG_ARCHITECTURES.includes(multiarch as never)) {
|
||||
// Refused rather than ignored: a typo here silently drops the one step that makes the package
|
||||
// installable at all, and the failure surfaces much later as the plugin not working.
|
||||
problems.push(`${at}.multiarch must be one of ${DPKG_ARCHITECTURES.join(', ')}`);
|
||||
}
|
||||
|
||||
for (const [manager, name] of Object.entries(packages)) {
|
||||
if (!PACKAGE_MANAGERS.includes(manager as PackageManager)) {
|
||||
problems.push(
|
||||
@@ -509,8 +600,19 @@ function osDependencyProblems(deps: unknown): string[] {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof name !== 'string' || !PACKAGE_RE.test(name)) {
|
||||
problems.push(`${at}.packages.${manager} must be a plain package name — lowercase, digits, dot, plus, dash`);
|
||||
// A LIST is allowed, because one binary genuinely can need more than one package. wine is the case:
|
||||
// `wine` provides the launcher on PATH and `wine32:i386` provides the 32-bit loader it needs to run
|
||||
// an i386 executable, and installing either alone produces a wine that cannot run the thing the
|
||||
// plugin came for. Every element is validated the same way and they reach apt as separate argv
|
||||
// entries, so a list is no more powerful than the names in it.
|
||||
const names = Array.isArray(name) ? name : [name];
|
||||
const bad = names.length === 0 || names.some((n) => typeof n !== 'string' || !PACKAGE_RE.test(n));
|
||||
if (bad) {
|
||||
problems.push(
|
||||
`${at}.packages.${manager} must be a plain package name (or a non-empty list of them) — ` +
|
||||
`lowercase, digits, dot, plus, dash, optionally with an architecture suffix ` +
|
||||
`(${DPKG_ARCHITECTURES.join(', ')})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,76 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
// No db/ — the mirror keeps nothing. The password lives in the owner's ~/.vnc, written by the sidecar.
|
||||
has: { api: true, schema: false, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'minesweeper',
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Minesweeper',
|
||||
summary: 'Minesweeper X under wine, on a headless display, streamed to the browser',
|
||||
color: '#64748b',
|
||||
icon: 'Bomb',
|
||||
source: 'https://gitea.officer.dev/plugins/minesweeper.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'minesweeper',
|
||||
label: 'Minesweeper',
|
||||
description:
|
||||
'Play Minesweeper on the server display. The game is confined to its own wine prefix and can reach nothing else on the machine.',
|
||||
},
|
||||
],
|
||||
// Six binaries, and this is the entry where the duplication above earns its keep: the owner sees what
|
||||
// installing this will make the machine acquire BEFORE consenting, and for wine that is not a small ask.
|
||||
//
|
||||
// `apt: 'wine'` is deliberately not the whole story and cannot be. The game is a 32-bit VB6 executable,
|
||||
// so the host also needs `dpkg --add-architecture i386` and `wine32:i386` — a system-wide change that no
|
||||
// package name can express, which is why the reason carries the command instead of pretending.
|
||||
//
|
||||
// Xvfb has no dnf entry because Fedora spells it `xorg-x11-server-Xvfb`, with a capital X, and a
|
||||
// manifest package name must be lowercase. Absent means "cannot be installed automatically here", which
|
||||
// becomes an instruction rather than a wrong guess.
|
||||
osDependencies: [
|
||||
{
|
||||
binary: 'wine',
|
||||
reason:
|
||||
'Runs the game. Minesweeper X is a 32-bit Windows executable, so this host also needs i386 multiarch (`dpkg --add-architecture i386`) and the 32-bit wine package — a 64-bit-only wine cannot load it.',
|
||||
multiarch: 'i386',
|
||||
packages: { apt: ['wine', 'wine32:i386'], dnf: 'wine', pacman: 'wine', brew: 'wine-stable' },
|
||||
},
|
||||
{
|
||||
binary: 'Xvfb',
|
||||
reason:
|
||||
'The virtual display the game draws on. A framebuffer with no hardware behind it, so nothing appears on any real monitor.',
|
||||
packages: { apt: 'xvfb', pacman: 'xorg-server-xvfb' },
|
||||
},
|
||||
{
|
||||
binary: 'x11vnc',
|
||||
reason:
|
||||
'Reads that display and serves it, so the browser has something to draw. Bound to a private unix socket, never a port.',
|
||||
packages: { apt: 'x11vnc', dnf: 'x11vnc', pacman: 'x11vnc' },
|
||||
},
|
||||
{
|
||||
binary: 'xdotool',
|
||||
reason:
|
||||
'Finds the game window and reads its size, so the panel can fit its canvas to the board instead of the whole framebuffer.',
|
||||
packages: { apt: 'xdotool', dnf: 'xdotool', pacman: 'xdotool' },
|
||||
},
|
||||
{
|
||||
binary: 'xdpyinfo',
|
||||
reason:
|
||||
'Waits for the display to be usable. A live Xvfb process is not the same as a display that answers, and starting the game too early fails silently.',
|
||||
packages: { apt: 'x11-utils', dnf: 'xorg-x11-utils', pacman: 'xorg-xdpyinfo' },
|
||||
},
|
||||
{
|
||||
binary: 'winetricks',
|
||||
reason:
|
||||
'Installs the Visual Basic 6 runtime into the wine prefix on first start. Minesweeper X needs MSVBVM60.DLL, which wine does not ship; without it the process exits immediately with nothing but an unresolved-import line.',
|
||||
packages: { apt: 'winetricks', dnf: 'winetricks', pacman: 'winetricks' },
|
||||
},
|
||||
],
|
||||
// No schema. The session is processes and a wine prefix on disk, and none of that belongs in Postgres.
|
||||
has: { api: true, schema: false, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'jellyfin',
|
||||
publisher: 'officerdev',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { DPKG_ARCHITECTURES, manifestProblems } from './manifest';
|
||||
import { reportDependencies } from './os-deps';
|
||||
import type { DiscoveredPlugin, OsDependency } from './manifest';
|
||||
|
||||
/**
|
||||
* A package cannot be installed for an architecture the host does not know about, and no package name can
|
||||
* say so — which is why `multiarch` is a field rather than something clever in the package string.
|
||||
*
|
||||
* Minesweeper is the case that produced it: `apt-get install wine` on a 64-bit host succeeds, reports a
|
||||
* clean plugin install, and leaves a 32-bit game dying with `Bad EXE format`. Nothing in that chain
|
||||
* mentions architectures, so it is worth pinning the two properties that keep it working.
|
||||
*/
|
||||
|
||||
const dep = (over: Partial<OsDependency> = {}): OsDependency => ({
|
||||
binary: 'definitely-not-a-real-binary-xyzzy',
|
||||
reason: 'test',
|
||||
packages: { apt: 'some-package' },
|
||||
...over,
|
||||
});
|
||||
|
||||
const plugin = (deps: OsDependency[]): DiscoveredPlugin =>
|
||||
({ appName: 'probe', dir: '/tmp/probe', manifest: { osDependencies: deps } }) as unknown as DiscoveredPlugin;
|
||||
|
||||
describe('multiarch is a closed vocabulary, not a command', () => {
|
||||
test('a valid architecture is accepted', () => {
|
||||
for (const arch of DPKG_ARCHITECTURES) {
|
||||
const problems = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ multiarch: arch })],
|
||||
} as never);
|
||||
expect(problems.filter((p) => p.includes('multiarch'))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
// The whole point of the enum. A manifest that could supply the string could run it as root on every
|
||||
// machine that installs the plugin, which is a far larger grant than "may install wine".
|
||||
test('anything else is refused, including something shell-shaped', () => {
|
||||
for (const bad of ['i486', 'I386', 'i386; rm -rf /', '$(whoami)', '']) {
|
||||
const problems = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ multiarch: bad as never })],
|
||||
} as never);
|
||||
expect(problems.some((p) => p.includes('multiarch'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a package name may carry a known architecture suffix, and only a known one', () => {
|
||||
const ok = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ packages: { apt: 'wine32:i386' } })],
|
||||
} as never);
|
||||
expect(ok.filter((p) => p.includes('packages'))).toEqual([]);
|
||||
|
||||
// The list form, which is what wine actually needs.
|
||||
const list = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ packages: { apt: ['wine', 'wine32:i386'] } })],
|
||||
} as never);
|
||||
expect(list.filter((p) => p.includes('packages'))).toEqual([]);
|
||||
|
||||
const bad = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ packages: { apt: 'wine32:sparc' } })],
|
||||
} as never);
|
||||
expect(bad.some((p) => p.includes('packages'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the report tells the owner what will CHANGE', () => {
|
||||
test('a missing dependency surfaces its architecture', () => {
|
||||
const report = reportDependencies(plugin([dep({ multiarch: 'i386' })]));
|
||||
expect(report.missing.length).toBe(1);
|
||||
// Reported on every host. Whether it is ACTED on is apt-only — see `enableArchitectures` — but the
|
||||
// owner is told what the plugin asks for regardless of what this machine will do about it.
|
||||
expect(report.architectures).toEqual(['i386']);
|
||||
});
|
||||
|
||||
// A binary already on PATH had its architecture enabled at some point in the past. Re-reporting it would
|
||||
// tell the owner the machine is about to change when it is not.
|
||||
test('a dependency that is already present does not', () => {
|
||||
const report = reportDependencies(plugin([dep({ binary: 'sh', multiarch: 'i386' })]));
|
||||
expect(report.missing.length).toBe(0);
|
||||
expect(report.architectures).toEqual([]);
|
||||
});
|
||||
|
||||
test('two dependencies wanting the same architecture report it once', () => {
|
||||
const report = reportDependencies(
|
||||
plugin([dep({ binary: 'nope-a-xyzzy', multiarch: 'i386' }), dep({ binary: 'nope-b-xyzzy', multiarch: 'i386' })]),
|
||||
);
|
||||
expect(report.architectures).toEqual(['i386']);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
||||
import { homedir, userInfo } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type OsDependency, type PackageManager } from './manifest';
|
||||
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type DpkgArchitecture, type OsDependency, type PackageManager } from './manifest';
|
||||
|
||||
// Host binaries a plugin needs, and getting them onto the machine.
|
||||
//
|
||||
@@ -58,7 +58,13 @@ export type DependencyStatus = {
|
||||
path: string | null;
|
||||
present: boolean;
|
||||
/** What would be installed on THIS machine, or null when this manager has no entry for it. */
|
||||
packageName: string | null;
|
||||
/**
|
||||
* The package(s) that would provide this binary here, or null when this manager has no entry for it.
|
||||
*
|
||||
* A LIST because one binary can need more than one package — wine needs both its launcher and the 32-bit
|
||||
* loader, and installing either alone gives a wine that cannot run what the plugin came for.
|
||||
*/
|
||||
packageName: string[] | null;
|
||||
/** Resolved download URL when the plugin ships a release binary, or null. */
|
||||
releaseUrl: string | null;
|
||||
/**
|
||||
@@ -81,8 +87,21 @@ export type DependencyStatus = {
|
||||
unreachable: boolean;
|
||||
/** Why it is unreachable, for the install log. */
|
||||
unreachableReason: string | null;
|
||||
/**
|
||||
* Carried through from the manifest so the report can say what installing this will CHANGE, not just
|
||||
* what it will fetch. Enabling an architecture is system-wide and permanent.
|
||||
*/
|
||||
multiarch: DpkgArchitecture | null;
|
||||
};
|
||||
|
||||
/** A dependency's packages for one manager, always as a list — the manifest allows either form. */
|
||||
export function packageNames(dep: OsDependency, manager: PackageManager): string[] | null {
|
||||
const entry = dep.packages[manager];
|
||||
if (!entry) return null;
|
||||
const names = Array.isArray(entry) ? entry : [entry];
|
||||
return names.length ? names : null;
|
||||
}
|
||||
|
||||
/** `linux`/`darwin` and `amd64`/`arm64` — the spelling Go releases use, which is who publishes this way. */
|
||||
export function hostTarget(): { os: string; arch: string } {
|
||||
const os = process.platform === 'darwin' ? 'darwin' : 'linux';
|
||||
@@ -252,8 +271,9 @@ function probe(dep: OsDependency, manager: PackageManager | null): DependencySta
|
||||
scope,
|
||||
unreachable: !!path && genuine && !reach.ok,
|
||||
unreachableReason: reach.reason,
|
||||
packageName: (manager && dep.packages[manager]) || null,
|
||||
packageName: manager ? packageNames(dep, manager) : null,
|
||||
releaseUrl: releaseUrlFor(dep),
|
||||
multiarch: dep.multiarch ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,6 +288,15 @@ export type DependencyReport = {
|
||||
* has. These are the owner's to resolve by hand, and the install says so rather than guessing.
|
||||
*/
|
||||
unresolvable: DependencyStatus[];
|
||||
/**
|
||||
* Architectures the missing packages need enabled first — `dpkg --add-architecture`, apt hosts only.
|
||||
*
|
||||
* Reported rather than done quietly because it is SYSTEM-WIDE and permanent: nothing here ever disables
|
||||
* one, including uninstalling the plugin. The owner should see "this will change how this machine
|
||||
* resolves packages" on the screen where they consent, not in a log afterwards.
|
||||
*/
|
||||
architectures: DpkgArchitecture[];
|
||||
|
||||
/** Can the platform actually run the install — passwordless sudo, or a manager that never needs it. */
|
||||
canInstall: boolean;
|
||||
/** Why not, when it cannot. Written to be shown to a person. */
|
||||
@@ -305,7 +334,7 @@ export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
|
||||
|
||||
// Deduped and sorted: ffmpeg and ffprobe are one apt package, and naming it twice on the command line
|
||||
// is the sort of thing that reads as a bug in a log even though apt does not care.
|
||||
const packagesToInstall = [...new Set(missing.map((d) => d.packageName).filter((n): n is string => !!n))].sort();
|
||||
const packagesToInstall = [...new Set(missing.flatMap((d) => d.packageName ?? []))].sort();
|
||||
|
||||
let canInstall = true;
|
||||
let blockedReason: string | null = null;
|
||||
@@ -343,7 +372,14 @@ export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
|
||||
'Install the packages yourself and then install the plugin.';
|
||||
}
|
||||
|
||||
return { manager, dependencies, missing, packagesToInstall, unresolvable, canInstall, blockedReason };
|
||||
// Only for the ones actually being installed. A binary already present needed its architecture enabled
|
||||
// at some point in the past, and re-reporting that would tell the owner the machine is about to change
|
||||
// when it is not.
|
||||
const architectures = [
|
||||
...new Set(missing.map((d) => d.multiarch).filter((a): a is DpkgArchitecture => !!a)),
|
||||
];
|
||||
|
||||
return { manager, dependencies, missing, packagesToInstall, unresolvable, architectures, canInstall, blockedReason };
|
||||
}
|
||||
|
||||
/** The command that would be run, as argv. Exported so the UI can SHOW it — the owner should see it. */
|
||||
@@ -451,6 +487,81 @@ export async function installRelease(dep: DependencyStatus): Promise<{ ok: boole
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enable a foreign architecture, so a package that only exists for it can be installed.
|
||||
*
|
||||
* ── Why the platform runs this at all ──
|
||||
*
|
||||
* Because no package name can express it. `wine32:i386` is not installable until dpkg has been told the
|
||||
* architecture exists, and that is a change to the machine's configuration rather than a package to
|
||||
* fetch. Leaving it to the owner means an install that reports success and produces a plugin that does
|
||||
* not work — which is exactly what happened with Minesweeper: `wine` alone lands 64-bit-only and the game
|
||||
* dies with `Bad EXE format`, several steps away from anything that mentions architectures.
|
||||
*
|
||||
* ── What it will and will not do ──
|
||||
*
|
||||
* apt hosts only. pacman and brew have no equivalent, and dnf resolves multilib without being asked.
|
||||
*
|
||||
* It is SYSTEM-WIDE and permanent. Nothing here ever removes an architecture, including
|
||||
* `plugins:delete` — something else may depend on it by then, and disabling one with packages installed
|
||||
* against it is how a machine ends up unable to run `apt-get -f install`. The manifest field is shown to
|
||||
* the owner before they consent for this reason.
|
||||
*
|
||||
* Already-enabled is success and costs one `dpkg` call. `apt-get update` runs only when something
|
||||
* actually changed, because it is slow and there is no reason to pay for it otherwise.
|
||||
*/
|
||||
async function enableArchitectures(
|
||||
manager: PackageManager,
|
||||
architectures: DpkgArchitecture[],
|
||||
): Promise<{ ok: boolean; output: string; error?: string }> {
|
||||
const wanted = [...new Set(architectures)];
|
||||
if (!wanted.length || manager !== 'apt') return { ok: true, output: '' };
|
||||
if (!Bun.which('dpkg')) return { ok: true, output: '' };
|
||||
|
||||
const current = Bun.spawnSync(['dpkg', '--print-foreign-architectures']);
|
||||
const enabled = new Set(new TextDecoder().decode(current.stdout).split(/\s+/).filter(Boolean));
|
||||
const missing = wanted.filter((a) => !enabled.has(a));
|
||||
if (!missing.length) return { ok: true, output: '' };
|
||||
|
||||
const escalate = sudoAvailable() ? ['sudo', '-n'] : [];
|
||||
let output = '';
|
||||
|
||||
for (const arch of missing) {
|
||||
// argv, never a shell string — and `arch` came from a closed enum in the manifest validator, so this
|
||||
// is belt and braces rather than the only guard. Both halves are deliberate; see the header comment.
|
||||
const proc = Bun.spawn([...escalate, 'dpkg', '--add-architecture', arch], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
output += `${out}${err}`;
|
||||
if (code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
output: output.trim().slice(-600),
|
||||
error:
|
||||
`could not enable the ${arch} architecture, which this plugin's packages need. ` +
|
||||
`Run it by hand: sudo dpkg --add-architecture ${arch} && sudo apt-get update`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The package lists have no entries for an architecture that was not enabled a moment ago, so without
|
||||
// this the install that follows cannot find anything to install.
|
||||
const upd = Bun.spawn([...escalate, 'apt-get', 'update'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [uo, ue] = await Promise.all([new Response(upd.stdout).text(), new Response(upd.stderr).text()]);
|
||||
const updCode = await upd.exited;
|
||||
output += `${uo}${ue}`;
|
||||
if (updCode !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
output: output.trim().slice(-600),
|
||||
error: `enabled ${missing.join(', ')} but \`apt-get update\` failed, so the new package lists are not available yet`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, output: output.trim().slice(-600) };
|
||||
}
|
||||
|
||||
export type InstallDependenciesResult = { ok: boolean; installed: string[]; output: string; error?: string };
|
||||
|
||||
/**
|
||||
@@ -463,9 +574,16 @@ export type InstallDependenciesResult = { ok: boolean; installed: string[]; outp
|
||||
export async function installDependencies(
|
||||
manager: PackageManager,
|
||||
packages: string[],
|
||||
architectures: DpkgArchitecture[] = [],
|
||||
): Promise<InstallDependenciesResult> {
|
||||
if (!packages.length) return { ok: true, installed: [], output: '' };
|
||||
|
||||
// Architectures FIRST, and the order is not cosmetic: `apt-get install wine32:i386` fails outright on a
|
||||
// host that has not been told i386 exists, so doing this after would install nothing and report a
|
||||
// package that does not exist.
|
||||
const arch = await enableArchitectures(manager, architectures);
|
||||
if (!arch.ok) return { ok: false, installed: [], output: arch.output, error: arch.error };
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(installCommand(manager, packages), { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
|
||||
@@ -120,6 +120,25 @@ export function unregisterSidecar(id: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Every sidecar's announced HTTP port, by name.
|
||||
|
||||
`createSidecarProxy` already learns this for the prefix it serves, by subscribing to `<name>:server`. That
|
||||
is per-proxy state and unreachable from anywhere else, which was fine while the only consumer was the
|
||||
proxy itself — and stopped being fine when a plugin needed a WEBSOCKET relayed onto the same sidecar. A
|
||||
second subscription in a second place would be a second thing to keep in step.
|
||||
|
||||
So the port is recorded here, once, from the same event. A sidecar that has not announced yet is absent
|
||||
rather than zero: "not listening" and "listening on nothing" are different answers and only one of them is
|
||||
true.
|
||||
*/
|
||||
const sidecarPorts = new Map<string, number>();
|
||||
|
||||
/** The port a named sidecar announced, or null if it has not announced one. */
|
||||
export function sidecarPort(name: string): number | null {
|
||||
return sidecarPorts.get(name) ?? null;
|
||||
}
|
||||
|
||||
export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
||||
// Check if this is a response to a pending request
|
||||
if ('id' in msg && msg.id && pending.has(msg.id)) {
|
||||
@@ -130,6 +149,13 @@ export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record any `<name>:server` announcement before dispatching, so `sidecarPort` is current by the time a
|
||||
// subscriber reacts to the same event.
|
||||
if ('type' in msg && typeof msg.type === 'string' && msg.type.endsWith(':server')) {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port === 'number') sidecarPorts.set(msg.type.slice(0, -':server'.length), port);
|
||||
}
|
||||
|
||||
// Otherwise dispatch as event
|
||||
dispatchEvent(msg);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getUserById } from 'officerdb';
|
||||
import { getUserById, OWNER_USER_ID } from 'officerdb';
|
||||
|
||||
// Identifies the platform owner. The user's `role` column IS the answer — there is no second mechanism
|
||||
// and no environment variable.
|
||||
@@ -13,10 +13,30 @@ import { getUserById } from 'officerdb';
|
||||
// are editable, and a cache with no invalidation contract is a staleness bug waiting for whoever builds
|
||||
// the role UI. This is one primary-key lookup on a request that has already verified a JWT.
|
||||
export async function isSuperAdmin(payload: { id?: number } | null | undefined): Promise<boolean> {
|
||||
if (!payload?.id) return false;
|
||||
return isOwner(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner is an IDENTITY, not a role. `OWNER_USER_ID` and nothing else.
|
||||
*
|
||||
* This asked `user.role === 'Super Admin'` until 2026-08-23, which was correct while roles were a hardcoded
|
||||
* TypeScript union and nothing could write a new one. The moment roles became a table it became a
|
||||
* privilege-escalation path: "add a role" and "become the owner" are the same feature, reachable from
|
||||
* whatever UI manages roles.
|
||||
*
|
||||
* So the question moved to one nothing in the product can answer for itself. `Super Admin` is still a role
|
||||
* and several accounts may hold it — it provisions an OS account with sudo, which is its own decision — but
|
||||
* holding it is not being the owner and no role grants that.
|
||||
*
|
||||
* Kept as a database read rather than a bare comparison, deliberately. A deleted or blocked account must not
|
||||
* still be the owner because its id happens to match, and this is one primary-key lookup on a request that
|
||||
* has already verified a JWT.
|
||||
*/
|
||||
export async function isOwner(payload: { id?: number } | null | undefined): Promise<boolean> {
|
||||
if (payload?.id !== OWNER_USER_ID) return false;
|
||||
try {
|
||||
const user = await getUserById(payload.id);
|
||||
return user?.role === 'Super Admin';
|
||||
const user = await getUserById(OWNER_USER_ID);
|
||||
return !!user;
|
||||
} catch {
|
||||
// A transient database error must never promote anyone. Deny, and let the next call retry.
|
||||
return false;
|
||||
|
||||
+10
-3
@@ -2,15 +2,22 @@ import type { ReactNode } from 'react';
|
||||
|
||||
type UntranslatedProps = {
|
||||
children: ReactNode;
|
||||
/** Why this text is not localized. Required so the decision is legible at the call site. */
|
||||
reason: 'foreign-ui-label';
|
||||
/**
|
||||
* Why this text is not localized. Required so the decision is legible at the call site.
|
||||
*
|
||||
* `foreign-ui-label` — quoted from another product's interface ("click **Enable**"). It has to match
|
||||
* what the reader sees on THEIR screen, and that product localizes by its own account language.
|
||||
* `product-name` — the name of a product or feature: Tmux, Neovim, Claude Code, Officer. The doc's
|
||||
* never-translate list covers these directly.
|
||||
*/
|
||||
reason: 'foreign-ui-label' | 'product-name';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Text that is deliberately NOT localized, marked as such where it is written.
|
||||
*
|
||||
* The only reason so far is `foreign-ui-label`: a label quoted from another product's interface, which
|
||||
* The first reason was `foreign-ui-label`: a label quoted from another product's interface, which
|
||||
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
|
||||
* what the reader sees on Google's screen, and Google localises by their Google account language rather
|
||||
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"ok": "OK",
|
||||
"close": "Schließen"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Suchen",
|
||||
"open": "Suche öffnen",
|
||||
"close": "Suche schließen",
|
||||
"clear": "Suche leeren"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Auswählen..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtern"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Im Terminal ausführen",
|
||||
"copy": "Befehl kopieren"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Hex-Farbe"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Wird geladen…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "Keine Daten gefunden.",
|
||||
"pagination": {
|
||||
"showingPage": "Seite {{current}} von {{total}}",
|
||||
"showingPageShort": "S. {{current}}/{{total}}",
|
||||
"rowsPerPage": "Zeilen pro Seite",
|
||||
"perPage": "{{size}} / Seite",
|
||||
"perPageShort": "{{size}}/S.",
|
||||
"first": "Erste",
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter",
|
||||
"last": "Letzte"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Seitennummerierung",
|
||||
"previous": "Zurück",
|
||||
"next": "Weiter",
|
||||
"morePages": "Weitere Seiten",
|
||||
"goToPrevious": "Zur vorherigen Seite",
|
||||
"goToNext": "Zur nächsten Seite"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Vorherige Folie",
|
||||
"next": "Nächste Folie"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Mehr"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Farbmodus umschalten"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Zugriff nicht autorisiert",
|
||||
"description": "Sie sind nicht berechtigt, diese Aktion auszuführen.",
|
||||
"hint": "Wenn Sie das für einen Fehler halten, wenden Sie sich an Ihre zuständige Person."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Ungültige Anmeldedaten",
|
||||
"description": "Ihr Zugriffstoken ist abgelaufen",
|
||||
"hint": "Bitte melden Sie sich erneut an"
|
||||
},
|
||||
"server": {
|
||||
"title": "Serverfehler"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Officer-Symbol",
|
||||
"logo": "Officer-Logo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar",
|
||||
"open": "Abrir búsqueda",
|
||||
"close": "Cerrar búsqueda",
|
||||
"clear": "Borrar búsqueda"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Seleccionar..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrar"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Ejecutar en el terminal",
|
||||
"copy": "Copiar comando"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Color hexadecimal"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Cargando…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "No se encontraron datos.",
|
||||
"pagination": {
|
||||
"showingPage": "Mostrando página {{current}} de {{total}}",
|
||||
"showingPageShort": "p {{current}}/{{total}}",
|
||||
"rowsPerPage": "Filas por página",
|
||||
"perPage": "{{size}} / página",
|
||||
"perPageShort": "{{size}}/p",
|
||||
"first": "Primera",
|
||||
"prev": "Anterior",
|
||||
"next": "Siguiente",
|
||||
"last": "Última"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Paginación",
|
||||
"previous": "Anterior",
|
||||
"next": "Siguiente",
|
||||
"morePages": "Más páginas",
|
||||
"goToPrevious": "Ir a la página anterior",
|
||||
"goToNext": "Ir a la página siguiente"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Diapositiva anterior",
|
||||
"next": "Diapositiva siguiente"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Más"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Alternar modo de color"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Acceso no autorizado",
|
||||
"description": "No tienes autorización para realizar esta acción.",
|
||||
"hint": "Si crees que esto es un error, contacta con tu responsable."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Credenciales no válidas",
|
||||
"description": "Tu token de acceso ha caducado",
|
||||
"hint": "Inicia sesión de nuevo"
|
||||
},
|
||||
"server": {
|
||||
"title": "Error del servidor"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Icono de Officer",
|
||||
"logo": "Logotipo de Officer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"ok": "OK",
|
||||
"close": "Fermer"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher",
|
||||
"open": "Ouvrir la recherche",
|
||||
"close": "Fermer la recherche",
|
||||
"clear": "Effacer la recherche"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Sélectionner..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrer"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Exécuter dans le terminal",
|
||||
"copy": "Copier la commande"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Couleur hexadécimale"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Chargement…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "Aucune donnée trouvée.",
|
||||
"pagination": {
|
||||
"showingPage": "Page {{current}} sur {{total}}",
|
||||
"showingPageShort": "p {{current}}/{{total}}",
|
||||
"rowsPerPage": "Lignes par page",
|
||||
"perPage": "{{size}} / page",
|
||||
"perPageShort": "{{size}}/p",
|
||||
"first": "Première",
|
||||
"prev": "Précédente",
|
||||
"next": "Suivante",
|
||||
"last": "Dernière"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"morePages": "Plus de pages",
|
||||
"goToPrevious": "Aller à la page précédente",
|
||||
"goToNext": "Aller à la page suivante"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Diapositive précédente",
|
||||
"next": "Diapositive suivante"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Plus"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Basculer le mode de couleur"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Accès non autorisé",
|
||||
"description": "Vous n'êtes pas autorisé à effectuer cette action.",
|
||||
"hint": "Si vous pensez qu'il s'agit d'une erreur, contactez votre responsable."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Identifiants invalides",
|
||||
"description": "Votre jeton d'accès a expiré",
|
||||
"hint": "Reconnectez-vous"
|
||||
},
|
||||
"server": {
|
||||
"title": "Erreur du serveur"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Icône Officer",
|
||||
"logo": "Logo Officer"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import deDE from './de-DE.json';
|
||||
import enGB from './en-GB.json';
|
||||
import esES from './es-ES.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
/** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */
|
||||
export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||
export const uiCatalogs: TranslationMap = {
|
||||
'en-GB': enGB,
|
||||
'pt-PT': ptPT,
|
||||
'es-ES': esES,
|
||||
'de-DE': deDE,
|
||||
'fr-FR': frFR,
|
||||
};
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
|
||||
@@ -2,11 +2,14 @@ import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import deDE from './de-DE.json';
|
||||
import esES from './es-ES.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
|
||||
// (ui.* strings land in phase 1) — the test still runs so it is load-bearing from day one.
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and another locale lacks is a red
|
||||
// build, not a warning — there is no regional fallback to paper over the gap.
|
||||
describe('shared components (ui.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
test('every shipped locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT, 'fr-FR': frFR, 'de-DE': deDE, 'es-ES': esES })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { I18nContextValue, InterpolationParams, TranslationMap } from './types';
|
||||
import { interpolate, resolveKey } from './resolve';
|
||||
import { setActiveI18n } from './standalone';
|
||||
|
||||
export const I18nContext = createContext<I18nContextValue>({
|
||||
t: (key: string) => key,
|
||||
@@ -17,6 +18,10 @@ type I18nProviderProps = {
|
||||
};
|
||||
|
||||
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
||||
// Mirrored for the one caller that cannot use a hook — see standalone.ts. Set during render rather
|
||||
// than in an effect so it is never a render behind what this provider is handing out.
|
||||
setActiveI18n({ translations, locale, fallbackLocale });
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
const t = (key: string, params?: InterpolationParams) => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
|
||||
@@ -3,5 +3,7 @@ export { useTranslation } from './useTranslation';
|
||||
export { useFormat } from './useFormat';
|
||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
export { findMissingKeys } from './parity';
|
||||
// Deliberately NOT re-exporting translateStandalone — see standalone.ts on why it is awkward to reach.
|
||||
export { activeLocale } from './standalone';
|
||||
export type { MissingKey } from './parity';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InterpolationParams, TranslationMap } from './types';
|
||||
import { interpolate, resolveKey } from './resolve';
|
||||
|
||||
/**
|
||||
* The active locale, readable OUTSIDE a React render.
|
||||
*
|
||||
* There is exactly one caller and it is not a shortcut: a panel's name in the app registry is
|
||||
* module-level data, and the framework invokes its thunk from a render where no hook of ours is
|
||||
* reachable — `workspaces` has no dependencies at all, deliberately, not even this package.
|
||||
*
|
||||
* NOT a general escape hatch. Anything inside a component uses `useTranslation`, which is reactive.
|
||||
* This is not reactive: writing here re-renders nothing. It is only correct where the caller is
|
||||
* already re-rendering for another reason, which is why `translateStandalone` is deliberately awkward
|
||||
* to reach and documented rather than exported from the package barrel.
|
||||
*/
|
||||
type ActiveI18n = { translations: TranslationMap; locale: string; fallbackLocale: string };
|
||||
|
||||
let active: ActiveI18n = { translations: {}, locale: 'en', fallbackLocale: 'en' };
|
||||
|
||||
/** Called by `I18nProvider` on every render, so this never lags the provider it mirrors. */
|
||||
export const setActiveI18n = (next: ActiveI18n): void => {
|
||||
active = next;
|
||||
};
|
||||
|
||||
export const translateStandalone = (key: string, params?: InterpolationParams): string => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
return interpolate(resolveKey(active.translations, active.locale, key, active.fallbackLocale, count), params);
|
||||
};
|
||||
|
||||
/** The locale the provider currently holds. Exposed so a caller can key a render on it. */
|
||||
export const activeLocale = (): string => active.locale;
|
||||
@@ -11,7 +11,6 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
|
||||
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
||||
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||
@@ -30,7 +29,6 @@ export const apps = [
|
||||
...widgetMetas,
|
||||
...monitorMetas,
|
||||
...qrTransferMetas,
|
||||
...minesweeperMetas,
|
||||
...sendOffMetas,
|
||||
...davMetas,
|
||||
...commandHqMetas,
|
||||
|
||||
@@ -30,6 +30,7 @@ export const findDuplicateKeys = (metas: AppRegistryMeta[]): string[] => [
|
||||
export const metasToRegistry = (metas: AppRegistryMeta[]): AppRegistryMap => {
|
||||
const duplicates = findDuplicateKeys(metas);
|
||||
if (duplicates.length > 0)
|
||||
// i18n-exempt: console diagnostic — read from a terminal, stays greppable in one language
|
||||
console.error(`AppRegistry: duplicate app key(s) ${duplicates.join(', ')} — one app is unreachable`);
|
||||
|
||||
return Object.fromEntries(metas.map(({ key, ...entry }) => [key, entry]));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router';
|
||||
import { Loader2, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// One entry: what it is, how to install it, and what to do about it once installed.
|
||||
//
|
||||
@@ -41,6 +42,7 @@ function Field({ field, value, onChange }: { field: ConfigField; value: string;
|
||||
}
|
||||
|
||||
export const AppStoreDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const id = params.get('selected');
|
||||
const { items, install, setEnabled, uninstall } = useAppStore();
|
||||
@@ -51,7 +53,11 @@ export const AppStoreDetail = () => {
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
if (!item) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select an app</div>;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t('appStore.selectApp')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" —
|
||||
@@ -82,13 +88,13 @@ export const AppStoreDetail = () => {
|
||||
person. `completeAt` links to the page that mints the token. */}
|
||||
{item.install.status === 'blocked' && item.install.lastError && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<div className="font-medium">Needs you</div>
|
||||
<div className="font-medium">{t('appStore.needsYou')}</div>
|
||||
<p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.install.status === 'failed' && item.install.lastError && (
|
||||
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm">
|
||||
<div className="font-medium">Install failed</div>
|
||||
<div className="font-medium">{t('appStore.installFailed')}</div>
|
||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -97,7 +103,7 @@ export const AppStoreDetail = () => {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '}
|
||||
{item.install.enabled ? 'enabled' : 'disabled'}
|
||||
{item.install.enabled ? t('appStore.enabled') : t('appStore.disabled')}
|
||||
{item.processStatus && ` · process ${item.processStatus}`}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -106,17 +112,14 @@ export const AppStoreDetail = () => {
|
||||
disabled={setEnabled.isPending}
|
||||
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })}
|
||||
>
|
||||
{item.install.enabled ? 'Disable' : 'Enable'}
|
||||
{item.install.enabled ? t('appStore.disable') : t('appStore.enable')}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
|
||||
Uninstall
|
||||
{t('appStore.uninstall')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Said plainly, because it is the question anyone hesitates over before clicking. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data,
|
||||
configuration and this app’s tables are kept either way.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('appStore.removalNote')}</p>
|
||||
</div>
|
||||
) : (
|
||||
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
|
||||
import { useAppStore, type StoreItem } from './useAppStore';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// The list of everything installable, with what has happened to each.
|
||||
//
|
||||
@@ -10,26 +11,31 @@ import { useAppStore, type StoreItem } from './useAppStore';
|
||||
|
||||
/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
|
||||
function StatusBadge({ item }: { item: StoreItem }) {
|
||||
const { t } = useTranslation();
|
||||
const { status, enabled } = item.install;
|
||||
|
||||
if (status === 'not-installed') return null;
|
||||
if (status === 'installing') {
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label="installing" />;
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label={t('appStore.installing')} />;
|
||||
}
|
||||
if (status === 'failed') return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label="failed" />;
|
||||
if (status === 'failed')
|
||||
return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label={t('appStore.failed')} />;
|
||||
// Blocked is not a failure: everything worked and it is waiting for something only a person can give.
|
||||
if (status === 'blocked') return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label="needs you" />;
|
||||
if (!enabled) return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label="disabled" />;
|
||||
if (status === 'blocked')
|
||||
return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label={t('appStore.needsYouShort')} />;
|
||||
if (!enabled)
|
||||
return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label={t('appStore.disabled')} />;
|
||||
|
||||
// Installed and enabled, but the process is not running — the case worth surfacing rather than
|
||||
// showing a tick that lies.
|
||||
if (item.processStatus && item.processStatus !== 'online') {
|
||||
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />;
|
||||
}
|
||||
return <Check className="h-3.5 w-3.5 text-success" aria-label="installed" />;
|
||||
return <Check className="h-3.5 w-3.5 text-success" aria-label={t('appStore.installedIcon')} />;
|
||||
}
|
||||
|
||||
export const AppStoreList = () => {
|
||||
const { t } = useTranslation();
|
||||
const { items, isLoading, error } = useAppStore();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
@@ -37,14 +43,14 @@ export const AppStoreList = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('appStore.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
// Distinguished from "nothing installable", which would otherwise look identical and is not a
|
||||
// problem the user can act on.
|
||||
return <div className="p-4 text-sm text-destructive">Could not load the app store.</div>;
|
||||
return <div className="p-4 text-sm text-destructive">{t('appStore.loadFailed')}</div>;
|
||||
}
|
||||
|
||||
const installed = items.filter((i) => i.install.status !== 'not-installed');
|
||||
@@ -66,13 +72,17 @@ export const AppStoreList = () => {
|
||||
<div className="flex h-full flex-col overflow-y-auto p-2">
|
||||
{installed.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Installed</div>
|
||||
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('appStore.installed')}
|
||||
</div>
|
||||
{installed.map((item) => (
|
||||
<Row key={item.id} item={item} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</div>
|
||||
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('appStore.available')}
|
||||
</div>
|
||||
{available.map((item) => (
|
||||
<Row key={item.id} item={item} />
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Store } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { AppStoreList } from './AppStoreList';
|
||||
import { AppStoreDetail } from './AppStoreDetail';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { AppStoreList } from './AppStoreList';
|
||||
export { AppStoreDetail } from './AppStoreDetail';
|
||||
@@ -12,6 +13,18 @@ export type { StoreItem, InstallMode, ConfigField } from './useAppStore';
|
||||
// side by side. `availableOnPanel: false` keeps them off the generic panel picker — they only make
|
||||
// sense together, on /app-store.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'app-store-list', name: 'App store', icon: Store, component: AppStoreList, availableOnPanel: false },
|
||||
{ key: 'app-store-detail', name: 'App detail', icon: Store, component: AppStoreDetail, availableOnPanel: false },
|
||||
{
|
||||
key: 'app-store-list',
|
||||
name: panelName('panelName.appStore'),
|
||||
icon: Store,
|
||||
component: AppStoreList,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'app-store-detail',
|
||||
name: panelName('panelName.appDetail'),
|
||||
icon: Store,
|
||||
component: AppStoreDetail,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Users, X } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { UseAgentPanelType } from './useAgentPanel';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
type AgentIdentityBarProps = { panel: UseAgentPanelType };
|
||||
|
||||
@@ -15,6 +16,7 @@ type AgentIdentityBarProps = { panel: UseAgentPanelType };
|
||||
* the two apart is to notice that the conversation did not reset.
|
||||
*/
|
||||
export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [naming, setNaming] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -56,7 +58,7 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setNaming(false)}
|
||||
aria-label="Cancel"
|
||||
aria-label={tUi('button.cancel')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -69,9 +71,9 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 border-b px-2 py-1.5 text-xs">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">This panel's agent no longer exists.</span>
|
||||
<span className="flex-1 truncate">{t('chat.agent.gone')}</span>
|
||||
<Button size="sm" variant="ghost" className="h-6 px-2 text-xs" onClick={panel.release}>
|
||||
Forget it
|
||||
{t('chat.agent.forget')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -87,7 +89,7 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
|
||||
onClick={() => setNaming(true)}
|
||||
>
|
||||
<Users className="mr-1 h-3.5 w-3.5" />
|
||||
Name this agent
|
||||
{t('chat.agent.name')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -101,7 +103,7 @@ export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => {
|
||||
<span className="font-medium">{panel.agent.name}</span>
|
||||
<span
|
||||
className="text-muted-foreground flex-1 truncate"
|
||||
title={peers.length ? `Peers: ${peers.map((p) => p.name).join(', ')}` : undefined}
|
||||
title={peers.length ? t('chat.agent.peers', { names: peers.map((p) => p.name).join(', ') }) : undefined}
|
||||
>
|
||||
{peers.length === 0
|
||||
? 'no peers yet'
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import { useWorkspace, cwdToPath } from '../../components/Workspace';
|
||||
import { usePanelSession } from './use-panel-session';
|
||||
import type { ChatMessage } from './types';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
export type ChatSessionSelection = {
|
||||
sessionId: string | null;
|
||||
@@ -33,6 +34,7 @@ export type ChatSessionSelection = {
|
||||
const title = (s: { title: string }) => s.title || '(untitled)';
|
||||
|
||||
export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const { workspace, cwd } = useWorkspace();
|
||||
|
||||
/**
|
||||
@@ -57,7 +59,7 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const current = sessions.find((s) => s.id === sessionId);
|
||||
const label = current ? title(current) : sessionId ? 'Conversation' : 'New chat';
|
||||
const label = current ? title(current) : sessionId ? 'Conversation' : t('chat.newChat');
|
||||
|
||||
const commitRename = async (id: string) => {
|
||||
const next = draft.trim();
|
||||
@@ -80,7 +82,7 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
names it, because only the harness can decide that. */}
|
||||
<DropdownMenuItem onClick={() => setSessionId(null)} className="cursor-pointer">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New chat
|
||||
{t('chat.newChat')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{sessions.length > 0 && <DropdownMenuSeparator />}
|
||||
@@ -107,10 +109,15 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
|
||||
/>
|
||||
<button type="submit" className="p-0.5 cursor-pointer" title="Save">
|
||||
<button type="submit" className="p-0.5 cursor-pointer" title={t('common.save')}>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setRenaming(null)} className="p-0.5 cursor-pointer" title="Cancel">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenaming(null)}
|
||||
className="p-0.5 cursor-pointer"
|
||||
title={tUi('button.cancel')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
@@ -124,7 +131,7 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
{/* Siblings of the label, never nested inside it — these mutate, they do not select. */}
|
||||
<span className="ml-2 flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
title="Rename"
|
||||
title={t('chat.rename')}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -136,11 +143,11 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
title="Delete"
|
||||
title={t('common.delete')}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (!confirm(`Delete "${title(s)}"?`)) return;
|
||||
if (!confirm(t('chat.deleteQ', { title: title(s) }))) return;
|
||||
// Deleting the one on screen leaves this panel pointing at nothing, so it starts a
|
||||
// new conversation rather than showing a transcript that no longer exists.
|
||||
if (s.id === sessionId) setSessionId(null);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ModelSelector } from '../components/ModelSelector';
|
||||
import { AttachmentList } from '../components/AttachmentList';
|
||||
import { AttachButton } from '../components/AttachButton';
|
||||
import { WebpageDialog } from '../components/WebpageDialog';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type ChatLauncherProps = {
|
||||
availableModels: ModelOption[];
|
||||
@@ -26,8 +27,10 @@ export function ChatLauncher({
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
onSubmit,
|
||||
placeholder = 'What do you want to work on now?',
|
||||
placeholder,
|
||||
}: ChatLauncherProps) {
|
||||
const { t } = useTranslation();
|
||||
const composerPlaceholder = placeholder ?? t('chat.composerPlaceholder');
|
||||
const { attachments, attachWebpage, attachImage, removeAttachment, clearAttachments, processAttachments } =
|
||||
useAttachments();
|
||||
const [input, setInput] = useState('');
|
||||
@@ -95,7 +98,7 @@ export function ChatLauncher({
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
placeholder={composerPlaceholder}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-2 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none text-lg"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel';
|
||||
import { usePanelSession } from './use-panel-session';
|
||||
import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import { cwdToPath } from '../../components/Workspace';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
/** A fetched transcript, ready to open on. */
|
||||
type ResumeState = {
|
||||
@@ -80,6 +81,7 @@ const ChatPanelInner = ({
|
||||
resume,
|
||||
panelId,
|
||||
}: ChatPanelInnerProps) => {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* Resuming needs the TRANSCRIPT, not just the id.
|
||||
*
|
||||
@@ -111,7 +113,7 @@ const ChatPanelInner = ({
|
||||
`claudeSessionId`, NOT `sessionId`. The panel's stored id is looked up in the sessions LIST — see
|
||||
`ChatHeader`'s `sessions.find((s) => s.id === sessionId)` — and that list is keyed on Claude's
|
||||
transcript id. Recording officer's own session key here put two id spaces on either side of one
|
||||
lookup: it never matched, so a new chat's header read "New chat" for the life of the conversation and
|
||||
lookup: it never matched, so a new chat's header read t('chat.newChat') for the 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 why navigating away and back appeared to
|
||||
be the cure.
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { UseChatType } from '../../../hooks/useChat';
|
||||
import { useEmbeddableChat } from './useEmbeddableChat';
|
||||
import { MessageList } from '../components/MessageList';
|
||||
import { InputArea } from '../components/InputArea';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type EmbeddableChatProps = {
|
||||
sessionId?: string;
|
||||
/**
|
||||
* What the transcript shows before there is anything in it. `null` shows nothing.
|
||||
*
|
||||
* The default, "Send a message to start", is true on the chat screen and wrong wherever something else
|
||||
* The default, t('chat.composer.startHint'), is true on the chat screen and wrong wherever something else
|
||||
* already explains what to do — a new commander has a setup card and a Start button above it, so the
|
||||
* message contradicts them and takes 200px of height while doing it.
|
||||
*/
|
||||
|
||||
@@ -61,7 +61,8 @@ export type BackgroundJobSpec = {
|
||||
const durationLine = (d: BackgroundJobSpec['duration']): string =>
|
||||
d === null
|
||||
? 'Run until I stop it. Do not bound the loop by time.'
|
||||
: `Run for about ${d} minute${d === 1 ? '' : 's'}, then exit 1 to say it timed out.`;
|
||||
: // i18n-exempt: agent prompt — this whole module builds the brief the MODEL executes
|
||||
`Run for about ${d} minute${d === 1 ? '' : 's'}, then exit 1 to say it timed out.`;
|
||||
|
||||
/**
|
||||
* Rule 7 — what happens on fire. `stop` and `handoff` both exit; only the sentence after that differs,
|
||||
@@ -200,6 +201,7 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
|
||||
``,
|
||||
what,
|
||||
``,
|
||||
// i18n-exempt: agent prompt — this whole module builds the brief the MODEL executes
|
||||
`WORKING DIRECTORY: ${spec.cwd}`,
|
||||
``,
|
||||
`MECHANICS:`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type AttachButtonProps = {
|
||||
/**
|
||||
@@ -37,6 +38,7 @@ export function AttachButton({
|
||||
onAttachText,
|
||||
size = 'sm',
|
||||
}: AttachButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||
const textInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -66,7 +68,12 @@ export function AttachButton({
|
||||
const fence = text.includes('```') ? '````' : '```';
|
||||
onAttachText(`${fence} ${file.name}\n${text.replace(/\s+$/, '')}\n${fence}`);
|
||||
} catch (err) {
|
||||
toast.error(`Could not read ${file.name}: ${err instanceof Error ? err.message : 'unknown error'}`);
|
||||
toast.error(
|
||||
t('chat.attach.readFailed', {
|
||||
name: file.name,
|
||||
reason: err instanceof Error ? err.message : t('chat.attach.unknownError'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,7 +83,7 @@ export function AttachButton({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Attach"
|
||||
aria-label={t('chat.attach.attach')}
|
||||
className={`shrink-0 ${sizeClasses} flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer`}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
@@ -86,7 +93,7 @@ export function AttachButton({
|
||||
{onAttachImage && (
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||
<Image className="mr-2 h-4 w-4" />
|
||||
Image
|
||||
{t('chat.attach.image')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* PDF sat here once with no `onSelect` at all — the menu offered it and did nothing, because
|
||||
@@ -102,11 +109,11 @@ export function AttachButton({
|
||||
)}
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => textInputRef.current?.click()}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Text File
|
||||
{t('chat.attach.textFile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={onAttachWebpage}>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
Webpage URL
|
||||
{t('chat.attach.webpageUrl')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { buildBackgroundJobPrompt, type BackgroundJobSpec, type FireBehavior } from '../background-job-prompt';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
// Spawning a background job.
|
||||
//
|
||||
@@ -64,6 +65,7 @@ export const BackgroundJobDialog = ({
|
||||
isCommander = false,
|
||||
recordPaths,
|
||||
}: BackgroundJobDialogProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [mode, setMode] = useState<'command' | 'prompt'>('command');
|
||||
const [body, setBody] = useState('');
|
||||
const [watching, setWatching] = useState(false);
|
||||
@@ -130,10 +132,11 @@ export const BackgroundJobDialog = ({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CirclePlay className={`h-4 w-4 ${toneText.warning}`} />
|
||||
Background job
|
||||
{t('chat.job.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Runs in <code className="font-mono text-xs">{cwd}</code>, tracked by the harness — if it dies, you are told.
|
||||
{t('chat.job.runsIn')} <code className="font-mono text-xs">{cwd}</code>, tracked by the harness — if it
|
||||
dies, you are told.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -146,11 +149,11 @@ export const BackgroundJobDialog = ({
|
||||
disabled={watching}
|
||||
onChange={() => setMode('command')}
|
||||
/>
|
||||
<span className={watching ? 'opacity-40' : ''}>Command — run exactly this</span>
|
||||
<span className={watching ? 'opacity-40' : ''}>{t('chat.job.command')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="radio" checked={mode === 'prompt'} onChange={() => setMode('prompt')} />
|
||||
<span>Prompt — describe it, the agent works it out</span>
|
||||
<span>{t('chat.job.prompt')}</span>
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -164,7 +167,7 @@ export const BackgroundJobDialog = ({
|
||||
<div className="space-y-2 rounded-md border border-duck-dark/10 p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={watching} onChange={(ev) => toggleWatching(ev.target.checked)} />
|
||||
Watch for something
|
||||
{t('chat.job.watch')}
|
||||
</label>
|
||||
{watching && (
|
||||
<>
|
||||
@@ -172,17 +175,15 @@ export const BackgroundJobDialog = ({
|
||||
value={condition}
|
||||
onChange={(ev) => setCondition(ev.target.value)}
|
||||
rows={3}
|
||||
placeholder="watch our Gitea repos; on any new push, pull it and push to the GitHub mirror"
|
||||
placeholder={t('chat.job.watchExample')}
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-duck-dark/50">
|
||||
In prose. The agent will ask before arming if anything is unclear.
|
||||
</p>
|
||||
<p className="text-xs text-duck-dark/50">{t('chat.job.watchHint')}</p>
|
||||
<div className="space-y-1 pt-1">
|
||||
{isCommander && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="radio" checked={onFire === 'handoff'} onChange={() => setOnFire('handoff')} />
|
||||
Exit and hand back to the agent, then re-arm
|
||||
{t('chat.job.handBack')}
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
@@ -191,7 +192,7 @@ export const BackgroundJobDialog = ({
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="radio" checked={onFire === 'keep-going'} onChange={() => setOnFire('keep-going')} />
|
||||
Keep going — act each time and carry on
|
||||
{t('chat.job.keepGoing')}
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
@@ -201,7 +202,7 @@ export const BackgroundJobDialog = ({
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={timed} onChange={(ev) => setTimed(ev.target.checked)} />
|
||||
Stop after
|
||||
{t('chat.job.stopAfter')}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -219,10 +220,10 @@ export const BackgroundJobDialog = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
Start
|
||||
{t('chat.job.start')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useBackgroundTasks, useTaskDetail, type BackgroundTask } from '../useBa
|
||||
import { SubagentTrace } from './ToolActivity';
|
||||
import { CodeSurface } from './CodeSurface';
|
||||
import { markUserBackgroundJob, markStopRequested } from '../../../background-job-origin';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type BackgroundTaskTrayProps = {
|
||||
messages: ChatMessage[];
|
||||
@@ -35,6 +36,7 @@ function statusTone(status: BackgroundTask['status']): Tone {
|
||||
* tail of a backgrounded shell's log, both read from the file Claude Code streams the task into.
|
||||
*/
|
||||
export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { tasks, pinned, unpinned, running, clearable, togglePin, dismiss, dismissFinished } =
|
||||
useBackgroundTasks(messages);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
@@ -189,7 +191,7 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
|
||||
)}
|
||||
|
||||
{pinned.length > 0 && (
|
||||
<TaskStrip label="Pinned" divided={unpinned.length > 0}>
|
||||
<TaskStrip label={t('chat.tasks.pinned')} divided={unpinned.length > 0}>
|
||||
{pinned.map((task) => (
|
||||
<TaskChip key={task.taskId} pinned {...chipProps(task)} />
|
||||
))}
|
||||
@@ -204,8 +206,8 @@ export const BackgroundTaskTray = ({ messages, sessionId }: BackgroundTaskTrayPr
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissFinished}
|
||||
title="Clear all finished"
|
||||
aria-label="Clear all finished tasks"
|
||||
title={t('chat.tasks.clearFinished')}
|
||||
aria-label={t('chat.tasks.clearFinishedTasks')}
|
||||
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
@@ -260,6 +262,7 @@ type TaskChipProps = {
|
||||
* nested in a button is invalid, and the browser resolves it by swallowing one of the two clicks.
|
||||
*/
|
||||
const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onStop }: TaskChipProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = task;
|
||||
const tone = toneText[statusTone(status)];
|
||||
const label = task.description || 'Background task';
|
||||
@@ -312,8 +315,8 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
title="Stop this job"
|
||||
aria-label={`Stop ${label}`}
|
||||
title={t('chat.tasks.stopJob')}
|
||||
aria-label={t('chat.tasks.stopNamed', { label })}
|
||||
className="cursor-pointer py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-destructive"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
@@ -323,8 +326,8 @@ const TaskChip = ({ task, active, pinned, onClick, onTogglePin, onDismiss, onSto
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss"
|
||||
aria-label={`Dismiss ${label}`}
|
||||
title={t('chat.tasks.dismiss')}
|
||||
aria-label={t('chat.tasks.dismissNamed', { label })}
|
||||
className="cursor-pointer rounded-full py-1 pl-0.5 pr-2 text-muted-foreground/50 transition-colors hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
@@ -361,6 +364,7 @@ const TaskPanel = ({
|
||||
onRestart,
|
||||
paused = false,
|
||||
}: TaskPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const running = !task.status;
|
||||
const { data, isLoading } = useTaskDetail(task.taskId, running);
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -411,11 +415,11 @@ const TaskPanel = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
title="Stop it and run the same command again"
|
||||
title={t('chat.tasks.rerun')}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 rounded border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-duck-teal/40 hover:text-duck-teal"
|
||||
>
|
||||
<RotateCw className="h-3 w-3" />
|
||||
Restart
|
||||
{t('chat.tasks.restart')}
|
||||
</button>
|
||||
)}
|
||||
{running && onStop && (
|
||||
@@ -425,7 +429,7 @@ const TaskPanel = ({
|
||||
title={
|
||||
killOffered
|
||||
? 'It ignored the request to stop. Kill it — no chance to clean up, release a port or take children with it.'
|
||||
: 'Stop this job'
|
||||
: t('chat.tasks.stopJob')
|
||||
}
|
||||
className={`flex shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs transition-colors ${
|
||||
killOffered
|
||||
@@ -434,14 +438,14 @@ const TaskPanel = ({
|
||||
}`}
|
||||
>
|
||||
{killOffered ? <Skull className="h-3 w-3" /> : <Square className="h-3 w-3" />}
|
||||
{killOffered ? 'Kill' : 'Stop'}
|
||||
{killOffered ? 'Kill' : t('chat.composer.stop')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title="Collapse"
|
||||
aria-label="Collapse task detail"
|
||||
title={t('chat.tasks.collapse')}
|
||||
aria-label={t('chat.tasks.collapseDetail')}
|
||||
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/**
|
||||
* An image inside a chat turn: a thumbnail in the bubble, full size when clicked.
|
||||
@@ -12,6 +13,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
* take comparable room, instead of one of them owning the pane. Same rule the mobile client uses.
|
||||
*/
|
||||
export const ChatImage = ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -47,7 +49,7 @@ export const ChatImage = ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
{/* No chrome and no padding: the picture is the content, and a header would take height from
|
||||
it on the one screen where height is what you came for. */}
|
||||
<DialogContent className="max-w-[95vw] w-auto p-0 bg-transparent border-0 shadow-none">
|
||||
<DialogTitle className="sr-only">{alt || 'Image'}</DialogTitle>
|
||||
<DialogTitle className="sr-only">{alt || t('chat.attach.image')}</DialogTitle>
|
||||
<img src={src} alt={alt ?? ''} className="max-h-[90vh] max-w-[95vw] w-auto rounded-lg" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/*
|
||||
`/login` — the same flow as the terminal, without the terminal.
|
||||
@@ -24,6 +25,7 @@ type Started = { id: string; url: string; expiresInMs: number };
|
||||
type Props = { open: boolean; onOpenChange: (open: boolean) => void };
|
||||
|
||||
export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const [started, setStarted] = useState<Started | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
@@ -78,13 +80,13 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in to Claude</DialogTitle>
|
||||
<DialogTitle>{t('chat.signIn.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{done ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm">
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
Signed in. New turns use these credentials — no restart needed.
|
||||
{t('chat.signIn.done')}
|
||||
</div>
|
||||
) : !started ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
@@ -93,16 +95,14 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Asking the CLI for a sign-in link…
|
||||
{t('chat.signIn.asking')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open this, authorise, and paste back the code it gives you.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('chat.signIn.openHint')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* A real anchor. Every link here opens a new tab, which is what you want for this one —
|
||||
losing the dialog would lose the process holding the other half of the exchange. */}
|
||||
@@ -112,7 +112,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Authorise in claude.com
|
||||
{t('chat.signIn.authorise')}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
{/* Copy, because opening here opens in THIS browser — already signed into whichever account
|
||||
@@ -128,7 +128,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
else setError('could not reach the clipboard — select the link and copy it by hand');
|
||||
});
|
||||
}}
|
||||
title="Copy the link, to open it in another browser or on your phone"
|
||||
title={t('chat.signIn.copyLink')}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-foreground/5"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
@@ -146,7 +146,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') void submit();
|
||||
}}
|
||||
placeholder="paste the code"
|
||||
placeholder={t('chat.signIn.pasteCode')}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -162,7 +162,7 @@ export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { highlight } from '../../FileViewer/renderers/highlight';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/**
|
||||
* The one way this app draws a block of code: shiki-highlighted when a language is known, line-numbered,
|
||||
@@ -53,6 +54,7 @@ type CodeSurfaceProps = {
|
||||
};
|
||||
|
||||
export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered, fallback }: CodeSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState<CopyMode | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Keyed by the source it was produced from. While a block is still streaming the stored html belongs to
|
||||
@@ -128,8 +130,8 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy('raw')}
|
||||
title="Copy the code"
|
||||
aria-label="Copy the code"
|
||||
title={t('chat.message.copyCode')}
|
||||
aria-label={t('chat.message.copyCode')}
|
||||
className={CONTROL}
|
||||
>
|
||||
{copied === 'raw' ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
@@ -137,8 +139,8 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy('md')}
|
||||
title="Copy as a markdown block"
|
||||
aria-label="Copy as a markdown block"
|
||||
title={t('chat.message.copyMarkdown')}
|
||||
aria-label={t('chat.message.copyMarkdown')}
|
||||
className={CONTROL}
|
||||
>
|
||||
{copied === 'md' ? (
|
||||
@@ -177,7 +179,7 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="mt-1 cursor-pointer text-xs text-duck-teal hover:underline"
|
||||
>
|
||||
{expanded ? 'Show less' : `Show more (${hidden} more lines)`}
|
||||
{expanded ? 'Show less' : t('chat.message.showMore', { count: hidden })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type CopyButtonProps = {
|
||||
text: string;
|
||||
@@ -8,6 +9,7 @@ type CopyButtonProps = {
|
||||
};
|
||||
|
||||
export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -21,7 +23,7 @@ export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`p-1 rounded text-foreground opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity cursor-pointer ${className}`}
|
||||
title="Copy"
|
||||
title={t('chat.copy')}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AttachmentList } from './AttachmentList';
|
||||
import { AttachButton } from './AttachButton';
|
||||
import { WebpageDialog } from './WebpageDialog';
|
||||
import { BackgroundTaskTray } from './BackgroundTaskTray';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type InputAreaProps = {
|
||||
manager: UseEmbeddableChatType;
|
||||
@@ -21,6 +22,7 @@ type InputAreaProps = {
|
||||
};
|
||||
|
||||
export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
|
||||
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
|
||||
@@ -88,7 +90,7 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
>
|
||||
{drop.isTarget && (
|
||||
<div className="pointer-events-none absolute inset-1 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-duck-teal bg-background/80">
|
||||
<span className="text-xs font-medium text-duck-teal">Drop images to attach</span>
|
||||
<span className="text-xs font-medium text-duck-teal">{t('chat.attach.dropImages')}</span>
|
||||
</div>
|
||||
)}
|
||||
{commandFeedback && (
|
||||
@@ -158,7 +160,7 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
onClick={stopGeneration}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
title="Stop"
|
||||
title={t('chat.composer.stop')}
|
||||
className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
@@ -177,7 +179,7 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
}}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
size="icon"
|
||||
title={isGenerating ? 'Queue — sends when the current turn finishes' : 'Send'}
|
||||
title={isGenerating ? 'Queue — sends when the current turn finishes' : t('chat.composer.send')}
|
||||
className={`shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer disabled:opacity-40 ${
|
||||
isGenerating ? 'bg-busy hover:bg-busy/90 text-busy-foreground' : 'bg-duck-teal hover:bg-duck-teal/90'
|
||||
}`}
|
||||
@@ -307,6 +309,7 @@ const QueuedList = ({
|
||||
onRemove: (id: string) => void;
|
||||
onSendNow: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (queued.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -316,7 +319,7 @@ const QueuedList = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSendNow}
|
||||
title="Send these into the turn that is already running"
|
||||
title={t('chat.composer.sendIntoTurn')}
|
||||
className="cursor-pointer self-start rounded px-1 text-[11px] text-busy-ink/80 transition-colors hover:bg-busy/10 hover:text-busy-ink"
|
||||
>
|
||||
Send now — press Enter on an empty box to add {queued.length === 1 ? 'this' : 'these'} to the running turn
|
||||
@@ -328,7 +331,7 @@ const QueuedList = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(item.id)}
|
||||
title="Remove from queue"
|
||||
title={t('chat.composer.removeFromQueue')}
|
||||
className="shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-black/10 hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
|
||||
@@ -36,6 +36,7 @@ import { CodeBlock } from './CodeBlock';
|
||||
import { ChatImage } from './ChatImage';
|
||||
import { injectImages } from './chat-images';
|
||||
import { randomId } from 'helpers/random-id';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */
|
||||
/**
|
||||
@@ -85,6 +86,7 @@ const CollapsibleBlock = ({ label, content }: { label: string; content: string }
|
||||
type TtsState = 'idle' | 'loading' | 'playing';
|
||||
|
||||
const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [state, setState] = useState<TtsState>('idle');
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const api = useFilesAPI('user-data');
|
||||
@@ -111,7 +113,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
||||
audio.onerror = () => {
|
||||
audioRef.current = null;
|
||||
setState('idle');
|
||||
toast.error('The narration was generated but would not play.');
|
||||
toast.error(t('chat.message.narrationFailed'));
|
||||
};
|
||||
await audio.play();
|
||||
setState('playing');
|
||||
@@ -120,7 +122,7 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
||||
// the icon comes back, the button looks broken rather than failed. Most often this is the TTS
|
||||
// service being down, which is worth saying out loud.
|
||||
setState('idle');
|
||||
toast.error(`Could not read this message aloud: ${errorText(err)}`);
|
||||
toast.error(t('chat.message.readAloudFailed', { reason: errorText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,6 +155,7 @@ type MessageBubbleProps = {
|
||||
};
|
||||
|
||||
export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaultOpen }: MessageBubbleProps) => {
|
||||
const { t } = useTranslation();
|
||||
switch (message.role) {
|
||||
case 'user': {
|
||||
const rawText = typeof message.text === 'string' ? message.text : '';
|
||||
@@ -161,7 +164,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
const text = match ? rawText.slice(match[0]!.length) : rawText;
|
||||
return (
|
||||
<>
|
||||
{metadata && <CollapsibleBlock label="Frontmatter" content={metadata} />}
|
||||
{metadata && <CollapsibleBlock label={t('chat.message.frontmatter')} content={metadata} />}
|
||||
<div className="flex justify-end group">
|
||||
<div className="max-w-[80%] min-w-0 rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-foreground">
|
||||
{message.images?.map((img, i) => (
|
||||
@@ -203,7 +206,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
}
|
||||
|
||||
case 'system':
|
||||
return <CollapsibleBlock label="System prompt" content={message.text} />;
|
||||
return <CollapsibleBlock label={t('chat.message.systemPrompt')} content={message.text} />;
|
||||
|
||||
// The one seam in a merged `/clear` chain. It says what was lost rather than just drawing a line,
|
||||
// because the risk of merging is precisely that the conversation looks unbroken to you and is not:
|
||||
@@ -214,7 +217,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<Eraser className="h-3 w-3" />
|
||||
Context cleared — nothing above this is in memory
|
||||
{t('chat.message.contextCleared')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
@@ -287,7 +290,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<CircleSlash className="h-3 w-3" />
|
||||
Interrupted by user
|
||||
{t('chat.message.interrupted')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
@@ -303,7 +306,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<PlugZap className="h-3 w-3" />
|
||||
Agent restarted — turn cut off
|
||||
{t('chat.message.agentRestarted')}
|
||||
</span>
|
||||
{message.prompt && onRetry && (
|
||||
<button
|
||||
@@ -312,7 +315,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
className="flex cursor-pointer items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-medium tracking-wide uppercase transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Retry
|
||||
{t('chat.message.retry')}
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
@@ -340,6 +343,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
* both sides discarded the taskId that relates them.
|
||||
*/
|
||||
const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task' }> }) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const { status, summary, description, taskType } = message;
|
||||
const Icon = status === 'completed' ? Check : status === 'failed' ? X : status === 'stopped' ? CircleSlash : Clock;
|
||||
const tone: Tone =
|
||||
@@ -354,7 +358,7 @@ const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task
|
||||
{taskType && <span className="ml-2 text-xs text-muted-foreground">{taskType}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status ? summary || `Task ${status}.` : 'Running in the background…'}
|
||||
{status ? summary || t('chat.tasks.status', { status }) : 'Running in the background…'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,6 +385,7 @@ const formatTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)
|
||||
* the sidecar measured — which is the one a replay in another tab gets.
|
||||
*/
|
||||
const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'compact' }> }) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const { done, trigger, preTokens, durationMs } = message;
|
||||
const startedAt = useRef(Date.now());
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
@@ -402,7 +407,11 @@ const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'c
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<FoldVertical className={`h-3 w-3 ${done ? '' : 'animate-pulse'}`} />
|
||||
{done ? `Context compacted${trigger === 'manual' ? '' : ' automatically'}` : 'Compacting the conversation…'}
|
||||
{done
|
||||
? trigger === 'manual'
|
||||
? t('chat.message.compacted')
|
||||
: t('chat.message.compactedAuto')
|
||||
: 'Compacting the conversation…'}
|
||||
{detail && <span className="normal-case opacity-70 tabular-nums">· {detail}</span>}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
@@ -455,27 +464,30 @@ const ModelSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'model
|
||||
* a fault: in plan mode the agent researches and proposes but will not edit anything, and a transcript with
|
||||
* no notice in it reads as an agent that inexplicably stopped doing the work.
|
||||
*/
|
||||
const PlanModeSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'planmode' }> }) => (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{message.ok ? (
|
||||
message.enabled ? (
|
||||
'Plan mode on — research only'
|
||||
const PlanModeSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'planmode' }> }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{message.ok ? (
|
||||
message.enabled ? (
|
||||
'Plan mode on — research only'
|
||||
) : (
|
||||
'Plan mode off'
|
||||
)
|
||||
) : (
|
||||
'Plan mode off'
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Plan mode unchanged
|
||||
<span className="normal-case opacity-70">· {message.error ?? 'the agent refused'}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
<>
|
||||
{t('chat.message.planUnchanged')}
|
||||
<span className="normal-case opacity-70">· {message.error ?? 'the agent refused'}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A plan, with the two buttons the agent is waiting on.
|
||||
@@ -496,6 +508,7 @@ const PlanCard = ({
|
||||
message: Extract<ChatMessage, { role: 'plan' }>;
|
||||
onDecide?: (requestId: string, approve: boolean, feedback?: string) => void;
|
||||
}) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const decided = message.approved !== undefined;
|
||||
@@ -535,7 +548,7 @@ const PlanCard = ({
|
||||
autoFocus
|
||||
value={feedback}
|
||||
onChange={(ev) => setFeedback(ev.target.value)}
|
||||
placeholder="What should change? The agent revises the plan from this."
|
||||
placeholder={t('chat.message.reviseHint')}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-xs outline-none focus:border-primary/50"
|
||||
/>
|
||||
@@ -547,7 +560,7 @@ const PlanCard = ({
|
||||
onClick={() => setRejecting(false)}
|
||||
className="cursor-pointer rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -564,7 +577,7 @@ const PlanCard = ({
|
||||
className="flex cursor-pointer items-center gap-1 rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
Approve
|
||||
{t('chat.message.approve')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,17 +592,20 @@ const PlanCard = ({
|
||||
* render nothing at all, because the streaming bubble bailed on empty text. The transcript looked frozen
|
||||
* and the only evidence the turn was alive was the send button having become a stop button.
|
||||
*/
|
||||
const ThinkingDots = () => (
|
||||
<span className="flex items-center gap-1 py-1" role="status" aria-label="Waiting for a response">
|
||||
{[0, 150, 300].map((delay) => (
|
||||
<span
|
||||
key={delay}
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
const ThinkingDots = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="flex items-center gap-1 py-1" role="status" aria-label={t('chat.message.waiting')}>
|
||||
{[0, 150, 300].map((delay) => (
|
||||
<span
|
||||
key={delay}
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type StreamingBubbleProps = {
|
||||
text: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { buildRows } from './turn-rows';
|
||||
import { useFoldExit } from './fold-exit';
|
||||
import { ToolOpenProvider } from './tool-open';
|
||||
import { TurnEnd, TurnFold } from './TurnFold';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
const OVERSCAN = 5;
|
||||
|
||||
@@ -16,6 +17,7 @@ type MessageListProps = {
|
||||
};
|
||||
|
||||
export const MessageList = ({ manager }: MessageListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
messages,
|
||||
historicalCount,
|
||||
@@ -115,7 +117,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
manager.emptyState
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
|
||||
Send a message to start
|
||||
{t('chat.composer.startHint')}
|
||||
</div>
|
||||
))}
|
||||
{rows.length > 0 && (
|
||||
@@ -175,7 +177,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
{showJumpToBottom && (
|
||||
<button
|
||||
onClick={jumpToBottom}
|
||||
aria-label="Jump to latest message"
|
||||
aria-label={t('chat.composer.jumpToLatest')}
|
||||
className="absolute bottom-2 left-1/2 -translate-x-1/2 cursor-pointer rounded-full bg-primary p-1.5 text-primary-foreground shadow-lg transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ModelOption } from 'state/useModels';
|
||||
import { getProviderDisplayName } from 'state/useModels';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { UsagePopover } from './UsagePopover';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
@@ -67,6 +68,7 @@ export function ModelSelector({
|
||||
planMode = false,
|
||||
onPlanModeChange,
|
||||
}: ModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
||||
|
||||
// Determine which model to display: selectedModel takes precedence, then model (from server), then fallback
|
||||
@@ -221,7 +223,7 @@ export function ModelSelector({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackgroundJob}
|
||||
title="Start a background job or watcher"
|
||||
title={t('chat.job.startJob')}
|
||||
className={`shrink-0 cursor-pointer rounded-md p-1.5 transition-colors hover:bg-foreground/10 ${toneText.warning}`}
|
||||
>
|
||||
<CirclePlay className="h-[18px] w-[18px]" />
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { QuestionActivity } from './QuestionActivity';
|
||||
import panelEnGB from '../../../locales/en-GB.json';
|
||||
import type { ChatMessage } from '../types';
|
||||
|
||||
/**
|
||||
* The component's own chrome ("Other...", "Send") comes from the `panel.*` catalogue now, so it only
|
||||
* reads as English inside a provider — the same one the app mounts. Asserting the words a person sees,
|
||||
* rather than loosening these to match a dot-path, is the point of wrapping.
|
||||
*/
|
||||
const withI18n = (ui: ReactNode) => (
|
||||
<I18nProvider translations={{ 'en-GB': { panel: panelEnGB } }} locale="en-GB" fallbackLocale="en-GB">
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
// The bug this file exists for: a Claude session's questions rendered as raw JSON for months because the
|
||||
// dispatch matched only OpenCode's tool name. Nothing threw — it read as "the model never asks". So what
|
||||
// is pinned here is the SHAPE both harnesses send, not the styling.
|
||||
@@ -19,10 +33,12 @@ describe('the two harnesses disagree on one field name', () => {
|
||||
test("Claude Code's `multiSelect` is honoured — several options, one submit", () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiSelect: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiSelect: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
fireEvent.click(screen.getByText('SQLite'));
|
||||
@@ -36,10 +52,12 @@ describe('the two harnesses disagree on one field name', () => {
|
||||
test("OpenCode's `multiple` means the same thing", () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiple: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', multiple: true, options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
fireEvent.click(screen.getByText(/Submit \(1 selected\)/));
|
||||
@@ -49,10 +67,12 @@ describe('the two harnesses disagree on one field name', () => {
|
||||
test('neither field set means single-select — one click answers', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText('SQLite'));
|
||||
expect(answers).toEqual(['SQLite']);
|
||||
@@ -65,20 +85,22 @@ describe('more than one question', () => {
|
||||
test('nothing is sent until every question has an answer', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([
|
||||
{ question: 'Which store?', header: 'Store', options: OPTIONS },
|
||||
{
|
||||
question: 'Which runtime?',
|
||||
header: 'Runtime',
|
||||
options: [
|
||||
{ label: 'Bun', description: 'what the repo uses' },
|
||||
{ label: 'Node', description: 'the other one' },
|
||||
],
|
||||
},
|
||||
])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([
|
||||
{ question: 'Which store?', header: 'Store', options: OPTIONS },
|
||||
{
|
||||
question: 'Which runtime?',
|
||||
header: 'Runtime',
|
||||
options: [
|
||||
{ label: 'Bun', description: 'what the repo uses' },
|
||||
{ label: 'Node', description: 'the other one' },
|
||||
],
|
||||
},
|
||||
])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
@@ -95,10 +117,12 @@ describe('the escape hatch', () => {
|
||||
test('free text answers the question', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText('Other...'), { target: { value: 'DuckDB' } });
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
@@ -124,7 +148,7 @@ describe('a question that already carries an output', () => {
|
||||
...ask([{ question: 'Which?', header: 'Store', options: OPTIONS }]),
|
||||
output: 'Postgres',
|
||||
} as never;
|
||||
render(<QuestionActivity message={answered} onAnswer={(t) => answers.push(t)} />);
|
||||
render(withI18n(<QuestionActivity message={answered} onAnswer={(t) => answers.push(t)} />));
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
expect(answers).toEqual([]);
|
||||
});
|
||||
@@ -135,7 +159,7 @@ describe('a question that already carries an output', () => {
|
||||
...ask([{ question: 'Which?', header: 'Store', options: OPTIONS }]),
|
||||
output: 'The user did not answer the questions.',
|
||||
} as never;
|
||||
render(<QuestionActivity message={stale} onAnswer={(t) => answers.push(t)} />);
|
||||
render(withI18n(<QuestionActivity message={stale} onAnswer={(t) => answers.push(t)} />));
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
expect(answers).toEqual([]);
|
||||
});
|
||||
@@ -143,10 +167,12 @@ describe('a question that already carries an output', () => {
|
||||
test('while a LIVE question — no output yet — is answerable exactly once', () => {
|
||||
const answers: string[] = [];
|
||||
render(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
withI18n(
|
||||
<QuestionActivity
|
||||
message={ask([{ question: 'Which?', header: 'Store', options: OPTIONS }])}
|
||||
onAnswer={(t) => answers.push(t)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByText('Postgres'));
|
||||
fireEvent.click(screen.getByText('SQLite'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageCircleQuestion, Check, Loader2 } from 'lucide-react';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
@@ -55,6 +56,7 @@ const isMulti = (q: Question): boolean => q.multiSelect ?? q.multiple ?? false;
|
||||
* grows an answer channel this is the piece that already knows how to ask.
|
||||
*/
|
||||
export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Keyed by question INDEX, because a payload may carry up to four and they are answered independently.
|
||||
// This was single-valued state shared across every question, so with more than one the first click sent
|
||||
// a partial answer and locked the rest — invisible while only one question was ever asked.
|
||||
@@ -132,7 +134,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
||||
{awaitingAgent && (
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Answer sent — waiting for the agent</span>
|
||||
<span>{t('chat.message.answerSent')}</span>
|
||||
</div>
|
||||
)}
|
||||
{questions.map((q, qi) => {
|
||||
@@ -203,7 +205,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
||||
type="text"
|
||||
value={otherText[qi] ?? ''}
|
||||
onChange={(ev) => setOtherText((prev) => ({ ...prev, [qi]: ev.target.value }))}
|
||||
placeholder="Other..."
|
||||
placeholder={t('chat.message.other')}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-duck-teal/40"
|
||||
/>
|
||||
<button
|
||||
@@ -211,7 +213,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
||||
disabled={!(otherText[qi] ?? '').trim()}
|
||||
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Send
|
||||
{t('chat.composer.send')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { diffStat, editDiff } from './line-diff';
|
||||
import { CodeSurface } from './CodeSurface';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { useToolOpen } from './tool-open';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
@@ -184,6 +185,7 @@ function langForPath(path: unknown): string {
|
||||
}
|
||||
|
||||
export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = toolIcons[message.toolName] ?? Wrench;
|
||||
const { head, tail } = splitSummary(getToolSummary(message.toolName, message.toolInput));
|
||||
const pending = message.output === undefined;
|
||||
@@ -266,7 +268,9 @@ export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
|
||||
|
||||
{children.length > 0 && (
|
||||
<div className="rounded-md border-l-2 border-duck-teal/30 pl-2">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1">Subagent</div>
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
|
||||
{t('chat.message.subagent')}
|
||||
</div>
|
||||
<SubagentTrace messages={children} />
|
||||
</div>
|
||||
)}
|
||||
@@ -274,7 +278,7 @@ export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
|
||||
{message.output !== undefined && (
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="mb-1 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{isError ? <span className="text-destructive">Output — error</span> : 'Output'}
|
||||
{isError ? <span className="text-destructive">{t('chat.message.outputError')}</span> : 'Output'}
|
||||
</div>
|
||||
<ToolOutput
|
||||
toolName={message.toolName}
|
||||
@@ -304,6 +308,7 @@ export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
|
||||
const DIFF_MAX_LINES = 40;
|
||||
|
||||
const DiffView = ({ lines }: { lines: DiffLine[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hidden = lines.length - DIFF_MAX_LINES;
|
||||
const shown = expanded ? lines : lines.slice(0, DIFF_MAX_LINES);
|
||||
@@ -349,7 +354,7 @@ const DiffView = ({ lines }: { lines: DiffLine[] }) => {
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="mt-1 cursor-pointer text-xs text-duck-teal hover:underline"
|
||||
>
|
||||
{expanded ? 'Show less' : `Show more (${hidden} more lines)`}
|
||||
{expanded ? 'Show less' : t('chat.message.showMore', { count: hidden })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FoldCounts } from './turn-rows';
|
||||
import { foldLabel, keyOf } from './turn-rows';
|
||||
import { COLLAPSE_MS } from './fold-exit';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type TurnFoldProps = {
|
||||
messages: ChatMessage[];
|
||||
@@ -86,13 +87,16 @@ export const TurnFold = ({ messages, counts, open, childrenOpen, onToggle }: Tur
|
||||
/**
|
||||
* A turn that ended without a closing message. Written as a seam, never as prose — see `turn-rows.ts`.
|
||||
*/
|
||||
export const TurnEnd = () => (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Turn finished · no closing message
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
export const TurnEnd = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('chat.turnFinished')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { UsageAccount } from '../useClaudeUsage';
|
||||
import { currentLimit, formatAge, formatReset, formatTokens, useClaudeUsage, weekdayLabel } from '../useClaudeUsage';
|
||||
import type { SwitchableAccount } from '../useAnthropicAccounts';
|
||||
import { useAnthropicAccounts } from '../useAnthropicAccounts';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
/**
|
||||
* Claude Code's own usage display, on the harness pill — for every account, not just the one signed in.
|
||||
@@ -43,36 +44,40 @@ const Section = ({ title, children }: { title: string; children: React.ReactNode
|
||||
</div>
|
||||
);
|
||||
|
||||
const Limits = ({ account, now }: { account: UsageAccount; now: number }) => (
|
||||
<Section title="Limits">
|
||||
<div className="flex flex-col gap-3">
|
||||
{account.limits.map((stored) => {
|
||||
// What the stored figure means NOW — a window that has rolled over since it was measured reads as
|
||||
// empty rather than as whatever it was when the account was last used.
|
||||
const limit = currentLimit(stored, now);
|
||||
const resets = formatReset(limit.resetsAt, now);
|
||||
return (
|
||||
<div key={limit.label} className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">{limit.label}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-foreground">{Math.round(limit.percent)}%</span>
|
||||
const Limits = ({ account, now }: { account: UsageAccount; now: number }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section title={t('chat.usage.limits')}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{account.limits.map((stored) => {
|
||||
// What the stored figure means NOW — a window that has rolled over since it was measured reads as
|
||||
// empty rather than as whatever it was when the account was last used.
|
||||
const limit = currentLimit(stored, now);
|
||||
const resets = formatReset(limit.resetsAt, now);
|
||||
return (
|
||||
<div key={limit.label} className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">{limit.label}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-foreground">{Math.round(limit.percent)}%</span>
|
||||
</div>
|
||||
<Meter value={limit.percent / 100} strong />
|
||||
{resets && <span className="text-xs text-muted-foreground">Resets in {resets}</span>}
|
||||
</div>
|
||||
<Meter value={limit.percent / 100} strong />
|
||||
{resets && <span className="text-xs text-muted-foreground">Resets in {resets}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Days = ({ account }: { account: UsageAccount }) => {
|
||||
const { t } = useTranslation();
|
||||
// Every bar is scaled to the busiest day, so the shape of the week is readable even in a quiet one. A
|
||||
// fixed scale would flatten a whole week of ordinary work into nothing.
|
||||
const peak = Math.max(1, ...account.days.map((d) => d.tokens));
|
||||
|
||||
return (
|
||||
<Section title="Tokens by day">
|
||||
<Section title={t('chat.usage.byDay')}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{account.days.map((day, index) => {
|
||||
const today = index === account.days.length - 1;
|
||||
@@ -100,19 +105,25 @@ const Days = ({ account }: { account: UsageAccount }) => {
|
||||
};
|
||||
|
||||
const Models = ({ account }: { account: UsageAccount }) => {
|
||||
const { t } = useTranslation();
|
||||
// Four, because the tail is a long list of models that answered once. The heaviest is the scale, the same
|
||||
// way the week scales to its busiest day.
|
||||
const models = account.models.slice(0, 4);
|
||||
const peak = Math.max(1, ...models.map((m) => m.total));
|
||||
|
||||
return (
|
||||
<Section title="Tokens by model">
|
||||
<Section title={t('chat.usage.byModel')}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{models.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="relative flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1.5"
|
||||
title={`In ${formatTokens(model.input)} · out ${formatTokens(model.output)} · cache read ${formatTokens(model.cacheRead)} · cache write ${formatTokens(model.cacheWrite)}`}
|
||||
title={t('chat.usage.modelBreakdown', {
|
||||
input: formatTokens(model.input),
|
||||
output: formatTokens(model.output),
|
||||
cacheRead: formatTokens(model.cacheRead),
|
||||
cacheWrite: formatTokens(model.cacheWrite),
|
||||
})}
|
||||
>
|
||||
{/* The bar sits BEHIND the row rather than beside it — the model names are long and a separate
|
||||
track would leave no room for either. */}
|
||||
@@ -215,6 +226,7 @@ type UsagePopoverProps = {
|
||||
* these numbers are about.
|
||||
*/
|
||||
export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
/*
|
||||
The owner check lives HERE rather than in the caller, so the composer does not have to know that a pill
|
||||
has anything to do with permissions.
|
||||
@@ -280,8 +292,10 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
title: a.current
|
||||
? `${a.emailAccount} — signed in`
|
||||
: a.usable
|
||||
? `Switch to ${a.emailAccount}${a.orgName ? ` (${a.orgName})` : ''}`
|
||||
: `${a.emailAccount} — sign-in expired, run /login`,
|
||||
? a.orgName
|
||||
? t('chat.usage.switchToOrg', { account: a.emailAccount, org: a.orgName })
|
||||
: t('chat.usage.switchTo', { account: a.emailAccount })
|
||||
: t('chat.usage.signInExpired', { account: a.emailAccount }),
|
||||
})),
|
||||
...archived.map((a) => ({
|
||||
key: `archived:${a.email}`,
|
||||
@@ -309,7 +323,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={`${pill} cursor-pointer hover:bg-foreground/10`} title="Usage and limits">
|
||||
<button type="button" className={`${pill} cursor-pointer hover:bg-foreground/10`} title={t('chat.usage.title')}>
|
||||
{label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -342,9 +356,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
{/* Said on the account being LOOKED AT rather than as a count somewhere else. The numbers below are
|
||||
real and the reset times are still correct; the only thing missing is the ability to act on
|
||||
them, and naming the fix beats naming the problem. */}
|
||||
{viewing && (
|
||||
<div className="mt-0.5 text-[10px] text-warning">No saved sign-in — run /login to use this one</div>
|
||||
)}
|
||||
{viewing && <div className="mt-0.5 text-[10px] text-warning">{t('chat.usage.noSignIn')}</div>}
|
||||
{/*
|
||||
How old this reading is, and it earns its line only when it is not live.
|
||||
|
||||
@@ -358,7 +370,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
</div>
|
||||
)}
|
||||
{selected?.current && isRefreshing && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">Updating…</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">{t('chat.usage.updating')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -386,7 +398,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
still leaves real token history below it. */}
|
||||
{isError && (
|
||||
<div className="border-t border-border px-4 py-3 text-xs text-muted-foreground">
|
||||
Couldn't read usage on this machine.
|
||||
{t('chat.usage.unreadable')}
|
||||
</div>
|
||||
)}
|
||||
{selected?.limitsProblem && selected.current && (
|
||||
@@ -394,7 +406,9 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
)}
|
||||
|
||||
{isLoading && !selected && (
|
||||
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">Loading…</div>
|
||||
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
{t('chat.usage.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '../../../locales';
|
||||
|
||||
type WebpageDialogProps = {
|
||||
open: boolean;
|
||||
@@ -10,6 +11,7 @@ type WebpageDialogProps = {
|
||||
};
|
||||
|
||||
export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
|
||||
const handleSubmit = (ev?: FormEvent) => {
|
||||
@@ -25,8 +27,8 @@ export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogPro
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Attach Webpage</DialogTitle>
|
||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
||||
<DialogTitle>{t('chat.attach.webpageTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('chat.attach.webpageHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
@@ -42,7 +44,7 @@ export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogPro
|
||||
disabled={!urlInput.trim()}
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
Attach
|
||||
{t('chat.attach.attach')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { ChatPanelWrapper } from './ChatPanelWrapper';
|
||||
import { ChatHeader } from './ChatHeader';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { MessageList } from './components/MessageList';
|
||||
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
|
||||
@@ -26,7 +27,7 @@ export * from './types';
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/chat',
|
||||
name: 'Chat',
|
||||
name: panelName('panelName.chat'),
|
||||
icon: MessageSquare,
|
||||
component: ChatPanelWrapper,
|
||||
header: ChatHeader,
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { Attachment } from './types';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
type UseAttachmentsParams = {
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
export function useAttachments(params?: UseAttachmentsParams) {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
|
||||
@@ -33,7 +35,7 @@ export function useAttachments(params?: UseAttachmentsParams) {
|
||||
);
|
||||
} catch {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||
toast.error('Failed to scrape webpage');
|
||||
toast.error(t('chat.attach.scrapeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,7 +60,7 @@ export function useAttachments(params?: UseAttachmentsParams) {
|
||||
);
|
||||
} catch {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||
toast.error('Failed to upload image');
|
||||
toast.error(t('chat.attach.uploadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
export function useAudioRecording(onTranscription: (text: string) => void) {
|
||||
const { t } = useTranslation();
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
@@ -16,7 +18,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
|
||||
|
||||
try {
|
||||
if (recorder.state === 'inactive') {
|
||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
||||
recorder.stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,10 +32,10 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
|
||||
recorder.stop();
|
||||
});
|
||||
|
||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
||||
recorder.stream.getTracks().forEach((track) => track.stop());
|
||||
|
||||
if (blob.size === 0) {
|
||||
toast.error('No audio was captured');
|
||||
toast.error(t('chat.composer.noAudio'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
|
||||
setTranscribing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
recorder.stream?.getTracks().forEach((t) => t.stop());
|
||||
recorder.stream?.getTracks().forEach((track) => track.stop());
|
||||
chunksRef.current = [];
|
||||
toast.error(err instanceof Error ? err.message : 'Recording failed');
|
||||
}
|
||||
@@ -94,7 +96,7 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (!recorder) return;
|
||||
if (recorder.state !== 'inactive') recorder.stop();
|
||||
recorder.stream?.getTracks().forEach((t) => t.stop());
|
||||
recorder.stream?.getTracks().forEach((track) => track.stop());
|
||||
mediaRecorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||
import type { NotifyPreference } from 'state/useClaudeSessions';
|
||||
import { useChat, EmbeddableChat } from '../Chat';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
export type SelectedSession = {
|
||||
id: string;
|
||||
@@ -71,6 +72,7 @@ function DetailBar({
|
||||
onDisconnect,
|
||||
notifyPreference,
|
||||
}: DetailBarProps) {
|
||||
const { t } = useTranslation();
|
||||
// The id to rename comes from the URL, not from `resumeSessionId`. They are the same for an ordinary
|
||||
// conversation and not for a merged `/clear` chain, where the resume target is the tail transcript
|
||||
// while the list, the links and the server all address the chain by its head. Renaming the tail would
|
||||
@@ -115,11 +117,11 @@ function DetailBar({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 px-3 text-center">
|
||||
<EditableTitle
|
||||
value={sessionTitle ?? 'New chat'}
|
||||
value={sessionTitle ?? t('chatHistory.newChatTitle')}
|
||||
onCommit={commitRename}
|
||||
readOnly={!sessionId}
|
||||
ariaLabel="Session title"
|
||||
hint="Click to rename this chat"
|
||||
ariaLabel={t('chatHistory.sessionTitle')}
|
||||
hint={t('chatHistory.renameHint')}
|
||||
className="block w-full cursor-text truncate text-sm font-medium text-foreground/80 transition-opacity hover:opacity-70"
|
||||
inputClassName="w-full border-b border-primary/40 bg-transparent text-center text-sm font-medium text-foreground/80 outline-none"
|
||||
/>
|
||||
@@ -142,7 +144,7 @@ function DetailBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDisconnect}
|
||||
title="End session — kills any running turn and releases it so it can be resumed elsewhere"
|
||||
title={t('chatHistory.endSession')}
|
||||
className="ml-1 p-1 rounded hover:bg-muted hover:text-destructive transition-colors cursor-pointer"
|
||||
>
|
||||
<Unplug className="h-3.5 w-3.5" />
|
||||
@@ -177,6 +179,7 @@ type NewChatProps = {
|
||||
};
|
||||
|
||||
function NewChat(props: NewChatProps) {
|
||||
const { t } = useTranslation();
|
||||
const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd, planMode } = props;
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
@@ -254,6 +257,7 @@ function NewChat(props: NewChatProps) {
|
||||
}
|
||||
|
||||
export const ChatDetailPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
const [selected] = useSelectedChatSession();
|
||||
|
||||
// Name the page after the conversation, whenever the URL names a real one. Gated on the route param
|
||||
@@ -284,7 +288,7 @@ export const ChatDetailPanel = () => {
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Select a session to view
|
||||
{t('chatHistory.selectSession')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Folder, ChevronRight, Loader2, FolderPlus, Check, Eye, EyeOff } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
type DirPickerModalProps = {
|
||||
open: boolean;
|
||||
@@ -13,6 +14,7 @@ type DirPickerModalProps = {
|
||||
// A simplified file-browser modal for picking a working directory (returns an absolute path).
|
||||
// Navigates within the home root; dirs elsewhere are reachable via the selector's free-text field.
|
||||
export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const api = useFilesAPI('home');
|
||||
const [path, setPath] = useState('/'); // root-relative, always starts with '/'
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -54,7 +56,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 p-0">
|
||||
<DialogHeader className="border-b border-border px-4 py-3">
|
||||
<DialogTitle className="text-sm">Choose a working directory</DialogTitle>
|
||||
<DialogTitle className="text-sm">{t('chatHistory.chooseDir')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
@@ -82,9 +84,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
||||
// Say when the folder isn't actually empty, just filtered — "No subfolders here" over a
|
||||
// directory holding thirty dot-folders is a lie the toggle below can't help you spot.
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm opacity-40">
|
||||
{hiddenCount > 0
|
||||
? `No visible subfolders — ${hiddenCount} hidden ${hiddenCount === 1 ? 'one is' : 'ones are'} filtered out`
|
||||
: 'No subfolders here'}
|
||||
{hiddenCount > 0 ? t('chatHistory.noSubfolders', { count: hiddenCount }) : 'No subfolders here'}
|
||||
</div>
|
||||
) : (
|
||||
dirs.map((d) => (
|
||||
@@ -113,14 +113,14 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') setCreating(false);
|
||||
}}
|
||||
placeholder="New folder name"
|
||||
placeholder={t('chatHistory.newFolderName')}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer"
|
||||
>
|
||||
Create
|
||||
{t('chatHistory.create')}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
@@ -129,7 +129,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-1.5 text-xs opacity-60 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" /> New folder
|
||||
<FolderPlus className="h-3.5 w-3.5" /> {t('chatHistory.newFolder')}
|
||||
</button>
|
||||
{/* Shown even at zero, so the control's state is always legible: a folder with nothing
|
||||
hidden and one with everything hidden must not look identical. */}
|
||||
@@ -139,7 +139,11 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
||||
className="flex items-center gap-1.5 text-xs opacity-60 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
{showHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
{showHidden ? 'Hide hidden' : `Show hidden${hiddenCount > 0 ? ` (${hiddenCount})` : ''}`}
|
||||
{showHidden
|
||||
? t('chatHistory.hideHidden')
|
||||
: hiddenCount > 0
|
||||
? t('chatHistory.showHiddenCount', { count: hiddenCount })
|
||||
: t('chatHistory.showHidden')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -151,7 +155,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
||||
onClick={onClose}
|
||||
className="rounded-md px-3 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -161,7 +165,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
|
||||
disabled={!absCurrent}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-3 py-1.5 text-sm font-medium text-duck-yellow hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" /> Select this folder
|
||||
<Check className="h-3.5 w-3.5" /> {t('chatHistory.selectFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Bell, BellOff, Check } from 'lucide-react';
|
||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||
import type { NotifyPreference } from 'state/useClaudeSessions';
|
||||
import type { PanelTranslationKey } from '../../locales';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown;
|
||||
|
||||
const OPTIONS: { value: NotifyPreference; label: string }[] = [
|
||||
{ value: 'every_turn', label: 'Every end of turn' },
|
||||
{ value: 'input_needed', label: 'Only when input is needed' },
|
||||
{ value: 'none', label: 'None' },
|
||||
const OPTIONS: { value: NotifyPreference; labelKey: PanelTranslationKey }[] = [
|
||||
{ value: 'every_turn', labelKey: 'chatHistory.notify.everyTurn' },
|
||||
{ value: 'input_needed', labelKey: 'chatHistory.notify.inputNeeded' },
|
||||
{ value: 'none', labelKey: 'chatHistory.notify.none' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,7 @@ type NotifyMenuProps = {
|
||||
* variant, which nothing here uses.
|
||||
*/
|
||||
export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const pick = (value: NotifyPreference) => {
|
||||
onChange(value);
|
||||
// Lazy, first-opt-in request — the only place this fires. A denial or an already-decided answer is a
|
||||
@@ -55,8 +58,10 @@ export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={`Notifications: ${OPTIONS.find((o) => o.value === preference)?.label ?? 'None'}`}
|
||||
aria-label="Notification preference"
|
||||
title={t('chatHistory.notificationsLabel', {
|
||||
choice: t(OPTIONS.find((o) => o.value === preference)?.labelKey ?? 'chatHistory.notify.none'),
|
||||
})}
|
||||
aria-label={t('chatHistory.notifyPreference')}
|
||||
className={`rounded p-1 transition-colors hover:bg-muted cursor-pointer ${BELL_TONE[preference]}`}
|
||||
>
|
||||
{preference === 'none' ? <BellOff className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5" />}
|
||||
@@ -66,7 +71,7 @@ export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => {
|
||||
{OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem key={opt.value} className="cursor-pointer" onClick={() => pick(opt.value)}>
|
||||
<Check className={`mr-2 h-4 w-4 ${opt.value === preference ? 'opacity-100' : 'opacity-0'}`} />
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes';
|
||||
import { DirPickerModal } from './DirPickerModal';
|
||||
import { LiveMarker, SessionRow, shortenPath, useScrollActiveIntoView, useSessionRowActions } from './SessionRow';
|
||||
import { SessionTree } from './SessionTree';
|
||||
import type { PanelTranslationKey } from '../../locales';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
/*
|
||||
The /chat sidebar: one panel, three views.
|
||||
@@ -30,10 +32,10 @@ import { SessionTree } from './SessionTree';
|
||||
|
||||
type View = 'tree' | 'latest' | 'live';
|
||||
|
||||
const TABS: { value: View; label: string }[] = [
|
||||
{ value: 'tree', label: 'Tree' },
|
||||
{ value: 'latest', label: 'Latest' },
|
||||
{ value: 'live', label: 'Live' },
|
||||
const TABS: { value: View; labelKey: PanelTranslationKey }[] = [
|
||||
{ value: 'tree', labelKey: 'chatHistory.tabs.tree' },
|
||||
{ value: 'latest', labelKey: 'chatHistory.tabs.latest' },
|
||||
{ value: 'live', labelKey: 'chatHistory.tabs.live' },
|
||||
];
|
||||
|
||||
/*
|
||||
@@ -52,6 +54,7 @@ const LIVE_KIND_KEY = 'chat.sessions.liveKind';
|
||||
const EXPANDED_KEY = 'chat.sessions.expanded';
|
||||
|
||||
export const SessionList = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
// Which session is open, and which project group we're browsing, both come from the route. Reading them
|
||||
// there rather than from the selection channel means the highlight and the group are correct on a deep
|
||||
@@ -157,7 +160,7 @@ export const SessionList = () => {
|
||||
value={tab.value}
|
||||
className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
{/* The count that decides whether Live is worth a click. Only when non-zero — a permanent
|
||||
`0` is the same as no information. */}
|
||||
{tab.value === 'live' && liveSessions.length > 0 && (
|
||||
@@ -173,8 +176,8 @@ export const SessionList = () => {
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Refresh"
|
||||
aria-label="Refresh sessions"
|
||||
title={t('chatHistory.refresh')}
|
||||
aria-label={t('chatHistory.refreshSessions')}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${latest.isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
@@ -184,10 +187,10 @@ export const SessionList = () => {
|
||||
<button
|
||||
onClick={() => setBrowsing(true)}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
title="New chat in a directory…"
|
||||
title={t('chatHistory.newChatInDir')}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Chat
|
||||
{t('chatHistory.newChat')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,25 +212,25 @@ export const SessionList = () => {
|
||||
<>
|
||||
{latest.error ? (
|
||||
<ErrorBlock
|
||||
title="Could not load sessions"
|
||||
title={t('chatHistory.loadFailed')}
|
||||
message={errorText(latest.error)}
|
||||
action={
|
||||
<button
|
||||
onClick={() => void latest.refetch()}
|
||||
className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"
|
||||
>
|
||||
Try again
|
||||
{t('chatHistory.tryAgain')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : latest.isLoading && latest.sessions.length === 0 ? (
|
||||
// Loading and empty used to be the same grey line of text, so a slow read looked like an
|
||||
// account with no history.
|
||||
<LoadingBlock label="Reading transcripts…" />
|
||||
<LoadingBlock label={t('chatHistory.reading')} />
|
||||
) : latest.sessions.length === 0 ? (
|
||||
<EmptyBlock
|
||||
icon={MessageSquare}
|
||||
title="No sessions yet"
|
||||
title={t('chatHistory.noSessions')}
|
||||
hint="Every conversation you have had shows up here, newest first. Start one with New Chat."
|
||||
/>
|
||||
) : (
|
||||
@@ -277,12 +280,13 @@ export const SessionList = () => {
|
||||
*/
|
||||
type LiveKind = 'agents' | 'commanders';
|
||||
|
||||
const LIVE_TABS: { value: LiveKind; label: string }[] = [
|
||||
{ value: 'agents', label: 'Agents' },
|
||||
{ value: 'commanders', label: 'Commanders' },
|
||||
const LIVE_TABS: { value: LiveKind; labelKey: PanelTranslationKey }[] = [
|
||||
{ value: 'agents', labelKey: 'chatHistory.tabs.agents' },
|
||||
{ value: 'commanders', labelKey: 'chatHistory.tabs.commanders' },
|
||||
];
|
||||
|
||||
const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }) => {
|
||||
const { t } = useTranslation();
|
||||
const { live: sessions, isLoading, error, refetch } = useLiveSessions();
|
||||
const [kind, setKind] = useLocalStorageState<LiveKind>(LIVE_KIND_KEY, 'agents');
|
||||
|
||||
@@ -297,20 +301,20 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorBlock
|
||||
title="Could not read live sessions"
|
||||
title={t('chatHistory.liveFailed')}
|
||||
message={errorText(error)}
|
||||
action={
|
||||
<button
|
||||
onClick={() => void refetch()}
|
||||
className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"
|
||||
>
|
||||
Try again
|
||||
{t('chatHistory.tryAgain')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isLoading && sessions.length === 0) return <LoadingBlock label="Asking the agent…" />;
|
||||
if (isLoading && sessions.length === 0) return <LoadingBlock label={t('chatHistory.asking')} />;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -328,7 +332,7 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }
|
||||
value={tab.value}
|
||||
className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
<span className={`ml-1 tabular-nums ${count > 0 ? 'text-success' : 'opacity-50'}`}>({count})</span>
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { DataRow, MetaItem, RelativeTime } from '@/components/Data';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import type { ClaudeSessionSummary, NotifyPreference } from 'state/useClaudeSessions';
|
||||
import { chatSessionPath } from './chat-routes';
|
||||
import { useTranslation } from '../../locales';
|
||||
import { Untranslated } from '@/components/Untranslated';
|
||||
|
||||
/*
|
||||
One conversation, rendered the same way in all three tabs.
|
||||
@@ -26,9 +28,13 @@ import { chatSessionPath } from './chat-routes';
|
||||
*/
|
||||
export const HarnessBadge = ({ harness }: { harness?: 'claude' | 'opencode' }) =>
|
||||
harness === 'opencode' ? (
|
||||
<span className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">OpenCode</span>
|
||||
<span className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">
|
||||
<Untranslated reason="product-name">OpenCode</Untranslated>
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">Claude</span>
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">
|
||||
<Untranslated reason="product-name">Claude</Untranslated>
|
||||
</span>
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -113,6 +119,7 @@ export function useSessionRowActions(actions: {
|
||||
renameSession: (id: string, title: string) => Promise<unknown>;
|
||||
deleteSession: (id: string) => Promise<unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [confirmingId, setConfirmingId] = useState<string | null>(null);
|
||||
@@ -134,7 +141,7 @@ export function useSessionRowActions(actions: {
|
||||
try {
|
||||
await actions.renameSession(id, title);
|
||||
} catch (err) {
|
||||
toast.error(`Could not rename the session: ${errorText(err)}`);
|
||||
toast.error(t('chatHistory.renameFailed', { reason: errorText(err) }));
|
||||
}
|
||||
}, [editingId, editValue, actions]);
|
||||
|
||||
@@ -144,7 +151,7 @@ export function useSessionRowActions(actions: {
|
||||
try {
|
||||
await actions.deleteSession(id);
|
||||
} catch (err) {
|
||||
toast.error(`Could not delete the session: ${errorText(err)}`);
|
||||
toast.error(t('chatHistory.deleteFailed', { reason: errorText(err) }));
|
||||
}
|
||||
},
|
||||
[actions],
|
||||
@@ -188,6 +195,7 @@ export const SessionRow = ({
|
||||
actions,
|
||||
activeRef,
|
||||
}: SessionRowProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const isActive = activeId === session.id;
|
||||
const isEditing = actions.editingId === session.id;
|
||||
const isConfirming = actions.confirmingId === session.id;
|
||||
@@ -209,15 +217,15 @@ export const SessionRow = ({
|
||||
if (ev.key === 'Escape') actions.setEditingId(null);
|
||||
}}
|
||||
onBlur={() => void actions.commitRename()}
|
||||
aria-label="Session title"
|
||||
aria-label={t('chatHistory.sessionTitle')}
|
||||
className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
className="cursor-pointer p-1 text-success hover:opacity-80"
|
||||
title="Save"
|
||||
aria-label="Save title"
|
||||
title={t('common.save')}
|
||||
aria-label={t('chatHistory.saveTitle')}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -226,8 +234,8 @@ export const SessionRow = ({
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
onClick={() => actions.setEditingId(null)}
|
||||
className="cursor-pointer p-1 opacity-50 hover:opacity-100"
|
||||
title="Cancel"
|
||||
aria-label="Cancel rename"
|
||||
title={tUi('button.cancel')}
|
||||
aria-label={t('chatHistory.cancelRename')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -279,21 +287,23 @@ export const SessionRow = ({
|
||||
{/* Say the size of it. Deleting a merged chain removes every part, and a row that looks like one
|
||||
conversation giving no warning that it is three files is exactly the surprise this prevents. */}
|
||||
<span className="text-xs text-destructive">
|
||||
{session.partCount && session.partCount > 1 ? `Delete all ${session.partCount}?` : 'Delete?'}
|
||||
{session.partCount && session.partCount > 1
|
||||
? t('chatHistory.deleteAll', { count: session.partCount })
|
||||
: 'Delete?'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => void actions.handleDelete(session.id)}
|
||||
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
title="Confirm delete"
|
||||
aria-label={`Confirm deleting ${session.title}`}
|
||||
title={t('chatHistory.confirmDelete')}
|
||||
aria-label={t('chatHistory.confirmDeletingNamed', { name: session.title })}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => actions.setConfirmingId(null)}
|
||||
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
|
||||
title="Cancel"
|
||||
aria-label="Cancel delete"
|
||||
title={tUi('button.cancel')}
|
||||
aria-label={t('chatHistory.cancelDelete')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -304,16 +314,16 @@ export const SessionRow = ({
|
||||
<button
|
||||
onClick={() => actions.startRename(session.id, session.title)}
|
||||
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Rename"
|
||||
aria-label={`Rename ${session.title}`}
|
||||
title={t('chatHistory.rename')}
|
||||
aria-label={t('chatHistory.renameNamed', { name: session.title })}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => actions.setConfirmingId(session.id)}
|
||||
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-destructive"
|
||||
title="Delete session"
|
||||
aria-label={`Delete ${session.title}`}
|
||||
title={t('chatHistory.deleteSession')}
|
||||
aria-label={t('chatHistory.deleteNamed', { name: session.title })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { LiveSession } from 'state/useLiveSessions';
|
||||
import { chatNewPath } from './chat-routes';
|
||||
import { SessionRow, shortenPath, type SessionRowActions, useScrollActiveIntoView } from './SessionRow';
|
||||
import { useSelectedChatSession } from '../../channels';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
/*
|
||||
Directories as an accordion, each expanding to its own conversations.
|
||||
@@ -36,6 +37,7 @@ type DirectoryNodeProps = {
|
||||
};
|
||||
|
||||
const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions }: DirectoryNodeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [, setSelected] = useSelectedChatSession();
|
||||
const activeRef = useScrollActiveIntoView();
|
||||
@@ -104,8 +106,8 @@ const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions
|
||||
<button
|
||||
type="button"
|
||||
onClick={startHere}
|
||||
title={`New chat in ${shortenPath(pwd.cwd, home)}`}
|
||||
aria-label={`New chat in ${shortenPath(pwd.cwd, home)}`}
|
||||
title={t('chatHistory.newChatIn', { path: shortenPath(pwd.cwd, home) })}
|
||||
aria-label={t('chatHistory.newChatIn', { path: shortenPath(pwd.cwd, home) })}
|
||||
className="mr-2 shrink-0 cursor-pointer rounded p-1.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/dir:opacity-100"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
@@ -123,11 +125,11 @@ const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions
|
||||
{expanded && (
|
||||
<div className="bg-background/40 py-2 pl-8">
|
||||
{isLoading && sessions.length === 0 ? (
|
||||
<LoadingBlock label="Reading transcripts…" />
|
||||
<LoadingBlock label={t('chatHistory.reading')} />
|
||||
) : sessions.length === 0 ? (
|
||||
// Reachable: `/chat/pwds` counts transcripts per group, and a group can hold only files this
|
||||
// reader skips. Better than an accordion that opens onto nothing.
|
||||
<div className="py-3 text-xs text-muted-foreground">No readable conversations here.</div>
|
||||
<div className="py-3 text-xs text-muted-foreground">{t('chatHistory.noReadable')}</div>
|
||||
) : (
|
||||
<DataList className="!h-auto !overflow-visible">
|
||||
{sessions.map((session) => (
|
||||
@@ -167,11 +169,12 @@ export const SessionTree = ({
|
||||
activeId,
|
||||
actions,
|
||||
}: SessionTreeProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!pwds.length) {
|
||||
return (
|
||||
<EmptyBlock
|
||||
icon={MessageSquare}
|
||||
title="No conversations yet"
|
||||
title={t('chatHistory.noConversations')}
|
||||
hint="Every directory you have run a chat in shows up here. Start one with New Chat."
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MessageSquare, List } from 'lucide-react';
|
||||
import { SessionList } from './SessionList';
|
||||
import { ChatDetailPanel } from './ChatDetailPanel';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { SessionList };
|
||||
export { ChatDetailPanel };
|
||||
@@ -11,14 +12,14 @@ export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'chat-session-list',
|
||||
name: 'Sessions',
|
||||
name: panelName('panelName.chatSessions'),
|
||||
icon: List,
|
||||
component: SessionList,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'chat-detail',
|
||||
name: 'Chat',
|
||||
name: panelName('panelName.chat'),
|
||||
icon: MessageSquare,
|
||||
component: ChatDetailPanel,
|
||||
availableOnPanel: false,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useFilesRefresh } from '../../channels';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useEditorTabs } from './use-editor-tabs';
|
||||
import { configureMonacoTypescript } from './monaco-setup';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
type CodeEditorViewProps = {
|
||||
className?: string;
|
||||
@@ -103,6 +104,7 @@ export const CodeEditorView = ({
|
||||
urlState,
|
||||
panelId,
|
||||
}: CodeEditorViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspace();
|
||||
const { restored, remember } = useEditorTabs(panelId, workspace);
|
||||
const {
|
||||
@@ -138,7 +140,7 @@ export const CodeEditorView = ({
|
||||
const res = await readFile(path);
|
||||
openFile(path, name, res.content);
|
||||
} catch {
|
||||
toast.error('Failed to read file');
|
||||
toast.error(t('codeEditor.readFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,7 +161,7 @@ export const CodeEditorView = ({
|
||||
.then((res) => alive && openFile(path, path.split('/').pop() ?? path, res.content))
|
||||
.catch(() => {
|
||||
unreadable.current.add(path);
|
||||
if (alive) toast.error('Failed to read file');
|
||||
if (alive) toast.error(t('codeEditor.readFailed'));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -273,9 +275,9 @@ export const CodeEditorView = ({
|
||||
try {
|
||||
await writeFile(activeFile.path, activeFile.content);
|
||||
markSaved(activeFile.path, activeFile.content);
|
||||
toast.success('File saved');
|
||||
toast.success(t('codeEditor.saved'));
|
||||
} catch {
|
||||
toast.error('Failed to save file');
|
||||
toast.error(t('codeEditor.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -322,10 +324,10 @@ export const CodeEditorView = ({
|
||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider flex items-center justify-between gap-2"
|
||||
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
|
||||
>
|
||||
<span>Explorer</span>
|
||||
<span>{t('codeEditor.explorer')}</span>
|
||||
<button
|
||||
onClick={() => setManualRefresh((n) => n + 1)}
|
||||
title="Refresh the tree and reload unmodified open files"
|
||||
title={t('codeEditor.refresh')}
|
||||
className="p-0.5 rounded hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
@@ -383,7 +385,7 @@ export const CodeEditorView = ({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
|
||||
Open a file from the explorer to start editing
|
||||
{t('codeEditor.openAFile')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Code } from 'lucide-react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
export const CodeEditorHeader = () => {
|
||||
const { t } = useTranslation();
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Code className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Code Editor</span>
|
||||
<span className="text-xs font-medium shrink-0">{t('codeEditor.title')}</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user