Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d60e409b | ||
|
|
b1dfa3f7bb | ||
|
|
f586d748d6 | ||
|
|
bcdc769083 | ||
|
|
430f058a6d | ||
|
|
9b459b79dd | ||
|
|
ab77c66f04 | ||
|
|
dd6135cac1 | ||
|
|
bd15a186b5 | ||
|
|
a15fadbddc | ||
|
|
7e054ddf11 | ||
|
|
0c2ff88c88 | ||
|
|
c982353381 |
@@ -20,7 +20,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**87 open, 22 closed**, across 29 themes.
|
||||
**87 open, 25 closed**, across 29 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||
@@ -35,9 +35,9 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 6 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 4 | 1 |
|
||||
| [Known bugs](todo/known-bugs/) | 3 | 2 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 18 | 3 |
|
||||
| [Multi-user](todo/multi-user/) | 16 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
@@ -45,7 +45,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||
@@ -66,10 +66,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
|
||||
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
|
||||
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
|
||||
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
|
||||
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
|
||||
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
|
||||
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
||||
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
|
||||
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
|
||||
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
|
||||
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
|
||||
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
|
||||
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# 2026.08.22.01 — deploy report
|
||||
|
||||
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
|
||||
No host failed, no process came back `errored`.
|
||||
|
||||
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
|
||||
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
|
||||
|
||||
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
|
||||
`~/dockers/officer.dev/platform` was not touched).
|
||||
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
|
||||
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
|
||||
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||
photos, soulseek, transmission, vault.
|
||||
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
|
||||
- All 19 processes `online` afterwards.
|
||||
|
||||
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
|
||||
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
|
||||
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
|
||||
captured an explicit `0`.
|
||||
|
||||
### `fofos` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
|
||||
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
|
||||
touch it, and it was left alone. No tracked change.
|
||||
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
|
||||
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
|
||||
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
|
||||
fix of 2026-08-21 is holding.
|
||||
|
||||
### `macbookpro` — succeeded
|
||||
|
||||
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
|
||||
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
|
||||
interactive shell, exactly as the plan and `docs/servers.md` require.
|
||||
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
|
||||
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
|
||||
|
||||
### `edge-pertento-bridge` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
|
||||
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
|
||||
|
||||
### `production-pertento-bridge` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
|
||||
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
|
||||
and the command still exited 0 — recorded so the next reader does not chase it.
|
||||
- `pm2 restart officer`: 9 → 10, `online`.
|
||||
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
|
||||
All `online`, none touched.
|
||||
|
||||
### `deadpool-rezio-bridge` — succeeded
|
||||
|
||||
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
|
||||
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
|
||||
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
|
||||
the others'. Not an error.
|
||||
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
|
||||
|
||||
### `officer-dev` — SKIPPED, and current
|
||||
|
||||
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
|
||||
|
||||
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
|
||||
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
|
||||
another agent working in this same tree during the rollout. Its work was left alone; only this report was
|
||||
staged, by path.
|
||||
|
||||
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
|
||||
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
|
||||
at `78db9c8c`, the tag — but the next release will include them.
|
||||
|
||||
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
|
||||
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
|
||||
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
|
||||
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
|
||||
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
|
||||
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
|
||||
|
||||
## What was not done
|
||||
|
||||
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
|
||||
happy with the rest. It needs no pull.
|
||||
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
|
||||
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
|
||||
files arrived, not that each parses.
|
||||
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026.08.22.02
|
||||
|
||||
Theme favourites, a shortcuts modal on `Ctrl+?`, Command HQ rebuilt into two columns, 27 tests on the
|
||||
authorization resolver, and the email plugin's multi-account backend.
|
||||
|
||||
**SCHEMA CHANGE.** `theme_favorites` is new, so `bun db:push` is a step. Without it, opening the theme
|
||||
dropdown fails on a missing relation the first time it reads a starred list.
|
||||
|
||||
**No dependency change** — nothing in `deploy/2026.08.22.01..HEAD` touches `package.json` dependencies or
|
||||
`bun.lock`. Do not run `bun install`.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Theme favourites**, per account, in `theme_favorites`. Star from the dropdown, from the browser
|
||||
(`f`), or from anywhere (`Ctrl+Shift+F`). The lists rotate to the theme you are wearing rather than
|
||||
sorting it to the top, so neighbours survive.
|
||||
- **`Ctrl+?`** renders every shell shortcut from a registry — `state/shortcuts.ts` — ported in shape from
|
||||
the owner's `vimazing`.
|
||||
- **Spotlight is on `Shift+Space`.** It was `alt-space` in the last release. `Ctrl+Space` cannot work:
|
||||
Brave claims it at the browser level and the key never reaches the page.
|
||||
- **Command HQ is two columns** — navigation stacked into one 24% rail, transcript at 76%. **It migrates a
|
||||
saved layout on load**, so anyone who has opened that screen will see their arrangement replaced once.
|
||||
That is deliberate; the screen is `locked` and the only user contribution was column sizes.
|
||||
- **27 tests on `permissions/authorize.ts`**, which had none. Its decisions are now pure functions, split
|
||||
from the database calls. No behaviour change.
|
||||
- **`assertPermissionTotality` reads the sockets that are served**, generated from one `WS_ROUTES`
|
||||
declaration, instead of a parallel list of handlers.
|
||||
- **The email plugin's multi-account backend**, via `plugins:update` — `?account=<id>` names a mailbox,
|
||||
`?account=all` spans them, absent is unchanged. Only hosts with that plugin installed are affected.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git pull --ff-only`
|
||||
2. **`bun db:push`** — new this release
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
|
||||
report rather than being omitted.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
|
||||
`docs/servers.md`.
|
||||
|
||||
## `officer-dev` — skip it, and it is already current
|
||||
|
||||
The commander runs there and skips its own host. That host was restarted repeatedly through the day as this
|
||||
work landed, so it is running this tree rather than lagging. Report it as skipped and current.
|
||||
|
||||
## Then check
|
||||
|
||||
`pm2 jlist`: `officer` is `online` and `restart_time` moved; nothing `errored`.
|
||||
|
||||
`theme_favorites` exists — `to_regclass('theme_favorites')` is not null. This is the one step whose absence
|
||||
shows up as a broken dropdown rather than a failed boot, so verify it directly where `psql` is available and
|
||||
say where it is not.
|
||||
|
||||
`scripts/deploy-claim.sh state 2026.08.22.02` says `done`.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.22.01",
|
||||
"version": "2026.08.22.02",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -18,6 +18,21 @@ there is a release to deploy that has not been deployed.
|
||||
git ls-remote --tags origin 'refs/tags/deploy/*'
|
||||
```
|
||||
|
||||
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
|
||||
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
|
||||
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
|
||||
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
|
||||
long stretches.
|
||||
|
||||
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
|
||||
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
|
||||
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
|
||||
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
|
||||
|
||||
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
|
||||
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
|
||||
already trusted.
|
||||
|
||||
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
|
||||
a commit message, not a branch, not a push.
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
|
||||
|
||||
// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL
|
||||
// /command-hq: navigation and detail, all panels always mounted (like /chat), selection carried by the URL
|
||||
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
|
||||
// "Route, surface, placement" and "Layout, v1".
|
||||
// "Route, surface, placement".
|
||||
|
||||
export const CommandHqScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
|
||||
|
||||
/*
|
||||
Move a saved three-column layout onto the two-column one.
|
||||
|
||||
The default alone cannot do it: this key is persisted per account, so anybody who has opened the screen
|
||||
keeps the arrangement they had. See `isLegacyThreeColumn` for why the test is structural rather than an
|
||||
appType check, and why replacing outright is the safe direction on a `locked` screen.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!workspace.isLoaded) return;
|
||||
if (!isLegacyThreeColumn(workspace.value)) return;
|
||||
workspace.setValue(defaultLayout);
|
||||
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
|
||||
@@ -1,14 +1,60 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The
|
||||
// detail pane gets the most room because it is a live chat transcript once something is selected.
|
||||
/*
|
||||
Two columns, with the navigation stacked into the left one.
|
||||
|
||||
── Why it changed from three ──
|
||||
|
||||
It was `16 / 34 / 50`: a category column, a commander column, and the transcript. So half the screen was
|
||||
navigation for a list that holds a handful of commanders — one, on the machine this was written on — while
|
||||
the only thing anybody reads got the other half. A category pane of its own is a filter for a list short
|
||||
enough to see whole.
|
||||
|
||||
Now: commanders on top, categories beneath them, transcript taking three quarters.
|
||||
|
||||
── Why the categories are BELOW the commanders ──
|
||||
|
||||
Because the commanders are the thing you came for and the categories are how you narrow them when there
|
||||
are too many to scan. Above, the filter would push the list down the screen and be the first thing read;
|
||||
below, the list is at the top where a list belongs and the filter is where you reach for it. It also means
|
||||
the categories can grow their own scrollbar without ever taking room from the commanders.
|
||||
*/
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'command-hq-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 },
|
||||
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 },
|
||||
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'command-hq-nav',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 62 },
|
||||
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 38 },
|
||||
],
|
||||
},
|
||||
size: 24,
|
||||
},
|
||||
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 76 },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Is this a saved copy of the three-column layout this replaced?
|
||||
*
|
||||
* No appType was retired — all three panels survive and only their arrangement changed — so `hasAppType`
|
||||
* cannot see this the way the /chat migration could. The test is structural instead: the old shape is a
|
||||
* horizontal root with THREE children, and the new one has two.
|
||||
*
|
||||
* It terminates for that reason: the replacement does not satisfy the test.
|
||||
*
|
||||
* Replacing outright is safe because the screen is `locked` — its structure is dictated by code, and the
|
||||
* only thing a person can have contributed is the column sizes, which is a cheap thing to lose once. The
|
||||
* same reasoning, and the same warning, as `ChatHistory/index.tsx`: get the test backwards and a stranded
|
||||
* panel collapses onto `appTypes.fallback` and is written back, which is how /offscale once rendered three
|
||||
* copies of one picker.
|
||||
*/
|
||||
export function isLegacyThreeColumn(node: LayoutNode): boolean {
|
||||
return node.type === 'group' && node.direction === 'horizontal' && node.children.length === 3;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
|
||||
import { Spotlight } from './Spotlight/Spotlight';
|
||||
import { ShortcutsModal } from './ShortcutsModal';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
import { usePageTitleSync } from '@/state/usePageTitle';
|
||||
import { RouteGate } from './RouteGate';
|
||||
@@ -51,6 +52,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
||||
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
||||
<Spotlight />
|
||||
{/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
|
||||
panel, an error boundary, or mid-navigation. */}
|
||||
<ShortcutsModal />
|
||||
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
||||
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
||||
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, Star } from 'lucide-react';
|
||||
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||
import type { Theme } from '@/components/ui/ThemeProvider';
|
||||
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
||||
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
||||
|
||||
const {
|
||||
DropdownMenu,
|
||||
@@ -21,7 +23,7 @@ const {
|
||||
*
|
||||
* ── The overlay ──
|
||||
*
|
||||
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
||||
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
||||
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
||||
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
||||
* you browse, so as little of it as possible should be covered.
|
||||
@@ -30,21 +32,39 @@ const {
|
||||
*/
|
||||
export const ThemeSwitcher = () => {
|
||||
const { theme, themeId, setTheme, themes } = useTheme();
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys();
|
||||
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
||||
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
||||
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||
|
||||
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is
|
||||
// readonly, so this builds mutable arrays rather than pushing into its own type.
|
||||
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
|
||||
(acc[t.family] ??= []).push(t);
|
||||
return acc;
|
||||
}, {});
|
||||
/*
|
||||
One ordering, used by both lists.
|
||||
|
||||
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
|
||||
is the registry order — families sit together in it — and lifting the selected theme out of its family
|
||||
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
|
||||
`theme/rotate.ts`.
|
||||
|
||||
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
|
||||
the order it was given, so the theme you are wearing still leads.
|
||||
*/
|
||||
const ordered = useMemo(() => {
|
||||
const rotated = rotateToSelected(themes, themeId);
|
||||
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
|
||||
return favouritesFirst(listed, favorites);
|
||||
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
||||
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||
|
||||
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
title={`Theme: ${theme.name} · ctrl+shift+←/→ to cycle · ctrl+shift+space for the list`}
|
||||
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
|
||||
isFavorite(themeId) ? 'unstar' : 'star'
|
||||
}`}
|
||||
aria-label={`Theme: ${theme.name}`}
|
||||
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
||||
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
||||
@@ -53,23 +73,74 @@ export const ThemeSwitcher = () => {
|
||||
style={{ color: 'var(--header-fg)' }}
|
||||
>
|
||||
{theme.name}
|
||||
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
|
||||
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
|
||||
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
ctrl+shift+←/→ · ctrl+shift+space
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(families).map(([family, entries]) => (
|
||||
<div key={family}>
|
||||
{entries.map((t) => (
|
||||
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
|
||||
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
||||
{t.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{/*
|
||||
The CONTENT does not scroll — the list inside it does.
|
||||
|
||||
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
|
||||
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
|
||||
visible for the first half-second and then gone for good, which is the same as not having written
|
||||
it. Header and footer are now siblings of a scroll box rather than passengers in one.
|
||||
*/}
|
||||
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
||||
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
||||
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
||||
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
|
||||
<span className="block">ctrl+? for every shortcut</span>
|
||||
</DropdownMenuLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
// The dropdown closes on any item activation, and this is a filter rather than a choice.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setOnlyFavorites((was) => !was);
|
||||
}}
|
||||
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
|
||||
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
||||
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{ordered.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
No favourites yet — star one with the button on its row.
|
||||
</p>
|
||||
) : (
|
||||
families.map(([family, entries]) => (
|
||||
<div key={family}>
|
||||
{entries.map((t) => (
|
||||
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
|
||||
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggle(t.id);
|
||||
}}
|
||||
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -90,8 +161,11 @@ export const ThemeSwitcher = () => {
|
||||
}}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{themes.map((t) => {
|
||||
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
|
||||
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
||||
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
||||
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
||||
{ordered.map((t) => {
|
||||
const isFocused = t.id === focused;
|
||||
return (
|
||||
<li key={t.id}>
|
||||
@@ -101,19 +175,43 @@ export const ThemeSwitcher = () => {
|
||||
setTheme(t.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
||||
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
||||
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
|
||||
{/*
|
||||
A span, not a button: this sits inside the row's own <button>, and a button inside
|
||||
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
||||
handler and the role are set explicitly instead, and `f` does the same thing from
|
||||
the keyboard.
|
||||
*/}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
|
||||
onClick={(ev) => {
|
||||
// Or the row's own onClick applies the theme and closes the overlay, which is
|
||||
// the opposite of what starring is for.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggle(t.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
||||
header does. */}
|
||||
<p className="mt-6 text-center text-xs opacity-60">
|
||||
j/k or ↑/↓ move · l or → preview · h or ← reset · enter keep · esc cancel
|
||||
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
|
||||
</p>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
|
||||
|
||||
/*
|
||||
`Ctrl+?` — every shortcut the shell owns, on one screen.
|
||||
|
||||
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
|
||||
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
|
||||
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
|
||||
interesting part is `state/shortcuts.ts`.
|
||||
|
||||
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
|
||||
controls grid, and for the same reason: these are short lists that read badly as one long column.
|
||||
*/
|
||||
|
||||
const Row = ({ keys, description }: { keys: string; description: string }) => (
|
||||
<div className="flex items-baseline gap-3 py-1">
|
||||
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
|
||||
putting them on the left. */}
|
||||
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
|
||||
{keys}
|
||||
</kbd>
|
||||
<span className="text-sm text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ShortcutsModal = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (isShortcutsChord(ev)) {
|
||||
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
|
||||
// composer is a help list you cannot open — the lesson from the Spotlight guard.
|
||||
ev.preventDefault();
|
||||
setOpen((was) => !was);
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape' && open) setOpen(false);
|
||||
};
|
||||
// Capture, so a panel with its own key handling cannot swallow it first.
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
|
||||
onMouseDown={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
|
||||
</div>
|
||||
|
||||
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
||||
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
|
||||
{SHORTCUT_SECTIONS.map((section) => (
|
||||
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
|
||||
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
|
||||
<div className="mt-2">
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,12 +11,28 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
||||
//
|
||||
// ── Keyboard first, and the trigger ──
|
||||
//
|
||||
// `alt-space`, and ONLY that. It went ctrl-space → shift-space → alt-space, and each move was the owner
|
||||
// telling us what the chord actually collides with on the machine he uses:
|
||||
// `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
|
||||
// shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
|
||||
//
|
||||
// ctrl-space focuses the address bar in Brave
|
||||
// shift-space types a space — see below, this was the real problem
|
||||
// alt-space taken by the window menu, and the owner rebound it rather than give up the chord
|
||||
// ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
|
||||
// preventDefault would win. It does not — the key never reaches this handler.
|
||||
// alt-space the GNOME window menu. He rebound the desktop to keep it, disliked the replacement, and
|
||||
// would rather move the app than the window manager.
|
||||
// shift-space where it lands, with BOTH of its stock behaviours deliberately overridden.
|
||||
//
|
||||
// ── Shift-space overrides two things, on purpose ──
|
||||
//
|
||||
// It types a space in a text field, and it scrolls a page UP when nothing has focus. The owner's judgement,
|
||||
// and it is his to make: neither is worth a launcher. Page-up-by-shift-space is a fossil nobody reaches for,
|
||||
// and typing a space is what the unmodified spacebar is for.
|
||||
//
|
||||
// The cost, stated once because it is the one that will bite: typing in CAPS by holding shift rather than
|
||||
// using caps lock puts a shift-space between every word. That opens the launcher mid-sentence. If it turns
|
||||
// out to be a nuisance, the fix is a guard for text fields only — which this had for a day, and which is
|
||||
// what made the launcher unopenable from the composer, where it is most wanted.
|
||||
//
|
||||
// This is also why the ctrl+? registry names the file that implements each chord: five moves in three days
|
||||
// is the argument for making them configurable, not for choosing better.
|
||||
//
|
||||
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
|
||||
// combination inside a text field: it types a space, and people hold shift through one more often than
|
||||
@@ -24,7 +40,7 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
||||
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
|
||||
// A launcher you have to prepare for is not a launcher.
|
||||
//
|
||||
// Alt-space has no meaning inside a text field, so there is NO focus check at all. The guard shift-space
|
||||
// There is NO focus check, and here that is a choice with a cost rather than a free one — see above. The guard shift-space
|
||||
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
|
||||
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
|
||||
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
|
||||
@@ -200,15 +216,18 @@ export const Spotlight = () => {
|
||||
// anywhere — including inside a panel that has its own key handling.
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
// `altKey` and `code`, not `key`: alt-space produces a different `key` on several layouts (and
|
||||
// nothing at all on some), while the physical key is `Space` everywhere.
|
||||
// `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
|
||||
// the physical key is `Space` everywhere.
|
||||
//
|
||||
// Every other modifier must be ABSENT. `ctrl+shift+space` is the theme browser, and one chord opening
|
||||
// both is the bug that took a capture-phase listener to fix on the theme side.
|
||||
//
|
||||
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
|
||||
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
|
||||
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
|
||||
// below covers the only thing the guard was protecting against — a layout that would otherwise type
|
||||
// a character.
|
||||
if (ev.altKey && ev.code === 'Space') {
|
||||
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
|
||||
ev.preventDefault();
|
||||
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
|
||||
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Every keyboard shortcut the shell owns, as data.
|
||||
|
||||
── Why a registry ──
|
||||
|
||||
Ported in shape from the owner's `vimazing` (`vim-maze/src/gameInfo.ts`), where the controls are declared
|
||||
once and the help screen renders them. The alternative is what this replaced: chords spelled out inside
|
||||
three separate key handlers, and a help text nobody updates because it lives somewhere else.
|
||||
|
||||
Two things follow from having it. `Ctrl+?` can render an accurate list without anybody maintaining prose.
|
||||
And when shortcuts become configurable — the next big piece — this is the table a binding UI edits, rather
|
||||
than a grep across handlers.
|
||||
|
||||
── What it is NOT, yet ──
|
||||
|
||||
Declarative but not authoritative: the handlers still contain their own conditions, so a chord changed here
|
||||
does not move. Making this the single source is part of the configurable-shortcuts work, and doing it now
|
||||
would mean a rewrite of three handlers for no visible gain. Where a chord appears in both places the file
|
||||
that owns it is named in `implementedIn`, so the pair can be found in one search.
|
||||
*/
|
||||
|
||||
export type Shortcut = {
|
||||
/** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
|
||||
keys: string;
|
||||
description: string;
|
||||
/** The file whose handler actually implements it, until this registry is authoritative. */
|
||||
implementedIn: string;
|
||||
};
|
||||
|
||||
export type ShortcutSection = {
|
||||
title: string;
|
||||
/** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
|
||||
note?: string;
|
||||
shortcuts: Shortcut[];
|
||||
};
|
||||
|
||||
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Shift+Space',
|
||||
description: 'Spotlight — go to any screen, plugin or setting by name',
|
||||
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
|
||||
},
|
||||
{ keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Themes',
|
||||
note: 'The browser applies as you move, so you watch the app change rather than reading names.',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: 'Ctrl+Shift+Space',
|
||||
description: 'Open the theme browser',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl+Shift+← / →',
|
||||
description: 'Previous / next theme, without opening anything',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl+Shift+F',
|
||||
description: 'Star or unstar the current theme, from anywhere',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'In the theme browser',
|
||||
shortcuts: [
|
||||
{ keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{
|
||||
keys: 'l →',
|
||||
description: 'Apply the focused theme and keep browsing',
|
||||
implementedIn: 'theme/useThemeKeys.ts',
|
||||
},
|
||||
{ keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
{ keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'In Spotlight',
|
||||
shortcuts: [
|
||||
{ keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
{ keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Chat',
|
||||
note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: '↑ / ↓',
|
||||
description: 'Previous / next prompt you sent, shell-style',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
{
|
||||
keys: 'Enter',
|
||||
description: 'Send · on an empty composer with something queued, send that now',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
{ keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
|
||||
{
|
||||
keys: 'Esc',
|
||||
description: 'Stop the turn that is generating',
|
||||
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Is this the `Ctrl+?` chord?
|
||||
*
|
||||
* `?` is `Shift+/` on most layouts and its own key on some, so both spellings are accepted rather than
|
||||
* guessing which keyboard is attached. `code === 'Slash'` is the physical key, which is stable where `key`
|
||||
* is not.
|
||||
*/
|
||||
export function isShortcutsChord(ev: KeyboardEvent): boolean {
|
||||
if (!ev.ctrlKey) return false;
|
||||
return ev.key === '?' || (ev.shiftKey && ev.code === 'Slash');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
/*
|
||||
Starred themes, per account.
|
||||
|
||||
With 200+ installed, "which of these did I like" is a question the list cannot answer by itself, and the
|
||||
answer belongs to the account rather than to the browser — the same taste follows you to the phone.
|
||||
|
||||
Optimistic, and that is not a nicety here: the star sits in a dropdown you are about to close, so a
|
||||
round trip before the icon fills means the confirmation arrives after the thing it confirms has gone.
|
||||
*/
|
||||
|
||||
const KEY = ['themes', 'favorites'];
|
||||
|
||||
export function useThemeFavorites() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery<{ favorites: string[] }>({
|
||||
queryKey: KEY,
|
||||
queryFn: () => client.get<{ favorites: string[] }>('/theme-favorites'),
|
||||
// Taste does not change while you look at it, and the mutation below keeps the cache honest.
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: ({ themeId, favorite }: { themeId: string; favorite: boolean }) =>
|
||||
client.put<{ themeId: string; favorite: boolean }>(`/theme-favorites/${themeId}`, { favorite }),
|
||||
onMutate: async ({ themeId, favorite }) => {
|
||||
await queryClient.cancelQueries({ queryKey: KEY });
|
||||
const previous = queryClient.getQueryData<{ favorites: string[] }>(KEY);
|
||||
const current = previous?.favorites ?? [];
|
||||
queryClient.setQueryData(KEY, {
|
||||
favorites: favorite ? [...new Set([...current, themeId])] : current.filter((id) => id !== themeId),
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
// Put it back on failure. A star that stayed filled after the write was refused is worse than one that
|
||||
// never filled: it reports a preference the server does not hold.
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(KEY, context.previous);
|
||||
},
|
||||
});
|
||||
|
||||
const favorites = new Set(query.data?.favorites ?? []);
|
||||
|
||||
return {
|
||||
favorites,
|
||||
isFavorite: (themeId: string) => favorites.has(themeId),
|
||||
toggle: (themeId: string) => toggle.mutate({ themeId, favorite: !favorites.has(themeId) }),
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export * from './chat-notification-prefs';
|
||||
export * from './nudges';
|
||||
export * from './ai-provider-usage';
|
||||
export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -35,6 +35,7 @@ export * from './chat-session-models/schema'; // chat_session_models
|
||||
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
|
||||
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
|
||||
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { themeFavorites } from './schema';
|
||||
|
||||
/** The theme ids this account has starred. Order is not meaningful — the caller owns the ordering. */
|
||||
export async function listThemeFavorites(userId: number): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ themeId: themeFavorites.themeId })
|
||||
.from(themeFavorites)
|
||||
.where(eq(themeFavorites.userId, userId));
|
||||
return rows.map((r) => r.themeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Star or unstar, returning the state it ended in.
|
||||
*
|
||||
* Idempotent in both directions on purpose: a double-tap, or two tabs racing, must not end with the star
|
||||
* in a state neither of them asked for. `onConflictDoNothing` makes the insert safe to repeat, and the
|
||||
* delete is naturally so.
|
||||
*/
|
||||
export async function setThemeFavorite(userId: number, themeId: string, favorite: boolean): Promise<boolean> {
|
||||
if (favorite) {
|
||||
await db.insert(themeFavorites).values({ userId, themeId }).onConflictDoNothing();
|
||||
return true;
|
||||
}
|
||||
await db.delete(themeFavorites).where(and(eq(themeFavorites.userId, userId), eq(themeFavorites.themeId, themeId)));
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* Which themes an account has starred.
|
||||
*
|
||||
* ── Why a table, when a preference could be one JSON column ──
|
||||
*
|
||||
* Because it is per account and unbounded. `user_settings` would hold an array that grows with taste and
|
||||
* has to be read-modify-written by whoever toggles a star, and two tabs doing that at once lose one of the
|
||||
* stars. A row per favourite makes a toggle an INSERT or a DELETE, which cannot race with itself.
|
||||
*
|
||||
* ── Why the theme id is text with no foreign key ──
|
||||
*
|
||||
* Themes are not database rows. Most are compiled into the bundle; the rest are directories under
|
||||
* `platform/themes/` discovered at request time, so a theme can appear or vanish without anything writing
|
||||
* SQL. A foreign key would either be unenforceable or would make installing a theme a migration.
|
||||
*
|
||||
* The consequence is deliberate: a star can outlive its theme. That is why the reader intersects with the
|
||||
* live registry rather than trusting the table — a favourite naming a theme nobody has installed is
|
||||
* ignored, not shown as a broken entry, and it costs one row until the account unstars something.
|
||||
*/
|
||||
export const themeFavorites = pgTable(
|
||||
'theme_favorites',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The theme's registry id — `duck-dark`, `catppuccin`. Not a foreign key; see above. */
|
||||
themeId: text('theme_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
// See src/databases/CLAUDE.md → "Composite keys".
|
||||
(table) => [uniqueIndex('uq_theme_favorites_user_theme').on(table.userId, table.themeId)],
|
||||
);
|
||||
+67
-16
@@ -162,16 +162,60 @@ const handlers: Record<string, any> = {
|
||||
sidecar: sidecarWebsocket,
|
||||
};
|
||||
|
||||
// Both doors into the platform, checked against the permission registry before either opens.
|
||||
//
|
||||
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
|
||||
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
|
||||
// from the map directly above, so neither list can be a stale copy of the thing it describes.
|
||||
/**
|
||||
* Every websocket path Bun serves, and the provider each one upgrades to.
|
||||
*
|
||||
* ── Why this exists rather than a hand-written route table ──
|
||||
*
|
||||
* The boot check below used to be fed `Object.keys(handlers)` — the map of provider implementations — while
|
||||
* Bun served a SEPARATE, hand-listed set of routes. Two lists describing one thing, and the comment beside
|
||||
* the check said neither could be a stale copy of the other, which was simply not true: a socket route with
|
||||
* no handler, or with no registry claim, was invisible to the very check that exists to refuse it.
|
||||
*
|
||||
* It had a live instance. `/api/cliamp/ws` and `/api/cliamp/audio/ws` stayed in the route table for two
|
||||
* days after their handlers and registry entries were commented out — not exploitable, since
|
||||
* `isWsProviderAllowed` finds no permission and 403s a member while the owner upgrades onto a dead socket,
|
||||
* but exactly the drift the guard is for. Deleting cliamp on 2026-08-15 removed the symptom and left the
|
||||
* shape.
|
||||
*
|
||||
* Now the routes are GENERATED from this, and this is what totality reads. A path added here without a
|
||||
* handler or without a registry claim fails the boot rather than serving quietly.
|
||||
*/
|
||||
const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
'/api/tasks/run/ws': 'task-runner',
|
||||
'/api/tasks/pipeline/ws': 'pipeline',
|
||||
'/api/terminal/ws': 'terminal',
|
||||
'/api/chat/ws': 'chat',
|
||||
'/api/chat/notify/ws': 'chat-notify',
|
||||
};
|
||||
|
||||
// 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'])];
|
||||
|
||||
/*
|
||||
Both doors into the platform, checked against the permission registry before either opens.
|
||||
|
||||
This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated cannot
|
||||
be reached even once. The HTTP half comes from hono.ts's own mount table. The socket half now comes from
|
||||
WS_ROUTES, which the route table is BUILT from, so the check reads what is actually served rather than a
|
||||
parallel list that agrees with it by hand.
|
||||
|
||||
A provider with a handler and no route is fine and is not checked: unreachable is the safe direction. The
|
||||
failure this catches is the other one — a route reachable with nothing claiming it.
|
||||
*/
|
||||
assertPermissionTotality({
|
||||
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
|
||||
wsProviders: Object.keys(handlers),
|
||||
wsProviders: SERVED_WS_PROVIDERS,
|
||||
});
|
||||
|
||||
// A route cannot name a provider that has no implementation. Cheap, and it turns a 500 on first connect
|
||||
// into a refusal to boot with the name in it.
|
||||
for (const [path, provider] of Object.entries(WS_ROUTES)) {
|
||||
if (!handlers[provider]) throw new Error(`${path} upgrades to '${provider}', which has no handler`);
|
||||
}
|
||||
|
||||
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
|
||||
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
|
||||
// module whose only job is a side effect is one an import sweep quietly deletes.
|
||||
@@ -312,11 +356,16 @@ console.log(
|
||||
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
|
||||
);
|
||||
|
||||
async function upgradeWs(
|
||||
req: Request,
|
||||
server: any,
|
||||
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline',
|
||||
) {
|
||||
/**
|
||||
* A provider reachable by upgrading a URL.
|
||||
*
|
||||
* Named rather than inline so `WS_ROUTES` is checked against it: a route naming a provider this function
|
||||
* 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';
|
||||
|
||||
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
@@ -441,11 +490,13 @@ const server = serve({
|
||||
});
|
||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||
},
|
||||
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
|
||||
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
|
||||
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
||||
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
|
||||
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'),
|
||||
// Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
|
||||
...Object.fromEntries(
|
||||
Object.entries(WS_ROUTES).map(([path, provider]) => [
|
||||
path,
|
||||
(req: Request, server: any) => upgradeWs(req, server, provider),
|
||||
]),
|
||||
),
|
||||
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
|
||||
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
|
||||
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
|
||||
|
||||
@@ -257,6 +257,16 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
offset: windowed ? start : 0,
|
||||
title: context?.title ?? null,
|
||||
partCount: context?.partCount ?? 1,
|
||||
/*
|
||||
Whether this conversation is a commander, derived from ITS OWN cwd rather than from the screen asking.
|
||||
|
||||
The composer offers "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and
|
||||
decided whether to by looking at the route it was rendered on. So the same commander, opened from
|
||||
/chat instead of Command HQ, silently lost the control that is the whole point of it. A commander is a
|
||||
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
|
||||
travels with the conversation.
|
||||
*/
|
||||
isCommander: isCommandersDir(who.email, detail.cwd),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Context } from 'hono';
|
||||
import { listThemeFavorites, setThemeFavorite } from 'officerdb';
|
||||
import { createRouter } from '../../create-router';
|
||||
|
||||
/*
|
||||
Starred themes, per account.
|
||||
|
||||
── Why this is not on `/themes` ──
|
||||
|
||||
Because that prefix is deliberately UNAUTHENTICATED: the sign-in screen renders in the chosen theme, so
|
||||
the list and the stylesheets have to be readable before anyone has an account. A per-account preference on
|
||||
an anonymous prefix has no caller to attribute it to — the first version of this lived there and answered
|
||||
500 to an anonymous GET, which was the honest consequence arriving early.
|
||||
|
||||
── Why the `account` permission ──
|
||||
|
||||
It is `core`, so every account holds it and it cannot be denied, and it already owns `/user`, `/dock` and
|
||||
`/api-keys` — preferences and credentials belonging to the caller themselves. A theme favourite is the
|
||||
same shape as a dock preference: it grants nothing, it is about this account's own eyes, and denying it
|
||||
would only mean an account that can choose a theme cannot remember which ones it liked.
|
||||
|
||||
Every route here reads `user.id` from the token. None of them names an account, so there is nothing to get
|
||||
wrong about whose stars these are.
|
||||
*/
|
||||
export const themeFavoritesRouter = createRouter();
|
||||
|
||||
// GET /theme-favorites — this account's starred theme ids.
|
||||
themeFavoritesRouter.get('/', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json({ favorites: await listThemeFavorites(user.id) });
|
||||
});
|
||||
|
||||
// PUT /theme-favorites/:id — star or unstar one.
|
||||
//
|
||||
// PUT rather than POST because it SETS a state rather than appending: sending `{favorite:true}` twice ends
|
||||
// where sending it once does, which is what a double-tap and two racing tabs both need. The store is
|
||||
// idempotent in both directions for the same reason.
|
||||
themeFavoritesRouter.put('/:id', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
const themeId = ctx.req.param('id');
|
||||
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const favorite = body.favorite !== false;
|
||||
return ctx.json({ themeId, favorite: await setThemeFavorite(user.id, themeId, favorite) });
|
||||
});
|
||||
@@ -5,6 +5,12 @@ import { discoverThemes, readThemeCss } from '../../themes/discover';
|
||||
/**
|
||||
* Installed themes — the ones in `platform/themes/`, not the ones the platform ships.
|
||||
*
|
||||
* **This prefix is UNAUTHENTICATED** (`UNPROTECTED_API_PREFIXES` in hono.ts) and must stay that way: the
|
||||
* sign-in screen paints in the chosen theme, before any account exists to ask. So nothing per-account can
|
||||
* live here — theme FAVOURITES are on `/theme-favorites`, behind the `account` permission, for exactly that
|
||||
* reason. Adding them here returned 500 on an anonymous request, which was the polite version of the
|
||||
* problem.
|
||||
*
|
||||
* The shipped themes are compiled into the bundle and need no server. These are read off disk at request
|
||||
* time, so dropping a directory in makes a theme appear on the next page load. No install step, no
|
||||
* rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { authRouter } from './api/auth';
|
||||
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||
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 { apiKeysRouter } from './api/api-keys/router';
|
||||
@@ -122,6 +123,8 @@ const isDavPath = (path: string) =>
|
||||
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
|
||||
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
|
||||
['/server-settings', serverSettingsRouter],
|
||||
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
|
||||
['/theme-favorites', themeFavoritesRouter],
|
||||
['/users', usersRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import type { EffectivePermissions } from './authorize';
|
||||
import { decideApiRequest, decideWsProvider, resolveEffectivePermissions } from './authorize';
|
||||
import type { PermissionLevel } from './registry';
|
||||
import { CORE_PERMISSIONS } from './registry';
|
||||
|
||||
/*
|
||||
The resolver, which its own header calls the file standing between a Member and a shell — and which had no
|
||||
automated tests at all until 2026-08-22.
|
||||
|
||||
It had none because every path needed two database calls. The decisions are now split from the fetching
|
||||
(`resolveEffectivePermissions`, `decideApiRequest`, `decideWsProvider` are pure), so this file needs no
|
||||
mock, no database and no `officerdb` stub — which matters beyond convenience: the one file in this
|
||||
repository that mocks `officerdb` leaks its stub into whatever runs after it.
|
||||
|
||||
REAL registry keys throughout, deliberately. A suite built on invented permissions would keep passing
|
||||
after a rename, which is the failure mode a test of an authorization table can least afford. If a key here
|
||||
disappears, this file should go red.
|
||||
*/
|
||||
|
||||
const member = (osUser: string | null = 'm1'): { role: UserRole; osUser: string | null } => ({
|
||||
role: 'Member',
|
||||
osUser,
|
||||
});
|
||||
const grants = (...pairs: Array<[string, PermissionLevel]>) => new Map<string, PermissionLevel>(pairs);
|
||||
const resolve = (user: { role: UserRole; osUser: string | null }, g = grants()) => resolveEffectivePermissions(user, g);
|
||||
|
||||
/** No account at all — an unauthenticated or deleted caller, as the async wrapper reports it. */
|
||||
const NOBODY: EffectivePermissions = { isOwner: false, grants: new Map() };
|
||||
|
||||
describe('resolving what an account holds', () => {
|
||||
test('the owner bypasses, and carries no grants to consult', () => {
|
||||
const owner = resolve({ role: 'Super Admin', osUser: null });
|
||||
expect(owner.isOwner).toBe(true);
|
||||
// Empty on purpose: an owner's grants are never read, and filling them would invite somebody to.
|
||||
expect(owner.grants.size).toBe(0);
|
||||
});
|
||||
|
||||
test('a member gets every core permission at write, granted nothing', () => {
|
||||
const held = resolve(member()).grants;
|
||||
for (const permission of CORE_PERMISSIONS) expect(held.get(permission.key)).toBe('write');
|
||||
});
|
||||
|
||||
test('core is write even though nothing granted it', () => {
|
||||
// A read-only "change your own password" is not a coherent thing to offer, which is why core is not
|
||||
// levelled. Pinned because a future levels-everywhere refactor would silently break it.
|
||||
expect(resolve(member()).grants.get('account')).toBe('write');
|
||||
});
|
||||
|
||||
test('an app grant is honoured at the level given', () => {
|
||||
expect(resolve(member(), grants(['calendar', 'read'])).grants.get('calendar')).toBe('read');
|
||||
expect(resolve(member(), grants(['calendar', 'write'])).grants.get('calendar')).toBe('write');
|
||||
});
|
||||
|
||||
// The rule that only exists because getOwnerHomeDir ignores its argument: a confined grant with no Linux
|
||||
// user to confine to would resolve to the OWNER's home.
|
||||
test('a confined grant needs a Linux account', () => {
|
||||
expect(resolve(member('m1'), grants(['files', 'write'])).grants.get('files')).toBe('write');
|
||||
expect(resolve(member(null), grants(['files', 'write'])).grants.has('files')).toBe(false);
|
||||
});
|
||||
|
||||
test('every confined permission is dropped together, not one by one', () => {
|
||||
const held = resolve(member(null), grants(['files', 'write'], ['terminal', 'write'], ['chat', 'write']));
|
||||
for (const key of ['files', 'terminal', 'chat']) expect(held.grants.has(key)).toBe(false);
|
||||
});
|
||||
|
||||
// The database has no CHECK stopping these rows; the API refuses to write one. This is the layer that
|
||||
// has to hold if one ever exists.
|
||||
test('an execution grant in the table is ignored', () => {
|
||||
expect(resolve(member(), grants(['tasks', 'write'])).grants.has('tasks')).toBe(false);
|
||||
});
|
||||
|
||||
test('an admin grant in the table is ignored', () => {
|
||||
expect(resolve(member(), grants(['user-admin', 'write'])).grants.has('user-admin')).toBe(false);
|
||||
expect(resolve(member(), grants(['server-admin', 'write'])).grants.has('server-admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('a grant naming a permission that no longer exists is ignored', () => {
|
||||
const held = resolve(member(), grants(['cliamp', 'write'], ['a-plugin-uninstalled-last-year', 'read']));
|
||||
expect(held.grants.has('cliamp')).toBe(false);
|
||||
expect(held.grants.has('a-plugin-uninstalled-last-year')).toBe(false);
|
||||
// …and does not disturb what is legitimately held.
|
||||
expect(held.grants.get('account')).toBe('write');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deciding an HTTP request', () => {
|
||||
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
||||
|
||||
test('the owner is allowed anything, including paths nothing serves', () => {
|
||||
expect(decideApiRequest(owner, 'DELETE', '/api/users/9').allowed).toBe(true);
|
||||
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this').allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('a path no permission covers is refused, not allowed by default', () => {
|
||||
const d = decideApiRequest(NOBODY, 'GET', '/api/nothing-serves-this');
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toContain('no permission covers this path');
|
||||
});
|
||||
|
||||
test('an execution permission is refused however it is granted', () => {
|
||||
// Refused by KIND, before any grant lookup — so a row granting it changes nothing.
|
||||
const forged = resolve(member(), grants(['tasks', 'write']));
|
||||
const d = decideApiRequest(forged, 'GET', '/api/tasks');
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toContain('runs as the server owner');
|
||||
});
|
||||
|
||||
test('an admin permission is refused', () => {
|
||||
const d = decideApiRequest(resolve(member()), 'GET', '/api/users');
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toContain('restricted to the server owner');
|
||||
});
|
||||
|
||||
// The one self-service route in the registry: updating your own profile lives on the router that also
|
||||
// holds account administration.
|
||||
test('a self-service route is allowed with no grant at all', () => {
|
||||
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users').allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('self-service is exact — neither the method nor the path may drift', () => {
|
||||
expect(decideApiRequest(resolve(member()), 'POST', '/api/users').allowed).toBe(false);
|
||||
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users/7').allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('no grant means no access, with the permission named', () => {
|
||||
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser');
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toContain('your role does not have access to');
|
||||
});
|
||||
|
||||
test('read grants reads and refuses writes', () => {
|
||||
const held = resolve(member('m1'), grants(['files', 'read']));
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
|
||||
const write = decideApiRequest(held, 'POST', '/api/file-browser');
|
||||
expect(write.allowed).toBe(false);
|
||||
expect(write.reason).toContain('read-only');
|
||||
});
|
||||
|
||||
test('write grants both', () => {
|
||||
const held = resolve(member('m1'), grants(['files', 'write']));
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
|
||||
expect(decideApiRequest(held, 'POST', '/api/file-browser').allowed).toBe(true);
|
||||
});
|
||||
|
||||
// A read that has to be a POST — the body is too big for a query string — must not need write.
|
||||
//
|
||||
// The path is `/api/file-browser/download`, not `/api/download`: readOnlyWrites entries are RELATIVE to
|
||||
// the permission's own api prefix. Worth a comment because the absolute form looks right, is what I wrote
|
||||
// first, and fails as "no permission covers this path" rather than as a level problem — a misleading
|
||||
// error for a misleading path.
|
||||
test('a readOnlyWrites path is allowed at read level', () => {
|
||||
const held = resolve(member('m1'), grants(['files', 'read']));
|
||||
expect(decideApiRequest(held, 'POST', '/api/file-browser/download').allowed).toBe(true);
|
||||
// And the same POST elsewhere under the prefix still needs write.
|
||||
expect(decideApiRequest(held, 'POST', '/api/file-browser/move').allowed).toBe(false);
|
||||
});
|
||||
|
||||
// The two rules composing: granted, but with no kernel boundary to be granted inside.
|
||||
test('a granted confined permission is still refused without a Linux account', () => {
|
||||
const held = resolve(member(null), grants(['files', 'write']));
|
||||
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('an unauthenticated caller is refused everything', () => {
|
||||
for (const path of ['/api/file-browser', '/api/chat', '/api/users', '/api/tasks']) {
|
||||
expect(decideApiRequest(NOBODY, 'GET', path).allowed).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deciding a websocket', () => {
|
||||
test('the owner opens anything', () => {
|
||||
expect(decideWsProvider({ isOwner: true, grants: new Map() }, 'terminal')).toBe(true);
|
||||
});
|
||||
|
||||
test('a provider no permission claims is refused', () => {
|
||||
expect(decideWsProvider(resolve(member()), 'not-a-provider')).toBe(false);
|
||||
});
|
||||
|
||||
test('a confined socket needs the grant AND the Linux account', () => {
|
||||
expect(decideWsProvider(resolve(member('m1'), grants(['terminal', 'read'])), 'terminal')).toBe(true);
|
||||
expect(decideWsProvider(resolve(member(null), grants(['terminal', 'read'])), 'terminal')).toBe(false);
|
||||
});
|
||||
|
||||
// No method to reason about, so any level opens it. Pinned because "read-only socket" is a tempting idea
|
||||
// that would silently close terminals granted at read.
|
||||
test('read is enough — a socket has no method', () => {
|
||||
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat')).toBe(true);
|
||||
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat-notify')).toBe(true);
|
||||
});
|
||||
|
||||
test("an execution permission's socket is refused however it is granted", () => {
|
||||
const forged = resolve(member('m1'), grants(['tasks', 'write']));
|
||||
expect(decideWsProvider(forged, 'task-runner')).toBe(false);
|
||||
expect(decideWsProvider(forged, 'pipeline')).toBe(false);
|
||||
});
|
||||
|
||||
test('an unauthenticated caller opens nothing', () => {
|
||||
for (const p of ['terminal', 'chat', 'task-runner', 'sidecar']) expect(decideWsProvider(NOBODY, p)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
NOT covered here, and stated rather than left as a gap somebody has to notice:
|
||||
|
||||
- `isOwnerOnlyRoute`. No permission in the registry declares `ownerOnly` yet — the field was added on
|
||||
2026-08-21 for plugin settings routes and has no user. It is checked before self-service and before
|
||||
kind, which is the part worth a test, and it cannot get one until a real permission uses it. Covering it
|
||||
with an invented permission would test this file's fixtures rather than the registry.
|
||||
- The grant cache and `invalidateRoleGrants`. They live on the async side, around a database call, which
|
||||
is exactly the half this split moved away from. Their contract — the one writer in
|
||||
`api/users/permissions-routes.ts` calls the invalidator — is a claim about call sites, not about logic,
|
||||
and a test here could only re-state it.
|
||||
*/
|
||||
@@ -65,6 +65,57 @@ async function grantsForRole(role: UserRole): Promise<Map<string, PermissionLeve
|
||||
* write such a row, but this is the layer that has to hold if one ever exists — a constraint the database
|
||||
* does not enforce is a constraint the reader must.
|
||||
*/
|
||||
/**
|
||||
* The resolution itself, with no database in it.
|
||||
*
|
||||
* Split out on 2026-08-22 so it can be tested. `authorize.ts` is described in its own header as the file
|
||||
* standing between a Member and a shell, and it had no automated tests at all — because every path through
|
||||
* it needed two database calls, and the only file in this repository that mocks `officerdb` has a stub that
|
||||
* leaks into whatever runs after it. Pulling the decision away from the fetching removes the need for a
|
||||
* mock rather than working around one: everything below this line is a pure function of two arguments.
|
||||
*
|
||||
* The async wrapper keeps the caching, the error handling and the single-question shape callers rely on.
|
||||
*/
|
||||
export function resolveEffectivePermissions(
|
||||
user: { role: UserRole; osUser: string | null },
|
||||
roleGrants: Map<string, PermissionLevel>,
|
||||
): EffectivePermissions {
|
||||
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
|
||||
|
||||
const grants = new Map<string, PermissionLevel>();
|
||||
for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
|
||||
|
||||
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
|
||||
// without it — see below.
|
||||
const hasOsAccount = !!user.osUser;
|
||||
|
||||
for (const [key, level] of roleGrants) {
|
||||
const permission = PERMISSION_BY_KEY.get(key);
|
||||
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
|
||||
// alternative is honouring a name nothing defines.
|
||||
if (!permission) continue;
|
||||
|
||||
// A confined permission touches the filesystem or runs a process, and is safe only because the
|
||||
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
|
||||
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
|
||||
// `getOwnerHomeDir` ignores the email it is passed, always.
|
||||
//
|
||||
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
|
||||
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
|
||||
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
|
||||
if (permission.kind === 'confined') {
|
||||
if (!hasOsAccount) continue;
|
||||
grants.set(key, level);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permission.kind !== 'app') continue;
|
||||
grants.set(key, level);
|
||||
}
|
||||
|
||||
return { isOwner: false, grants };
|
||||
}
|
||||
|
||||
export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> {
|
||||
const empty: EffectivePermissions = { isOwner: false, grants: new Map() };
|
||||
if (!userId) return empty;
|
||||
@@ -73,39 +124,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
|
||||
const user = await getUserById(userId);
|
||||
if (!user) return empty;
|
||||
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
|
||||
|
||||
const grants = new Map<string, PermissionLevel>();
|
||||
for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
|
||||
|
||||
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
|
||||
// without it — see below.
|
||||
const hasOsAccount = !!user.osUser;
|
||||
|
||||
for (const [key, level] of await grantsForRole(user.role)) {
|
||||
const permission = PERMISSION_BY_KEY.get(key);
|
||||
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
|
||||
// alternative is honouring a name nothing defines.
|
||||
if (!permission) continue;
|
||||
|
||||
// A confined permission touches the filesystem or runs a process, and is safe only because the
|
||||
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
|
||||
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
|
||||
// `getOwnerHomeDir` ignores the email it is passed, always.
|
||||
//
|
||||
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
|
||||
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
|
||||
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
|
||||
if (permission.kind === 'confined') {
|
||||
if (!hasOsAccount) continue;
|
||||
grants.set(key, level);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permission.kind !== 'app') continue;
|
||||
grants.set(key, level);
|
||||
}
|
||||
|
||||
return { isOwner: false, grants };
|
||||
return resolveEffectivePermissions(user, await grantsForRole(user.role));
|
||||
} catch {
|
||||
// A transient database error must never grant anything. Deny, and let the next request retry.
|
||||
return empty;
|
||||
@@ -123,7 +142,16 @@ export async function isApiRequestAllowed(
|
||||
method: string,
|
||||
path: string,
|
||||
): Promise<{ allowed: boolean; reason?: string }> {
|
||||
const { isOwner, grants } = await getEffectivePermissions(userId);
|
||||
return decideApiRequest(await getEffectivePermissions(userId), method, path);
|
||||
}
|
||||
|
||||
/** The decision, given an already-resolved account. Pure, and where every rule below actually lives. */
|
||||
export function decideApiRequest(
|
||||
effective: EffectivePermissions,
|
||||
method: string,
|
||||
path: string,
|
||||
): { allowed: boolean; reason?: string } {
|
||||
const { isOwner, grants } = effective;
|
||||
if (isOwner) return { allowed: true };
|
||||
|
||||
const permission = permissionForApiPath(path);
|
||||
@@ -170,7 +198,12 @@ export async function isApiRequestAllowed(
|
||||
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
|
||||
*/
|
||||
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
|
||||
const { isOwner, grants } = await getEffectivePermissions(userId);
|
||||
return decideWsProvider(await getEffectivePermissions(userId), provider);
|
||||
}
|
||||
|
||||
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
|
||||
export function decideWsProvider(effective: EffectivePermissions, provider: string): boolean {
|
||||
const { isOwner, grants } = effective;
|
||||
if (isOwner) return true;
|
||||
|
||||
const permission = permissionForWsProvider(provider);
|
||||
|
||||
@@ -147,7 +147,10 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// to present the authority the account already has, so denying it would only force the holder to keep
|
||||
// using a password in places a password should not go. What a key can then DO is decided by the same
|
||||
// permission checks as any other request from that user; nothing here widens them.
|
||||
api: ['/user', '/dock', '/api-keys'],
|
||||
// `/theme-favorites` sits here rather than on `/themes`, which is unauthenticated so the sign-in screen
|
||||
// can paint. A starred theme is the same shape as a dock preference: the caller's own taste, granting
|
||||
// nothing, and not a sensible thing to deny an account that can already choose a theme.
|
||||
api: ['/user', '/dock', '/api-keys', '/theme-favorites'],
|
||||
routes: ['/settings/profile'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,6 +19,8 @@ type ResumeState = {
|
||||
messages: ChatMessage[];
|
||||
total: number;
|
||||
offset: number;
|
||||
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
|
||||
isCommander: boolean;
|
||||
};
|
||||
|
||||
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
|
||||
@@ -104,6 +106,9 @@ const ChatPanelInner = ({
|
||||
promptPrefix={promptPrefix}
|
||||
panelId={panelId}
|
||||
sessionId={sessionKey ?? undefined}
|
||||
// From the SESSION, so a commander opened here gets a commander's composer. `chatContext` may also
|
||||
// set it via `context`; either being true is enough — see `manager.isCommander`.
|
||||
isCommander={resume?.isCommander === true}
|
||||
{...chatContext}
|
||||
/>
|
||||
</div>
|
||||
@@ -221,6 +226,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
||||
messages: detail.messages as unknown as ChatMessage[],
|
||||
total: detail.total,
|
||||
offset: detail.offset,
|
||||
isCommander: detail.isCommander === true,
|
||||
});
|
||||
} catch {
|
||||
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
|
||||
|
||||
@@ -23,6 +23,14 @@ type EmbeddableChatProps = {
|
||||
chat?: UseChatType;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/**
|
||||
* This SESSION is a commander, as reported by `GET /chat/sessions/:id`.
|
||||
*
|
||||
* Separate from `context`, which says which SCREEN is rendering. Both can make the composer offer the
|
||||
* handoff watcher and only this one is true of the conversation wherever it is opened — which is the bug:
|
||||
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
onMessageComplete?: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ type UseEmbeddableChatParams = {
|
||||
chat?: UseChatType;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
|
||||
isCommander?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -611,6 +613,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
// place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the
|
||||
// composer, so the option was never rendered anywhere at all.
|
||||
context: params.context,
|
||||
/*
|
||||
One answer to "is this a commander", from either of the two things that can know.
|
||||
|
||||
The SCREEN knows when Command HQ starts one, before the session exists and before any route could
|
||||
report on it. The SESSION knows once it has a transcript, wherever it is opened. Neither alone is
|
||||
enough: screen-only was the original bug (a commander in /chat lost its controls) and session-only
|
||||
would lose the brand-new composer, which has no session yet.
|
||||
*/
|
||||
isCommander: params.context === 'command-hq' || params.isCommander === true,
|
||||
messages,
|
||||
historicalCount,
|
||||
streamingText,
|
||||
|
||||
@@ -199,12 +199,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
`handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a
|
||||
promise the platform has to keep rather than only a sentence in the prompt.
|
||||
|
||||
Read from the MANAGER rather than recomputed from the route, which is what it was: a commander
|
||||
opened from /chat reported `context: 'chat'` and lost the option, having gained it two hours
|
||||
earlier. `manager.isCommander` is true when either the screen or the session says so.
|
||||
|
||||
This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the
|
||||
option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the
|
||||
default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the
|
||||
server. Only the radio button was missing, so the feature looked unbuilt.
|
||||
*/
|
||||
isCommander={manager.context === 'command-hq'}
|
||||
isCommander={manager.isCommander}
|
||||
onSubmit={(intent, preamble, armWatcher) => {
|
||||
// Claimed by the next job to start, which is how the preview tells this one apart from the
|
||||
// background jobs an agent starts on its own.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Plus, Radar } from 'lucide-react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||
@@ -55,23 +55,21 @@ export const CommandHqCategories = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Radar className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Command HQ</div>
|
||||
<div className="truncate text-xs text-muted-foreground">Watchers & long-running jobs</div>
|
||||
</div>
|
||||
</div>
|
||||
/*
|
||||
One scrollable list, and a heading that does not scroll with it.
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
|
||||
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
|
||||
</nav>
|
||||
This was a nav COLUMN with the screen's branding at the top — a 9px Radar tile, "Command HQ", and
|
||||
"Watchers & long-running jobs" — costing about seventy pixels. All three are gone: the panel now sits
|
||||
at the FOOT of the left column rather than owning it, so the vertical room is the whole budget, and the
|
||||
screen's own header already says Command HQ two inches above. Repeating it in a filter panel was
|
||||
branding where a list should be.
|
||||
|
||||
<div className="mt-2 flex items-center justify-between px-4 pb-1">
|
||||
`All commanders` and `Uncategorised` are inside the scroll box with the categories rather than in a
|
||||
separate nav above it. They are the same kind of thing — a filter over the list — and splitting them
|
||||
into two nav blocks made the second look like a different mechanism.
|
||||
*/
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/30">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -83,7 +81,9 @@ export const CommandHqCategories = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
|
||||
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
|
||||
{categories.map((category) => (
|
||||
<Row
|
||||
key={category.id}
|
||||
|
||||
@@ -140,7 +140,16 @@ export const CommandHqGrid = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{/*
|
||||
One per line, full width — a grid of one.
|
||||
|
||||
It was `sm:grid-cols-2 xl:grid-cols-3`, which was reasonable when this panel owned 34% of the screen
|
||||
and is not now that it owns part of a 24% column: two columns of card inside it left each one too
|
||||
narrow for a title, so every card truncated and the cost line wrapped. Nothing about the card changed
|
||||
— same rounded pill, same `p-4`, same `gap-3` between them — it simply stops being asked to share a
|
||||
row.
|
||||
*/}
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{commanders.map((commander) => (
|
||||
<Card
|
||||
key={commander.id}
|
||||
|
||||
@@ -96,6 +96,14 @@ export type ClaudeSessionDetail = {
|
||||
title?: string | null;
|
||||
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
|
||||
partCount?: number;
|
||||
/**
|
||||
* A commander rather than an ordinary conversation, derived server-side from this session's own cwd.
|
||||
*
|
||||
* It travels with the SESSION and not with the screen, which is the whole point. The composer decides
|
||||
* whether to offer "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and used
|
||||
* to decide it from the route, so the same commander opened in /chat quietly lost it.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
};
|
||||
|
||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||
|
||||
@@ -9,3 +9,5 @@ export { useThemeAsset } from './useThemeAsset';
|
||||
export { useThemeKeys } from './useThemeKeys';
|
||||
export { THEMES, DEFAULT_THEME, findTheme, isKnownTheme, isDarkTheme, themeFromLegacyColorMode } from './themes';
|
||||
export type { Theme, ThemeId } from './themes';
|
||||
// Ordering helpers for the pickers — pure, and tested in rotate.test.ts.
|
||||
export { rotateToSelected, favouritesFirst, groupByFamily } from './rotate';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { favouritesFirst, groupByFamily, rotateToSelected } from './rotate';
|
||||
|
||||
const ids = (list: Array<{ id: string }>) => list.map((t) => t.id);
|
||||
const list = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }];
|
||||
|
||||
describe('rotating to the selected theme', () => {
|
||||
test('puts the selected one first and wraps the rest behind it', () => {
|
||||
expect(ids(rotateToSelected(list, 'c'))).toEqual(['c', 'd', 'a', 'b']);
|
||||
});
|
||||
|
||||
// The property that makes this a rotation rather than a sort: nobody loses a neighbour.
|
||||
test('every adjacency survives, only the starting point moves', () => {
|
||||
const rotated = ids(rotateToSelected(list, 'c'));
|
||||
const doubled = [...ids(list), ...ids(list)].join(',');
|
||||
expect(doubled).toContain(rotated.join(','));
|
||||
});
|
||||
|
||||
test('leaves the order alone when the selected one is already first', () => {
|
||||
expect(ids(rotateToSelected(list, 'a'))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
// An id from an uninstalled theme, or none chosen yet. Rotating by a guess would be worse than not.
|
||||
test('leaves the order alone for an unknown or missing id', () => {
|
||||
expect(ids(rotateToSelected(list, 'gone'))).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(ids(rotateToSelected(list, undefined))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
test('does not mutate the input', () => {
|
||||
const original = [...ids(list)];
|
||||
rotateToSelected(list, 'd');
|
||||
expect(ids(list)).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('favourites first', () => {
|
||||
test('lifts favourites, keeping the order within each half', () => {
|
||||
expect(ids(favouritesFirst(list, new Set(['d', 'b'])))).toEqual(['b', 'd', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('changes nothing when there are none', () => {
|
||||
expect(ids(favouritesFirst(list, new Set()))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
// Composed with the rotation: the theme you are wearing leads its half.
|
||||
test('composes with the rotation', () => {
|
||||
expect(ids(favouritesFirst(rotateToSelected(list, 'c'), new Set(['c', 'a'])))).toEqual(['c', 'a', 'd', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouping by family', () => {
|
||||
const themed = [
|
||||
{ id: 'duck-dark', name: 'Duck Dark', family: 'Duck' },
|
||||
{ id: 'cat', name: 'Catppuccin', family: 'Catppuccin' },
|
||||
{ id: 'duck-light', name: 'Duck Light', family: 'Duck' },
|
||||
] as never as Parameters<typeof groupByFamily>[0];
|
||||
|
||||
test('keeps families in first-seen order, so a rotation decides them', () => {
|
||||
expect(groupByFamily(themed).map(([family]) => family)).toEqual(['Duck', 'Catppuccin']);
|
||||
});
|
||||
|
||||
// Members of a family separated by the rotation still collect together — the alternative is the same
|
||||
// family heading appearing twice.
|
||||
test('collects a family split by the rotation', () => {
|
||||
const groups = groupByFamily(themed);
|
||||
expect(groups[0]![1].map((t) => t.id)).toEqual(['duck-dark', 'duck-light']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Theme, ThemeId } from './themes';
|
||||
|
||||
/**
|
||||
* The theme list, rotated so the one you are wearing is first.
|
||||
*
|
||||
* With 200+ themes the list is far taller than any container, and the theme you have on is the anchor for
|
||||
* every decision you are about to make — "what did I just come from", "what is next to this". Sorting the
|
||||
* selected one to the top would answer the first question and destroy the second, because it takes that
|
||||
* theme out of its family and leaves a hole where it was.
|
||||
*
|
||||
* A ROTATION keeps every neighbour: the order is unchanged, the starting point moves. Duck Dark's
|
||||
* neighbours are still Duck Light and whatever followed it; they have simply wrapped around behind you.
|
||||
*
|
||||
* The registry order is the ordering to preserve — families sit together in it, and that grouping is the
|
||||
* only structure a list of two hundred names has.
|
||||
*/
|
||||
export function rotateToSelected<T extends { id: ThemeId }>(list: readonly T[], selected: ThemeId | undefined): T[] {
|
||||
if (!selected) return [...list];
|
||||
const at = list.findIndex((entry) => entry.id === selected);
|
||||
// Not present: an id from a theme that has been uninstalled, or none set yet. Leave the order alone
|
||||
// rather than rotate by a guess.
|
||||
if (at <= 0) return [...list];
|
||||
return [...list.slice(at), ...list.slice(0, at)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Favourites first, then everything else — each half keeping the rotation it was given.
|
||||
*
|
||||
* Two-tier rather than a filter, because hiding the rest makes the list unable to answer "what else is
|
||||
* there". A filter is a separate, explicit act; this is the default shape.
|
||||
*/
|
||||
export function favouritesFirst<T extends { id: ThemeId }>(list: readonly T[], favourites: ReadonlySet<ThemeId>): T[] {
|
||||
if (favourites.size === 0) return [...list];
|
||||
const starred: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const entry of list) (favourites.has(entry.id) ? starred : rest).push(entry);
|
||||
return [...starred, ...rest];
|
||||
}
|
||||
|
||||
/** Grouped by family for the dropdown, over an already-ordered list. Families appear in first-seen order. */
|
||||
export function groupByFamily(list: readonly Theme[]): Array<[string, Theme[]]> {
|
||||
const groups = new Map<string, Theme[]>();
|
||||
for (const theme of list) {
|
||||
const existing = groups.get(theme.family);
|
||||
if (existing) existing.push(theme);
|
||||
else groups.set(theme.family, [theme]);
|
||||
}
|
||||
return [...groups.entries()];
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { ThemeId } from './themes';
|
||||
* ctrl+shift+→ next theme, from anywhere, no UI
|
||||
* ctrl+shift+← previous theme
|
||||
* ctrl+shift+space open the list, then browse it
|
||||
* ctrl+shift+f star or unstar the current theme, from anywhere
|
||||
*
|
||||
* One modifier for all three, which took some choosing. `ctrl+alt+arrows` switches WORKSPACE on GNOME and
|
||||
* `ctrl+alt+l` locks the screen, so ctrl+alt is unusable for this. `shift+alt` is free on Linux but is the
|
||||
@@ -20,6 +21,8 @@ import type { ThemeId } from './themes';
|
||||
* action invited the guess that `ctrl+shift+j` moved down — which opens the browser console instead. One
|
||||
* spelling, no guessing.
|
||||
*
|
||||
* Inside the list `f` stars or unstars what is FOCUSED, so a browse and a shortlist are one pass.
|
||||
*
|
||||
* Inside the list the vim keys stay, UNMODIFIED — `j`/`k`/`l`/`h` alongside the arrows. No modifier means
|
||||
* no collision with anything the browser owns, and this is a vim person's application.
|
||||
*
|
||||
@@ -38,8 +41,22 @@ import type { ThemeId } from './themes';
|
||||
* This hook owns the keys and the selection; something else draws the list. It lives in the theme package
|
||||
* because the behaviour belongs to themes, while a dialog would drag a component library in behind it.
|
||||
*/
|
||||
export function useThemeKeys() {
|
||||
type ThemeKeysOptions = {
|
||||
/**
|
||||
* Star or unstar the focused theme — bound to `f` while the list is open.
|
||||
*
|
||||
* Passed in rather than owned here: favourites are per ACCOUNT and live behind an API, which is app
|
||||
* knowledge. This hook owns keys and selection and nothing else.
|
||||
*/
|
||||
onToggleFavorite?: (themeId: ThemeId) => void;
|
||||
};
|
||||
|
||||
export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
const { themeId, setTheme, themes } = useTheme();
|
||||
// A ref so the window listener never needs re-binding when the callback identity changes — which it does
|
||||
// on every render, since it closes over a mutation.
|
||||
const onToggleFavorite = useRef(options.onToggleFavorite);
|
||||
onToggleFavorite.current = options.onToggleFavorite;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focused, setFocused] = useState<ThemeId>(themeId);
|
||||
const startedOn = useRef<ThemeId>(themeId);
|
||||
@@ -61,10 +78,24 @@ export function useThemeKeys() {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const { themeId: current, focused: focus, open: isOpen } = state.current;
|
||||
|
||||
// Toggle the list. `ctrl+alt+space` still works — it is what this shipped with an hour ago and
|
||||
// fingers are quicker to learn than to unlearn.
|
||||
// Toggle the list. `ctrl+alt+space` is still accepted alongside `ctrl+shift+space` — it is what this
|
||||
// shipped with first, and fingers are quicker to learn than to unlearn.
|
||||
if (event.ctrlKey && (event.shiftKey || event.altKey) && event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
/*
|
||||
Two problems solved by two lines, and both were reported from the same keystroke.
|
||||
|
||||
`stopPropagation` stops the header's dropdown ALSO opening. The theme pill is a Radix
|
||||
DropdownMenuTrigger, and a focused trigger opens on Space — so if the pill happened to have focus,
|
||||
the chord opened the overlay and the menu behind it. React attaches its handlers at the root, which
|
||||
bubbles before window, so the trigger saw the key first no matter what this listener did with it.
|
||||
This listener now runs in the CAPTURE phase and stops the event dead.
|
||||
|
||||
The blur stops the overlay opening with focus still in a text field, which made j/k/l do nothing:
|
||||
the guard below correctly refuses to steal keys from an input, and the input never lost focus.
|
||||
*/
|
||||
event.stopPropagation();
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
setOpen((was) => {
|
||||
if (!was) {
|
||||
startedOn.current = current;
|
||||
@@ -83,6 +114,22 @@ export function useThemeKeys() {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const typing = Boolean(target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName)));
|
||||
|
||||
/*
|
||||
Star or unstar the CURRENT theme, from anywhere, list open or not.
|
||||
|
||||
Not behind the `typing` guard, unlike the arrows beside it, and the difference is the point: word
|
||||
selection is what `ctrl+shift+←/→` collides with inside a text field, and `ctrl+shift+F` collides
|
||||
with nothing — it types no character and no browser claims it. Guarding it would only mean the
|
||||
binding fails in the composer, which is where you are when a theme annoys you.
|
||||
*/
|
||||
if (event.ctrlKey && event.shiftKey && (event.key === 'f' || event.key === 'F')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// The one you are WEARING, not the one focused in the list — this fires with no list open.
|
||||
onToggleFavorite.current?.(current);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle from anywhere, list open or not. Arrows only — see the note above.
|
||||
if (event.ctrlKey && event.shiftKey && !typing) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
@@ -97,9 +144,18 @@ export function useThemeKeys() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen || typing) return;
|
||||
// Only the GLOBAL bindings above defer to a text field. Once the list is open there is no input in
|
||||
// it, focus was blurred to open it, and a `typing` check here is what made every vim key dead when
|
||||
// the overlay was opened from the composer.
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'f':
|
||||
case 'F':
|
||||
// Star or unstar what is focused, without moving or applying it.
|
||||
event.preventDefault();
|
||||
onToggleFavorite.current?.(focus);
|
||||
return;
|
||||
case 'j':
|
||||
case 'J':
|
||||
case 'ArrowDown':
|
||||
@@ -140,8 +196,10 @@ export function useThemeKeys() {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
// CAPTURE, so the chord is claimed before React's root-level handlers see it — see the note above about
|
||||
// the dropdown trigger opening alongside the overlay.
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [setTheme, step]);
|
||||
|
||||
return { open, setOpen, focused, setFocused };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: known-bugs/a-commander-opened-from-chat-loses-its-context
|
||||
title: 'A commander opened from /chat loses its commander controls'
|
||||
theme: known-bugs
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 4
|
||||
@@ -28,3 +28,16 @@ closes this and makes the same thing work in any future surface that renders a c
|
||||
|
||||
Related: the same `context` name means two different things either side of `EmbeddableChat` — a `useChat`
|
||||
option and a component prop, neither reading the other's — which is what hid the original bug twice.
|
||||
|
||||
---
|
||||
|
||||
**Fixed 2026-08-22.** `GET /chat/sessions/:id` now reports `isCommander`, derived from the session's own cwd
|
||||
by the same `isCommandersDir` the list and `/chat/live` already used — so the answer travels with the
|
||||
conversation instead of with the screen showing it.
|
||||
|
||||
The composer reads ONE value, `manager.isCommander`, true when either source says so. Both are needed and
|
||||
neither is sufficient: the SCREEN knows when Command HQ starts a commander, before a session or a transcript
|
||||
exists for any route to report on, and the SESSION knows once it does, wherever it is opened. Screen-only
|
||||
was this bug; session-only would have lost the brand-new composer.
|
||||
|
||||
Verified on a real commander: `172cdd8c` resolves `cwd=…/commanders` and `isCommander=true`.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: multi-user/assertpermissiontotality-checks-the-wrong
|
||||
title: "`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13"
|
||||
title: '`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13'
|
||||
theme: multi-user
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-13
|
||||
date_source: stated
|
||||
order: 12
|
||||
@@ -17,3 +17,27 @@ a dead socket. But the boot check that exists to stop exactly this cannot see it
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
---
|
||||
|
||||
**Fixed 2026-08-22.** Half of this item was already stale and half was still exactly true, which is worth
|
||||
separating because the stale half is what would have made someone close it.
|
||||
|
||||
Stale: the eight red tests in `registry.test.ts` are green, and have been since cliamp was deleted on
|
||||
2026-08-15 — that removed the live instance of the drift along with the routes.
|
||||
|
||||
Still true: the two lists were still two lists. `assertPermissionTotality` read `Object.keys(handlers)`, the
|
||||
map of implementations, while Bun served a hand-written route table twenty lines away. The comment beside
|
||||
the check claimed neither could be a stale copy of the other, and that was simply false.
|
||||
|
||||
The fix is one declaration. `WS_ROUTES` maps path → provider, the Bun route table is GENERATED from it, and
|
||||
the boot check reads it — so a socket route cannot exist without being checked. Two smaller guards came with
|
||||
it: a route naming a provider with no handler now refuses to boot with the path in the message, and the
|
||||
provider union that was inline in `upgradeWs` is named (`UpgradableProvider`) so the route table typechecks
|
||||
against what that function can actually serve.
|
||||
|
||||
`sidecar` and `dev-proxy` are listed explicitly as served-but-not-in-WS_ROUTES, because they upgrade through
|
||||
their own handlers. Named rather than omitted, so their absence cannot read as an oversight.
|
||||
|
||||
Verified by running the check against the real prefix lists: the served set passes, and adding one unclaimed
|
||||
provider is refused with `websocket provider 'cliamp-audio' is served but no permission claims it`.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: multi-user/permissions-authorize-ts-has-no-automated
|
||||
title: "`permissions/authorize.ts` has no automated tests"
|
||||
title: '`permissions/authorize.ts` has no automated tests'
|
||||
theme: multi-user
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-20
|
||||
date_source: assumed
|
||||
order: 11
|
||||
@@ -12,3 +12,32 @@ order: 11
|
||||
registry functions and the totality check; the resolver that does the owner bypass, the grant
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
---
|
||||
|
||||
**Done 2026-08-22. 27 tests, no mock and no database.**
|
||||
|
||||
The reason it had none was structural: every path through the resolver needed two database calls, and the
|
||||
only file in this repository that mocks `officerdb` leaks its stub into whatever runs after it — so the
|
||||
obvious approach would have traded one untested file for a flaky suite.
|
||||
|
||||
So the decisions were split from the fetching. `resolveEffectivePermissions(user, roleGrants)`,
|
||||
`decideApiRequest(effective, method, path)` and `decideWsProvider(effective, provider)` are pure; the async
|
||||
functions keep the caching, the fail-closed catches and the shape callers use. The tests use REAL registry
|
||||
keys throughout, deliberately — a suite built on invented permissions keeps passing after a rename, which is
|
||||
the failure an authorization table can least afford.
|
||||
|
||||
What is pinned, beyond the happy paths: the owner carries no grants to consult; core is `write`
|
||||
unconditionally; a confined grant is dropped without a Linux user, and all three confined permissions drop
|
||||
together; execution and admin grants in the table are ignored even though no CHECK stops the row existing;
|
||||
an unknown key is ignored without disturbing what is legitimately held; self-service is exact in both method
|
||||
and path; `readOnlyWrites` is relative to the permission's prefix; a socket needs no level; and an
|
||||
unauthenticated caller is refused every door.
|
||||
|
||||
Two things deliberately NOT covered, and said so in the file rather than left to be noticed: `ownerOnly`,
|
||||
because no permission declares it yet and covering it would test the fixtures rather than the registry; and
|
||||
the grant cache, whose contract is a claim about call sites rather than logic.
|
||||
|
||||
Found while writing them: `readOnlyWrites` paths are relative to the permission's api prefix, and getting
|
||||
that wrong fails as "no permission covers this path" rather than as a level error — a misleading message for
|
||||
a misleading path.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
id: shell/all-keyboard-shortcuts-configurable
|
||||
title: "All keyboard shortcuts configurable"
|
||||
theme: shell
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 3
|
||||
---
|
||||
|
||||
**All keyboard shortcuts configurable.** Stated 2026-08-22 as coming soon. Every binding in the app
|
||||
should be something the owner sets, not something the code decides.
|
||||
|
||||
**There is nothing central to configure today.** Nine separate `keydown` listeners are registered across
|
||||
the app — the code editor, the file browser and its grid and dialogs, the terminal, the chat launcher,
|
||||
the embeddable chat, the file viewer's editor, and Spotlight — each owning its own keys, in its own
|
||||
component, with no registry and no way to see the set at once. So the first half of this is an
|
||||
inventory, and the second is a registry the bindings are read FROM rather than written INTO the
|
||||
components.
|
||||
|
||||
Spotlight is the cautionary tale and it is already written down in `Spotlight.tsx:14`: the launcher went
|
||||
`ctrl-space` → `shift-space` → `alt-space`, three changes in a day, because each chord turned out to
|
||||
collide with something — `shift-space` types a space, which is why that version needed a focus check
|
||||
that `alt-space` does not. **A hard-coded chord costs a code change and a release every time it is
|
||||
wrong**, which is the argument for this item in one sentence.
|
||||
|
||||
Worth deciding with the config format: a binding that conflicts with another must be refused or flagged
|
||||
at the moment it is set, since the failure otherwise is silent and shows up as "that key does nothing".
|
||||
And a browser cannot claim every chord — some are the browser's or the window manager's — so the UI has
|
||||
to be able to say a binding was rejected by something above it.
|
||||
|
||||
Storage lands on `todo/settings/` — this is exactly the kind of preference that should be a portable
|
||||
file rather than a row.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: shell/ctrl-shows-every-keyboard-shortcut-in-a-modal
|
||||
title: "Ctrl+? shows every keyboard shortcut in a modal"
|
||||
theme: shell
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 4
|
||||
---
|
||||
|
||||
**Ctrl+? shows every keyboard shortcut in a modal.** Stated 2026-08-22, alongside making the bindings
|
||||
configurable. One chord, and the whole set is on screen.
|
||||
|
||||
It is the same work seen from the other end, and the order matters: **the modal is trivial once a
|
||||
registry exists and impossible to keep honest before that.** Today the bindings live in nine separate
|
||||
`keydown` listeners across the app, so a hand-written list of shortcuts would be a second copy of
|
||||
something nobody updates — a help screen that lies is worse than none. Build it as a render of the
|
||||
registry from `shell/all-keyboard-shortcuts-configurable`, never as its own list.
|
||||
|
||||
Two things to get right when it is built: the modal should show which bindings apply **here** versus
|
||||
everywhere, since most of the nine are scoped to one app or panel; and it is the natural place to
|
||||
surface a conflict or a chord the browser refused, because that is when someone is looking.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: shell/revamp-the-menus-toward-an-omarchy-like-shape
|
||||
title: "Revamp the menus — spotlight, themes — toward an Omarchy-like shape"
|
||||
theme: shell
|
||||
status: open
|
||||
created: 2026-08-22
|
||||
date_source: stated
|
||||
order: 5
|
||||
---
|
||||
|
||||
**Revamp the menus — spotlight, themes — toward an Omarchy-like shape.** Flagged 2026-08-22 as a plan
|
||||
the owner has and has deliberately not worked out yet: *"i have plans… i think some time soon we will
|
||||
take a more Omarchy like approach but i am too happy right now to think about that."*
|
||||
|
||||
**Recorded so it is not lost, not so someone can start it.** The shape is his and does not exist on
|
||||
paper yet — ask him before designing anything here.
|
||||
|
||||
What is already true and will be the raw material: Spotlight exists and opens on `alt-space`
|
||||
(`Spotlight.tsx`), the dock is full and cannot grow, and all 22 themes are generated from Omarchy's own
|
||||
palettes by `scripts/generate-themes.ts` — so the borrowing has already started on the colour side and
|
||||
this is the same borrowing extended to the menus. `shell/dock-effects-and-auto-hide` and the launcher
|
||||
item are both part of whatever this becomes rather than separate work.
|
||||
|
||||
Note the two keyboard items in this theme are probably prerequisites rather than neighbours: an
|
||||
Omarchy-like shell is keyboard-driven first, and that needs bindings to be configurable and discoverable
|
||||
before the menus are rebuilt around them.
|
||||
Reference in New Issue
Block a user