Files
platform/docs/workspace-panels.md
T
pastilhas fd923bb9be give the soulseek workspace a url
The section is /soulseek/:section — nav entries are NavLinks, the view panel reads the same
URL instead of being told, and the dashboard's tiles and recent searches are real links (a
recent search now opens that search, not the search screen's front page).

The peer went in `?user=<name>` rather than the /soulseek/users/:name the audit sketched: a
second path segment would need a nested route just to keep the nav highlight, and `?search=`
had already set the convention there. That deletes the `soulseek:user` channel and with it a
`{username, nonce}` request the Users panel consumed-once and cleared — the nonce existed so
asking for the same peer twice counted twice. A link is idempotent, so there is nothing to
consume and nothing to disambiguate.

`soulseek:refresh` stays: it is a signal, which is what channels are for.
2026-08-07 11:45:33 +00:00

200 lines
11 KiB
Markdown

# The Workspace/Panel framework — how it works
Orientation for anyone building a panel app or a dashboard route. This describes the framework **as it
is**, not as it should be; the defect list and the work queue live in `workspace-panel-todo.md`, and the
full reasoning behind them in `COMMS/workspace-panel-framework-analysis-2026-08-07.md`.
Where the framework is *going* — and why anything here is worth changing at all — is
**`agent-coordination.md`**: agents on one dashboard coordinating with each other instead of through
the human. Read that first if you are deciding what to build; read this one if you are deciding how.
Code: `src/workspaces/officerdev/src/components/Workspace/` — 12 files, ~1,700 lines, no tests.
---
## 1. The data model
One serialisable tree, persisted verbatim as Postgres `jsonb`:
```ts
type LayoutGroup = { type: 'group'; id: string; direction: 'horizontal' | 'vertical';
children: { node: LayoutNode; size: number }[] };
type LayoutPanel = { type: 'panel'; id: string; appType: string | null; zoom?: number; fitContent?: boolean };
type LayoutNode = LayoutGroup | LayoutPanel;
```
That is the whole model, and it is the good part — small, immutable, and transformed only by the pure
functions in `layout-utils.ts` (`splitPanel`, `removePanel`, `setApp`, `setZoom`, `updateSizes`,
`swapPanels`). Every mutator returns a new tree, or the identical reference when nothing changed.
A **group** is a resizable split. A **panel** is a leaf that names an app by string (`appType`).
## 2. The three layers
| layer | file | what it does |
|---|---|---|
| the public API | `WorkspaceView.tsx` | owns swap/drag/maximize/zoom state, wires mutators to `workspace.setValue`, builds the context |
| the recursion | `WorkspaceRenderer.tsx` | tree → `ResizablePanelGroup`; the mobile collapse; the 500 ms resize debounce |
| one panel | `PanelSlot.tsx` | chrome, header, traffic lights, zoom, context menus, maximize; **mounts the app** |
`WorkspaceLayout.tsx` is the read-only variant (no swap, no maximize, no persistence) used by the
dashboard-creation preview. `AppPicker.tsx` is the chip grid in an empty panel. `DragOverlay.tsx`,
`LayoutEditor.tsx` and `WorkspaceHeader.tsx` are currently unreachable.
A screen wires it up in four lines:
```tsx
const ws = useDashboardState<LayoutNode>('screens/music', defaultLayout);
return <WorkspaceView workspace={ws} locked cwd="~" />;
```
`locked` means the user cannot split, close or re-app panels — the screen's layout is the product. Every
route screen is locked; only user-created dashboards are not.
## 3. What a panel knows
A panel app is `ComponentType<{ panelId: string }>`. **`panelId` is the only prop it gets.** Everything
else arrives through one of four doors, and picking the right door is most of the design work:
1. **`useWorkspace()`** — ambient *environment*: `dashboardId`, `cwd`, `root`, `promptPrefix`, plus the
framework's own maximize/swap/zoom state. Read-mostly, set once per screen.
2. **react-router**`useParams` / `useSearchParams`. Panels render inside the Route element tree, so
they can read the URL directly. **This is where selection belongs.** See §4.
3. **`useDashboardState(key, default)`** — durable, per-user, server-persisted state. See §5.
4. **`usePanelChannel(name, initial)`** — the cross-panel bus. See §4.
`appType` → component resolution happens at `PanelSlot.tsx`, against a global registry seeded by
`<AppRegistry />` at mount: each app folder exports `appRegistryMetas`, and `metasToRegistry` flattens
them into one `Record<appType, entry>`. Registry entry fields:
| field | effect |
|---|---|
| `name`, `icon` | label in the picker and the default header |
| `component` | the body, gets `panelId` |
| `header?` | replaces the default icon+name header, gets `panelId` |
| `provider?` | wraps header **and** body, so the header can read the provider's context |
| `transparent?` | skips all chrome — no card, no header, no zoom wrapper |
| `availableOnPanel?` | `false` hides it from the picker. **Not access control** — a saved layout naming it still mounts it |
| `zoomable?` | `false` opts out of per-panel CSS `zoom` (the xterm apps and Remote Desktop, whose geometry is negotiated with a server) |
Two escape hatches exist for components that cannot be in the global registry:
- **`components` prop** — `Record<panelId, Component>`, keyed by *panel id*, taking precedence over the
registry. This is how a screen mounts a component that lives in `officer-web` rather than `officerdev`
(Email's list/reader, Browser's tabs/preview).
- **`ephemeral` prop** — a second, independent layout tree rendered in a sibling collapsible pane, driven
entirely from search params and deliberately *never* persisted. One producer: `useFileViewerPanels`,
which is how FileBrowser opens a viewer/player/side-chat without writing it into your saved layout.
## 4. How panels talk to each other
**Two mechanisms, and the split between them is the single most important convention here.**
### The URL — for "which thing is open"
Anything addressable — a session, an album, a section, a torrent — belongs in `useParams` or
`useSearchParams`. Every panel reads the URL independently; nobody passes a selection to anybody. This
gives you deep links, back/forward, cmd-click, and refresh-survival for free.
This is the post-refactor pattern, and about two thirds of the app now does it. `docs/navigation-audit.md`
is the authority.
### `usePanelChannel` — for signals
```ts
export const usePanelChannel = <T,>(channel: string, initialData: T) =>
useGlobal<T>(['PANEL_CHANNEL', channel], initialData);
```
Five lines. It is a **slot in the app-wide React Query cache** — not a subscription, not an event
emitter. Writing sets the cache entry; every component reading that key re-renders. It is
process-global: nothing in the key comes from a panel, a dashboard or a route, so two workspaces on the
same screen share every channel. Lifetime is the SPA session — it does not survive a reload.
Legitimate use is **notification**, not state: "something changed, refetch." The honest examples:
| channel | who publishes | who subscribes |
|---|---|---|
| `files:refresh-signal` | Chat on turn-complete, FileViewer after a write | FileBrowser re-lists the directory |
| `music:resync` | MusicBrowser after a reindex | MusicDetail refetches |
| `soulseek:refresh` | SearchResults on enqueue | Transfers list refetches now instead of on its poll tick |
The one correct *scoped* use of the same primitive is `terminal-conn-${panelId}`: a panel's body and its
registry `header` are separately-mounted components with no shared parent, so the body publishes its
connection status and the header renders it. Panel-scoped by construction, which is exactly what
`panelId` is for.
### The thirteen channels, honestly
Was 13 channels, 26 call sites. Six carried selection that belongs in the URL, and three of those are
gone: `soulseek:section` and `soulseek:user` (the whole Soulseek workspace now has one) and
`monitor:scope`. Still to move: `music:cwd` — the entire library location, so no album is linkable — and
`chat:selected-session`, which is the chat's to move, not this document's. Two are view toggles where a
channel is genuinely fine (`music:favorites`, `music:lyrics`).
Two are wired to nobody: `preview:refresh` and `chat:active-session` have publishers and no subscribers.
A third, `file-viewer:<panelId>`, had a subscriber and no publisher — it is gone, along with the
`officerdev/file-viewer` registration it fed, which would have rendered "No file selected" forever. The
file viewer users actually see is an *ephemeral* panel from `useFileViewerPanels` and reads the URL.
Four channel names are **bare string literals with no shared constant**, repeated across up to four
files. A typo does not error — you silently get a fresh channel stuck on its initial value. And because
`usePanelChannel<T>` takes `T` from each caller, nothing enforces that a publisher and a subscriber agree
on the payload type.
**Rule of thumb:** if you can write down what a channel's value *is* ("the open session", "the current
folder"), it is selection and belongs in the URL. If the only thing it means is "something happened",
it is a signal and a channel is right — give it an exported constant and a nonce payload.
## 5. Persistence
One hook, one endpoint, three tables:
```
useDashboardState(key, default) → GET/PATCH /api/dashboards → dashboards | screens | dashboard_defaults
```
`useDashboardState` keeps the **entire** user state blob under one React Query key with
`staleTime: Infinity`, reads `state[key] ?? default`, and on mount **seeds its default to the server** if
the key is absent. Writes are optimistic: set the cache, fire a PATCH, ignore the result.
The server dispatches each PATCH key by *family*: `workspaces`, `ws-layout-*`, `ws-terminals-*`,
`ws-host-terminals-*`, `screens/*`. Two consequences worth knowing before you invent a key:
- **There is no `else`.** A key matching no family is dropped, and the request still returns 200. The
client's error path never fires, so an unknown key looks exactly like a successful save and fails only
on reload. Three key families in active use today (`ws-tmux-*`, `ws-nvim-*`, `ws-claude-code-*`) are in
precisely this state.
- **`null` only means "delete" in two of the branches.** Elsewhere it reaches a `NOT NULL` jsonb column.
Because `useDashboardState` seeds one row per key on mount, per-panel scalar preferences should **not**
get their own key — that mints a row per panel. Put them on the layout node instead, the way `zoom` is
(`layout-utils.setZoom` drops the field at its default so untouched panels stay clean).
Not everything belongs on the server. Per-tab UI state — which panel is maximized, what you named the
tab — uses `useSessionState` (`hooks/useSessionState`): `useGlobal` for the live value, sessionStorage as
the write-through copy the next document reads back.
## 6. Building a new screen — the checklist
1. `defaultLayout.ts` next to the screen, plus a `normalizeLayout` guard pinning `appType`s to an
allow-list (this guard is copy-pasted 14 times today and should be framework; until it is, copy it).
2. `useDashboardState<LayoutNode>('screens/<name>', defaultLayout)``<WorkspaceView workspace={ws} locked />`.
3. Panel apps under `apps/<Feature>/`, exporting `appRegistryMetas` with `availableOnPanel: false`,
registered in `AppRegistry.tsx`.
4. Selection in the URL — route pair `/x` + `/x/:param`, or `?selected=` for a master list with a live
preview. Rows are real `<Link>`s.
5. A title rule in `usePageTitle.ts`.
6. If two panels must coordinate a *refresh*, export a channel constant. If they must coordinate a
*selection*, you want the URL.
## 7. What is deliberately good — don't regress it
- **Maximize is a CSS state toggle on the same element** — no portal, no remount, so scroll position and
media playback survive it. Every mutator should be held to this standard.
- **`layout-utils` is pure and short-circuits on referential equality**, so identity churn is bounded.
- **The framework contains zero route navigation**, which is why fixing a channel-driven app is a local
change to that app and never a framework change.
- **The resize debounce** — 500 ms trailing, with a guard that drops the spurious first `onLayout`.
- **`WorkspaceContext` carries environment, never selection**, and only 4 of ~30 apps read any of it.