The todo entry said the file-viewer registration was dead; tracing it confirmed that and turned up the reason it looked alive — the ephemeral file viewer is a different mounting path entirely. Recorded, with what was checked in the database before deleting anything. CLAUDE.md, navigation-audit.md and workspace-panels.md all listed FILE_VIEWER_CHANNEL among the legitimate refresh/signal channels. It never had a publisher, and no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
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:
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:
useWorkspace()— ambient environment:dashboardId,cwd,root,promptPrefix, plus the framework's own maximize/swap/zoom state. Read-mostly, set once per screen.- 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. useDashboardState(key, default)— durable, per-user, server-persisted state. See §5.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:
componentsprop —Record<panelId, Component>, keyed by panel id, taking precedence over the registry. This is how a screen mounts a component that lives inofficer-webrather thanofficerdev(Email's list/reader, Browser's tabs/preview).ephemeralprop — 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).
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. nullonly means "delete" in two of the branches. Elsewhere it reaches aNOT NULLjsonb 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
defaultLayout.tsnext to the screen, plus anormalizeLayoutguard pinningappTypes to an allow-list (this guard is copy-pasted 14 times today and should be framework; until it is, copy it).useDashboardState<LayoutNode>('screens/<name>', defaultLayout)→<WorkspaceView workspace={ws} locked />.- Panel apps under
apps/<Feature>/, exportingappRegistryMetaswithavailableOnPanel: false, registered inAppRegistry.tsx. - Selection in the URL — route pair
/x+/x/:param, or?selected=for a master list with a live preview. Rows are real<Link>s. - A title rule in
usePageTitle.ts. - 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-utilsis 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. WorkspaceContextcarries environment, never selection, and only 4 of ~30 apps read any of it.