# 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('screens/music', defaultLayout); return ; ``` `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 `` at mount: each app folder exports `appRegistryMetas`, and `metasToRegistry` flattens them into one `Record`. 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`, 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 = (channel: string, initialData: T) => useGlobal(['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:` 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` 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('screens/', defaultLayout)` → ``. 3. Panel apps under `apps//`, 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 ``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.