document the navigation conventions in claude.md

the url-as-source-of-truth rules from docs/navigation-audit.md were only in the
audit; anything new was as likely to reach for a selection channel as a link.
also records the workspace-barrel requirement for shared route helpers, and adds
officer-headscale to the pm2 list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:14:48 +00:00
co-authored by Claude Opus 5
parent 17522be71a
commit c39460ce2d
+38 -3
View File
@@ -25,7 +25,7 @@ Long-running and privileged work lives in **sidecars**: separate processes that
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`.
`officer-slskd`, `officer-headscale`.
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
@@ -205,14 +205,49 @@ Worked examples: `/soulseek`, `/music`, `/chat`.
that pins `appType`s to an allow-list. Panels are windowed apps under
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
Panels coordinate via `usePanelChannel`.
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
- **Page title by route.** Add a rule to `RULES` in `src/apps/officer-web/state/usePageTitle.ts`
(`{ match: (p) => p.startsWith('/<name>'), title: '<Name>' }`, most-specific first);
`usePageTitleSync` does the rest.
`usePageTitleSync` does the rest. `startsWith` means nested routes are covered.
### The URL is the source of truth for selection
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
link-focusable). Half the app still does this; none of the new code should.
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
`usePanelChannel` is for genuine signals and refresh buses (`preview:refresh`,
`files:refresh-signal`, `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`, `FILE_VIEWER_CHANNEL`).
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
*mutates* rather than navigates stays a `<button>`.
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` +
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
canonicalises both the bare route and a bogus param.
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
Action buttons are **siblings** of the anchor, never nested inside it.
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
when you want `isActive`.
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
## Further Reading
- `CONVENTIONS.md` — component organisation, state management, React patterns, with rationale
- `docs/navigation-audit.md`**authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
- `src/apps/CLAUDE.md` — shared frontend patterns
- `src/databases/CLAUDE.md` — database patterns