Files
platform/docs/workspace-panels.md
T

11 KiB

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.

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:

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:

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-routeruseParams / 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 propRecord<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

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 appTypes 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.