docs/agent-coordination.md is the objective the workspace/panel work serves — several agents on one dashboard handing work to each other instead of routing every step through the human, with the human authoring the workflow at the top. Written from the owner's own words during the 2026-08-07 conversation; where a section records a decision, that decision is his. It settles the questions that were blocking: sessions may be reaped and resumed from the durable sessionKey→claudeSessionId map (no heartbeat), the address is the human-assigned panel name rather than the panel id, roles are prompts rather than features, and the protocol is turn-boundary-only by construction — which routes around the mid-output restart failure instead of fixing it. Cross-referenced from CLAUDE.md, workspace-panels.md and workspace-panel-todo.md so it is findable from any of them. The todo is still ordered by defect severity and now says so; the re-rank against the objective is deferred, not forgotten. Also corrects two items dated 2026-08-08 to the day they were actually found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
11 KiB
Markdown
198 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
|
|
|
|
13 channels, 26 call sites. Six carry selection that should be in the URL — `music:cwd` (the entire
|
|
library location, so no album is linkable), `soulseek:section` and `soulseek:user` (the whole Soulseek
|
|
workspace has no URL), `chat:selected-session`, `monitor:scope`. Two are view toggles where a channel is
|
|
genuinely fine (`music:favorites`, `music:lyrics`).
|
|
|
|
Three are wired to nobody: `preview:refresh` and `chat:active-session` have publishers and no
|
|
subscribers; `file-viewer:<panelId>` has a subscriber and no publisher, which is why the registry-mounted
|
|
`officerdev/file-viewer` app renders "No file selected" forever.
|
|
|
|
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.
|