54 KiB
Workspaces & Panels — working TODO
Living list. Add items as they are found, tick them as they land, and write the resolution into the item rather than deleting it — the reason a thing was done is worth more later than a clean list. Move anything fully settled to §7.
How the framework actually works is documented separately, in workspace-panels.md — read that
first if you are new to it. This file is only the defect list and the work queue.
Why any of it matters is in agent-coordination.md — the north star.
This list stays ordered by defect severity. The re-rank against the objective lives in
agent-coordination.md §6 (done 2026-08-07), and the two orderings deliberately disagree — read §6
before deciding what to pick up next. Its conclusions in one paragraph:
- Tier A, on the critical path: the swallowed persist failure (§1), the PATCH dispatcher's missing
else(§2), validate-on-read + the'[]'layout default + an error boundary (§4), the two resize-debounce lost-update items (§5.5), the never-invalidated cache (§5.5), and tests forlayout-utils.ts(§9). Five of the seven are one defect — a write that silently did not land — and they matter because panel identity now lives in the layout jsonb. Tier A is empty as of 2026-08-07. §1's.catch(() => {})(70c2f08), all of §2 and §3's missinguserIdpredicate (f4ed740), §9 (85452d1), all three Tier A items in §4 — the error boundary (64961d4), validate-on-read and the'[]'layout default (ef036df) — and §5.5's three: the two resize-debounce lost updates (b0a32ae) and the never-invalidated cache (81ad3ef). Everything the re-rank put on the critical path for "the human opens the dashboard in the morning and sees the result" has landed. Pick up from Tier B. - Tier B:
dashboardId-as-a-bag (§5.9) — the agent address book is keyed on it; panel lifecycle (§5.1), but inverted: the requirement is that closing a chat panel must not destroy the agent;normalizeLayoutas framework (§5.4); and the mobile-collapse decision (§6), which swings on one unanswered question. §5.9's first three are done — the bag is a parsed identity (dbe585f) and the two file-browser fields are deleted (717580f,585f234). What is left there ispromptPrefixand the constructed-in-two-places preview context. - Tier C, orthogonal: everything else — including §5.2 (remounts) and §5.3 (drag-to-move), both
large downgrades. A panel is now a pointer to a server-side session, so a remount costs a replay, and
the drag hazard is disarmed by resolving identity by name (
e588524+bc82086). - And the result that matters: no item in this file blocked building the MVP. It was built and ran unattended on the framework as it stands. Stop treating this list as the prerequisite queue for the objective.
Findings and full reasoning: COMMS/workspace-panel-framework-analysis-2026-08-07.md. Every file:line
below was opened; DB claims were run against live officer_dev. Paths are relative to
platform/src/workspaces/officerdev/src/ unless they start with servers/, databases/ or sidecars/.
Framework core: components/Workspace/ — 12 files, 1,533 lines. No tests exist for any of it.
Two open decisions gate real work — see §6. Don't start item 4.1 or 5.3 before those are answered.
1. Observability first — do these before anything else
Both are two-line fixes, and without them you cannot tell whether any later fix worked.
-
Fix the Running Shells paths — the panel has 404'd since 2026-07-31.
apps/Terminal/RunningShells.tsx:43,49call/terminal/sessionsandDELETE /terminal/sessions/:id.useClientprefixes/api;servers/sidecar/create-proxy.ts:69strips the mount prefix, so the sidecar receives/sessions. The pty sidecar serves only/_officer/sessions(sidecars/pty/server.mjs:30) and/_officer/sessions/:id(:34) — everything else 404s at:40. Correct paths:/terminal/_officer/sessionsand/terminal/_officer/sessions/:id. The panel (fccf212) predates the proxy move (7129cd8) that deleted the oldservers/api/terminal/router.tsand was never updated. Symptom today: permanently reads "No shells running", kill button is a silent no-op. -
Surface the
clientscount in Running Shells.RunningShells.tsx:12-20drops theclientsfield the sidecar returns (sidecars/pty/sessions.mjs:169) — the one field that distinguishes an orphan (clients: 0) from a live shell. Add it to the type and render it. -
Stop swallowing persist failures.
state/src/useDashboardState.ts:46is.catch(() => { }). Every 500 in this document is invisible because of it — the optimistic cache keeps the UI correct until reload. At minimum log; better, surface a toast and roll the cache back. Tier A1 — the single highest-value item in this file against the objective (agent-coordination.md§6.2). A panel'sconfig.agentNameis written through this path, so a swallowed 500 leaves a panel that shows its name, answers to its name, and forgets it on reload: invisible for exactly as long as nobody is looking, which is the window the whole project serves. Resolved70c2f08— both catches nowconsole.errorand raise a toast naming the key, and roll the cache back. The rollback is a compare-and-swap: it only reverts if the cache still holds exactly what that call wrote. Writes to one key overlap freely (a window resize fires one per group), and a blind rollback over a later successful write would turn one failure into two. The other three, not through this hook, resolved81ad3ef: the directclient.patch('/dashboards', …)calls inapps/Dashboards/DashboardListApp.tsx:93andDashboardPreview.tsx:323,326,356— dashboard create, rename and delete, which build multi-key patches and so bypassuseDashboardState. They now callpersistDashboardState, which shares the hook's in-flight bookkeeping and, on failure, invalidates rather than reverts: once the roster has been rewritten and keys dropped from the blob there is no single previous value to swap back, and a refetch is the only thing that makes the list agree with the server. A failed delete used to leave the dashboard gone from the list and alive on the server, reappearing at the next reload with no hint why.
2. Live data loss
All four resolved in f4ed740 — they were one defect with four faces, so they were fixed as one
change. Each item keeps its diagnosis below and records what was done. Verified against the live server:
an unknown key 400s, the three prefixes round-trip through a GET, a null on a live dashboard is a no-op,
and the rename sequence leaves workspaces with no zombie.
-
The PATCH dispatcher silently drops three key families in active use.
servers/api/dashboards/dashboards.ts:26-88is a chain ofif (match) { …; continue; }that ends with noelse— unmatched keys are dropped and the request returns 200 with a fresh state blob. Matched:workspaces,ws-layout-*,ws-terminals-*,ws-host-terminals-*,screens/*.apps/Terminal/CommandTerminalWrapper.tsx:18writesws-${statePrefix}-${dashboardId}, and the registered prefixes (apps/Terminal/index.tsx:22,27,34) aretmux,nvim,claude-code. So the panel→session map for those three survives only in the React Query cache, for the life of the tab. Every reload mints a fresh uuid and abandons the previous pty — alive, unreachable, never killed. (Tmux's own state survives vianew-session -A -s off-<panelId>; the shell runningtmux attachdoes not.) Two parts: add the three families, and add a fallbackelsethat 400s on an unknown key. Tier A2 — the server half of A1.ws-layout-*is matched, so panelconfigpersists today (verified: theagent-mvplayout round-trips withconfig: {agentName: …}intact). The missingelsemeans the next key family added for coordination is a silent no-op returning 200. Resolved. The three maps live in a newdashboards.panel_statejsonb bag keyed by prefix ({tmux: {panelId: sessionId}, …}), withdashboard_defaults.panel_statefor panels on a screen. A bag rather than three columns because the fourth prefix should not need a schema change; an allow-list rather than a regex becausews-<prefix>-<id>cannot be split without one — both halves may contain dashes.PANEL_STATE_PREFIXESinservers/api/dashboards/dashboards.tsmust stay in step with thestatePrefixprops inapps/Terminal/index.tsx, and the new fallbackelse— a 400 naming the key — is what tells you when it does not. Living on the dashboard row also means the maps are deleted with it. -
ws-terminals-{id}: nullon a live dashboard is a 500. Same file,:61-66— thews-layout-*branch has avalue === null→deleteDashboardcase (:42); the terminals branches do not. A null falls to the UPDATE branch and sets aNOT NULLcolumn (databases/officer_db/src/queries/dashboards.ts:70) → 23502. Resolved. A null on either terminals branch is now a no-op: it means "forget this key", and it only ever arrives paired withws-layout-{id}: nullon a rename, by which point the row is gone. -
HostTerminalWrappernever strips the prefix, so it mints phantom dashboards. (found 2026-08-07, latent —dashboardsis still 0 rows)apps/Terminal/HostTerminalWrapper.tsx:12is`ws-host-terminals-${dashboardId}`with no regex, while its siblingTerminalWrapper.tsx:13-14correctly matches^ws-layout-(.+)$first. So the key becomesws-host-terminals-ws-layout-<id>orws-host-terminals-screens/terminal, the dispatcher's^ws-host-terminals-(.+)$branch captures that whole string as an id, andupsertDashboardinserts a row when the id is unknown (queries/dashboards.ts:75-86,name: data.name ?? id).getAllDashboardStatemaps everydashboardsrow intoworkspaces, so the garbage id surfaces in the Dashboards list as a real dashboard. Two apps deriving a state key from the same string by two different rules is the actual defect; the id-shaped-string-as-a-bag problem behind it is §5.9. Latent only becauseofficerdev/terminal-hostis in no default layout and hidden from the picker. Resolved. All three wrappers now callterminalStateKey(prefix, dashboardId)(apps/Terminal/state-key.ts) —TerminalWrapper's rule, extracted, with the reasoning beside it. Belt and braces on the server: the terminals and panel-state branches use a newupdateDashboardthat will not INSERT, and 404 on an unknown id instead of creating one. §5.9 is still the real fix; this closes the hole a mis-derived key could fall through. -
Renaming a dashboard resurrects it as a zombie row.
apps/Dashboards/DashboardPreview.tsx:316-318PATCHesws-layout-old: nullandws-terminals-old: nulltogether. The first deletes the row; the second then callsupsertDashboard, finds nothing, and re-INSERTs it withname = id. The old slug reappears inworkspaceson the next GET as a duplicate. Fixed by the item above, but verify this specific sequence after fixing. Resolved, and verified as the specific sequence: createzombie-test, then PATCH{ws-layout-renamed: …, ws-layout-zombie-test: null, ws-terminals-zombie-test: null, ws-host-terminals-zombie-test: null}in one body.workspacescomes back as['agent-mvp', 'renamed'].
3. Multi-user correctness — before another member creates a dashboard
dashboards is empty (0 rows) today, so none of this has fired yet. Members can now sign in
(d8ee678), so it is a matter of time. Note TODO.md's preamble still says the platform is collapsing
to single-user — that predates the capability permission model and should not be used to deprioritise
these.
-
dashboards.idis a global primary key but ids areslugify(name).databases/officer_db/src/schema/dashboards.tsdeclaresid: text('id').primaryKey(). Live:"dashboards_pkey" PRIMARY KEY, btree (id)plus a redundant"uq_dashboards_user_id" UNIQUE, btree (user_id, id)— evidence per-user ids were intended and half-built. Ids come fromDashboardPreview.tsx:300(slugify(trimmed) || generateSlug()) and the uniqueness loop at:340checks only the caller's own dashboards. Two users both naming a dashboard "Work" → both getid = 'work'→ the second violates the PK → 500 → swallowed → the dashboard shows, then vanishes on reload. Fix: composite PK(user_id, id), or uuid ids. Composite PKs have a known drizzle re-diff quirk (seedatabases/CLAUDE.md→ "Composite keys") — harmless churn, but read the plan. -
upsertDashboard's UPDATE has nouserIdpredicate.databases/officer_db/src/queries/dashboards.ts:73—db.update(dashboards).set(set).where(eq(dashboards.id, id)). Theexistinglookup above it is scoped, so it cannot reach another user's row today, but it is a non-transactional read-then-write. It becomes a live cross-user overwrite the moment the PK above is made composite. Do both in one change or the first fix opens the second. Resolvedf4ed740, incidentally.upsertDashboardis nowupdateDashboard(scoped by(userId, id),RETURNINGto say whether it matched) with an INSERT only when that returns nothing — so the read-then-write is gone as well as the missing predicate. The composite-PK item above is still open, and is still the one that matters; this just no longer opens a second hole when it lands.
4. Resilience — one bad row is currently a white screen
The first three items are Tier A (agent-coordination.md §6.2, A3–A4). This is the last mile of the
objective: work proceeds overnight, and the human's only act is to open the dashboard and look. A
malformed row turning that into a white screen loses the result at the one moment it is consumed — and
config added a new unvalidated field to an already-unvalidated tree. The work itself is safe in
chat_session_events, which is why recovery must offer "restore default layout" rather than SQL.
§4 is closed — 64961d4 (the boundary), ef036df (validate-on-read and the '[]' default),
92b8905 (validate-on-write). "A layout is an object" is now stated in four places: the column, the
route, the read, and the renderer's willingness to survive being handed something else anyway.
-
Add an error boundary.
grep -rln "componentDidCatch\|getDerivedStateFromError\|ErrorBoundary\|errorElement" srcreturned nothing across the whole repo. Resolved64961d4— two of them, because "recover" means different things at different depths.components/ErrorBoundary.tsxis a generic render-prop class (resetKeysso a boundary cannot latch);Screens/Dashboard/Layout/DashboardLayout.tsxwraps the routed screen with the dock and header deliberately outside it, so navigating away is itself a way out, and its fallback offers a two-click reset of everyscreens/*layout — dashboards are left alone, being user-created and holding content.PanelSlot.tsxwraps each app so one bad panel leaves the rest of the workspace running, with "clear this panel" as its recovery, offered only when the layout is the user's to edit. Not a targeted "reset this screen": React unmounts the subtree before rendering a fallback, so an effect-based registry of "which layout key was I rendering" is empty at exactly the moment you need it. -
Validate on read.
useDashboardState.ts:36waskey in state ? state[key] as T : defaultValue— an unchecked cast over a value that arrived asunknownfrom jsonb. Resolvedef036df—readValue()checks kind-compatibility before casting and falls back to the caller's default, warning once per key. NotisLayoutNode(): all 24 layout call sites would have had to pass a predicate, and the same wrong-container bug applies to thepanelId → sessionIdmaps. So the rule is generic and only guards object-shaped defaults — a wrong primitive is a cosmetic surprise, a wrong container is a crash. It deliberately does not write the correction back; a read should not overwrite the server, and the next realsetValuerepairs the row. -
layoutcolumns default to'[]', which is not a validLayoutNode.schema/dashboards.ts:13,31— an empty array for a column holding an object. Any path that upserts without a layout (e.g. theworkspacesbranch) writes it;key in stateis then true, so the stored[]wins over the caller's default andnormalizeLayoutcalls.children.mapon it and throws. Creating a dashboard from the dashboard list is exactly that path, so this was reproducible, not theoretical. Resolvedef036df— both columns are nullable with no default (NULL means "none stored", which is the truth), andgetAllDashboardStateomits the key when what is stored is not an object, so a row written before this is repaired by the next write instead of crashing the read. Verified live: creating a dashboard with no layout emits nows-layoutkey, and a row hand-set back to'[]'is omitted too.bun db:pushplanned exactly the four expectedALTERs plus the two known-harmlesspk_music_now_playinglines. -
Validate on write. The whole path is
unknown:useDashboardState.ts:46→servers/api/dashboards/dashboards.ts:45,83→queries/dashboards.ts:51,97→ castas neverat:114to satisfy drizzle → jsonb. No zod, no CHECK on either layout column. Givendatabases/CLAUDE.md's stance that the schema is the source of truth for contents, a CHECK onjsonb_typeof(layout) = 'object'is the cheap half. Resolved92b8905— both halves, because they do different jobs.ck_dashboards_layout_object/ck_screens_layout_objectare the backstop: nothing reaches those columns without passing them, including a psql session or a future writer nobody has written yet. NULL stays legal — it means "none stored", which is the honest state of a dashboard created without a layout. But a constraint violation surfaces as a 500 that tells the caller nothing, so the PATCH dispatcher checks the same rule first and answers400 "ws-layout-x" must be a layout object. Deliberately not a fullLayoutNodeschema: this is the wrong-container class of bug — the one that crashesnormalizeLayout— and a structural validator would need the client'szoom/configvocabulary in the database layer to say anything more. Verified live:db:pushplanned exactly the twoADD CONSTRAINTs plus the knownpk_music_now_playingchurn and was stable on a second run;update … set layout = '[]'and= '3'are both rejected by Postgres; array, number and string bodies tows-layout-{id}andscreens/{name}all 400, and an object still round-trips. -
A bare number is squatting in a framework namespace. (verified in the live DB)
apps/Soulseek/shared.ts:12builtscreens/soulseek-zoom/${panelId}and passed it touseDashboardState<number>. The server routes^screens/(.+)$intoscreens.layout, so:user_id 1 | soulseek-zoom/soulseek-view | jsonb_typeof = number. That namespace belongs to layouts. Resolvedba664dc— Soulseek's private zoom became a framework feature (LayoutPanel.zoom), so the scalar now rides on the layout node insidescreens/soulseek-v2and needs no key of its own. The squatting row is gone:select name from screensreturns 15 rows, nonesoulseek-zoom/*. This is also the general answer for per-panel scalar prefs — put them on the node, not in a key, becauseuseDashboardStateseeds a row per key on mount.
5. The framework work proper
5.1 Give panels a lifecycle — the highest-value change here
-
onCloseonAppRegistryEntry, invoked by the mutators, not by unmount.components/Workspace/types.ts:38-47has no close hook; the onlyonCloseisPanelComponentEntry.onClose(:55), which is a header-button handler for ephemeral panels and is suppressed on mobile (PanelSlot.tsx:382). So the two ways a panel dies —removePanel(root, id)andsetApp(root, id, null)(the red traffic light,PanelSlot.tsx:295) — are pure tree rewrites that notify nobody. Invoke it fromWorkspaceView'shandleRemove/handleSetApp, never from aPanelSlotunmount — aPanelSlotunmount is precisely the ambiguous signal this exists to replace. Implementation: diff removed panel ids old-tree ∖ new-tree, or have the mutators return them.This is the thing `apps/Terminal/TerminalWrapper.tsx:31-38` asks for in writing: *"Killing on unmount is not an option until the panel system can tell a real close from an incidental remount."* It closes the terminal orphan leak as a consequence rather than as a special case, and the same gap affects every panel holding a server-side resource. **The requirement inverts for a chat panel** *(2026-08-07)* — do not wire one into this hook without deciding first. `agent-coordination.md` §5 Q2: idle reaping deliberately leaves the `sessionKey → claudeSessionId` pointer intact, but the explicit `disconnect` path calls `clearClaudeSession`, which destroys it and orphans the transcript. A panel is a pointer to a server-side session; **closing the window must not delete what it points at.** What coordination wants from `onClose` is a guarantee that nothing rides the disconnect path, not an eager cleanup. -
Then: kill the pty on real close. Once the hook exists,
TerminalWrapper/CommandTerminalWrapper/HostTerminalWrappercanDELETE /terminal/_officer/sessions/:idand drop the map entry — the thing they each explain they cannot currently do. -
Then: reap orphans in the sidecar as a backstop.
sidecars/pty/sessions.mjs:127-130detach()never checksclients.size === 0; there is no idle timeout and no session cap.lastActivityAtis written (:110) and displayed (:167) but never read by a timer. Each orphan costs aSHELL -iwith the owner's full env plus up toBUFFER_MAX = 512 KiB(:10) — bounded per session, unbounded in aggregate. Note:sidecars/is platform, so in scope — but confirm before touching the pty protocol.
5.2 Stop the avoidable remounts
Tier C — the largest downgrade in the re-rank (agent-coordination.md §6.4). A remount used to
threaten whatever the panel was holding; a panel now holds nothing. A chat panel that remounts re-runs
resume-cursor against the durable log and replays, costing latency, which §2 of the north star
declares free. Fix these for the interaction quality they are genuinely about — scroll position, media
playback, transcodes — not as a prerequisite for agent coordination.
A panel's React identity is its position plus key={child.node.id} on its nearest ancestor group
slot (WorkspaceRenderer.tsx:178,204) — the panel's own id is a key nowhere. That's the root cause of
the whole table:
| operation | remounts? | why |
|---|---|---|
| split, direction matches parent | no | layout-utils.ts:34-44 splices children through by reference |
| split, direction differs / leaf | yes | :20-30 wraps in a new group with a fresh uid() |
| split the root when root is one panel | yes, everything | WorkspaceRenderer.tsx:40 renders one unkeyed child whose type changes |
| remove from a 2-child group | yes — the survivor | :68-70 collapses the group, re-keying the sibling |
| swap | apps remount | setApp twice; the component type at each slot changes |
| resize / maximize | no | ids preserved; maximize is a CSS toggle |
| mobile panel switch | yes, by design | only the active child renders |
| any ephemeral panel on mobile | yes — every panel | WorkspaceView.tsx:165 replaces the workspace instead of overlaying |
| viewport crossing 768px | yes, full | useIsMobile flips the branch at WorkspaceView.tsx:165 |
- Reuse the panel's id when wrapping it in a new group, so the ancestor slot key doesn't flip. Kills rows 2 and 3.
- Don't re-key the survivor when a group collapses (
layout-utils.ts:68-70, 83-88). Kills row 4. - Overlay the mobile ephemeral panel instead of replacing the workspace
(
WorkspaceView.tsx:165). Affects/files,/email,/chat,/browser,/dashboards. - Reference for how it should feel: maximize (
PanelSlot.tsx:430-457) is a CSS state toggle on the same element — no portal, no remount, scroll position and media playback preserved. Every mutator should be held to that.
5.3 Drag-to-move: finish it or delete it
- Decide, then act — do not patch it.
dragSourceIdcan never become non-null: the only code that would set it is commented out atPanelSlot.tsx:240-256. So the whole path is dead:DragOverlay.tsx(99 lines, imported by nothing),LayoutEditor.tsx(51, imported by nothing),movePanel+insertPanel+DropPosition(layout-utils.ts:142-169), and three context fields. ~180 lines. If finishing it:movePanelmints a brand-new panel id, so it destroys all panel-keyed state, andPanelContents(layout-utils.ts:229) carries only{appType, config}— so a move silently dropsfitContentandzoom. Fix before re-enabling. Downgraded 2026-08-07 — this was on the critical path inagent-coordination.md§1.5 (P3: "dragging a panel would silently sever its session binding") and no longer is. Q3 made the agent's name the address and the panel id merely where it lives, ande588524made move and swap carryconfigwith the panel.useAgentPanelresolves by name and re-anchors the row afterwards. Still worth deciding — it is dead code — but it gates nothing. Seeagent-coordination.md§6.4.
5.4 Make normalizeLayout framework, not convention
- One
makeLayoutNormalizer(allowed, fallback)applied insideWorkspaceView. The guard currently exists 14 times, character-identical except for the allow-list and the fallback, plus a matchinguseMemo/persist-backuseEffectpair 13 times — ~150 duplicated lines. Consequences of it being convention: - 10WorkspaceViewconsumers have no guard at all, includingscreens/HomeScreen.tsx:11, which also omitslocked— the one screen where a user can set any registry appType with nothing pinning it back. - Allow-lists are hand-written literals, never derived from the AppRegistry. They catch a renamed appType; they do not catch one still allow-listed but deleted from the registry — that passes and reachesPanelSlot.tsx:311-317, which on alockedscreen renders an empty teal-bordered box with no picker and no way for the user to recover. -screens/QrTransferScreen.tsx:19-39has the guard but no persist-back, so it re-normalises on every mount forever and never heals the row. - Then collapse the three default-layout mechanisms: per-screen
defaultLayout.ts(×20),createDefaultLayout()in the core, and the 6-entry template array atDashboardPreview.tsx:33-142.
5.5 Persistence hygiene
Three Tier A items live here (agent-coordination.md §6.2, A5 and A7). The two lost-update bugs are
worse than described now that identity is in the layout: a debounce timer closing over a pre-drag tree
does not just resurrect a deleted panel, it re-writes an older config, so a panel that was just
named reverts to anonymous. And the never-invalidated cache means two windows onto the same server-side
work disagree permanently about the roster, with neither told — a direct contradiction of §5 Q1.
- The resize debounce can resurrect a deleted panel.
WorkspaceRenderer.tsx:108-124held a 500 ms timer in a ref with nouseEffect, therefore no cleanup, and its callback closed over the layout as it was when the drag began. Drag a splitter, remove a panel within 500 ms → the timer PATCHed the pre-deletion tree and the panel came back. Same shape for a split. Resolvedb0a32ae— the timer now lives behind auseEffectthat flushes it on unmount rather than dropping it: with the write expressed as an updater the early write is correct, and dropping would lose a splitter drag made just before navigating away, which the no-cleanup version did at least persist. - Concurrent same-key writes clobber. A window resize fires
onLayouton every group at once; each scheduled its own timer against the same base tree and the last won (WorkspaceView.tsx:76-81). Resolvedb0a32ae— all eight mutations inWorkspaceViewpass an updater tosetValue(onLayoutChange((prev) => …)) instead of a tree computed from thelayouttheir callback closed over, so each composes against the current cache. The no-op guards (countPanels, the=== layoutidentity checks) still test the renderedlayout: they only decide whether a write is worth making, and being one render stale there costs a redundant no-op write at worst. Worth keeping in mind for anything new that writes the layout: compute fromprev, always. The cost of getting it wrong is no longer a size that snaps back — it is an olderconfig, so a panel that was just given an agent's name reverts to anonymous and the agent stops being addressable. - The cache is never invalidated.
staleTime: Infinityand noinvalidateQueriesanywhere in the repo. Meanwhile every PATCH computed and returned a full fresh state blob which the client discarded — 3 SELECTs per splitter release, thrown away. Resolved81ad3ef— both halves. The PATCH returns{ok: true}; nothing had ever read that body, and a caller that did would be reading state assembled before whatever concurrent write it raced. The client refetches on focus, with three non-default guards, because this cache is optimistic: a refetch that started before an in-flight PATCH landed would overwrite the value already on screen — the same lost-update shape as the two items above, and self-healing only until the next mutation composes on top of the stale tree. SorefetchOnMount: false(splitting a panel mounts a fresh consumer, which is exactly when a write is in flight),refetchOnReconnect: false, andrefetchOnWindowFocusgated on a module-level in-flight count plus a 2 s quiet period. - Preserve sibling sizes on split.
splitInner/insertPanelredistribute evenly (100 / newChildren.length), so one split discards carefully tuned proportions. tpl-Npanel ids collide across dashboards.DashboardPreview.tsx:25-26—let tplCounter = 0; const tplUid = () => \tpl-${++tplCounter}`— module-level, no entropy, resets every page load. Two dashboards created from templates after a reload hold panels with **identical ids**, and panel id keysterminal-conn-${panelId}andfile-viewer:${panelId}. Use the core'suid()(layout-utils.ts:4), which stampsp-${Date.now()}-${n}`. One line.
5.6 Registry
- No duplicate-key guard.
metasToRegistryisObject.fromEntries(state/src/useAppRegistry.ts:19) — a collision silently last-wins and one app just disappears. All 44 keys are unique today. Throw in dev. - Seeding depends on undocumented mount ordering. Three call sites call
useAppRegistry()with no argument, defaulting to[]. It works only because<AppRegistry />sits atfrontend.tsx:32, above<App />at:34. Mount aWorkspaceViewabove that and every panel renders empty. officerdev/file-vieweris a dead registration. Its provider readsusePanelChannel('file-viewer:' + panelId)and nothing in the repo writes that channel. AlsoavailableOnPanel: false, so it can't be picked. If it ever appeared in a layout it would say "No file selected" forever.dashboard-listis pickable in every panel on every dashboard.apps/Dashboards/index.ts:12-16omitsavailableOnPanelwhile its siblingdashboard-previewsetsfalse. Almost certainly unintended — the flag is opt-out, and 12 of 44 metas omit it. Consider making it opt-in.
5.7 Effect hygiene in panel apps
All the same bug: an app guessing "am I being closed?" from an unmount, or paying for a remount from 5.2.
- PARKED —
useClientidentity /DesktopViewdeps. Revisit later, not now.useClientreturns a fresh object every render (workspaces/hooks/src/useClient.ts:30-38), but it is consumed by calling verbs at call time, which is identity-agnostic. Do not memoize it. Only two dep-array sites exist:apps/Desktop/DesktopView.tsx:205(one redundant connect at mount — React bails on the unchangedsetStatus, so it settles rather than looping) andhooks/useChat.ts:124(auseCallback, harmless). Owner has used this pattern for years without issue; my first write-up called it a live bug and that was overstated. DictateDialognever releases the microphone.apps/FileBrowser/FileBrowserApp/components/DictateDialog.tsx:142-149—return () => { if (!showDictate) cleanup(); }. The cleanup that runs ontrue → falseis the one registered by thetruerender, whereshowDictateistrue, so it never fires. Mic stream,AudioContextand the rAF loop leak.apps/QrTransfer/Receiver.tsx:120is the correct version.useAudioRecordinghas no unmount cleanup at all (apps/Chat/useAudioRecording.ts— nouseEffect). Unmounting Chat mid-recording leaves the mic open for the life of the tab.HostTerminalWrapperstill has the cleanup the other two deliberately removed.apps/Terminal/HostTerminalWrapper.tsx:28-35deletes the panel→session mapping from persisted state on any unmount — exactly whatTerminalWrapper.tsx:30-38andCommandTerminalWrapper.tsx:31-32document removing, and why. Latent only becauseofficerdev/terminal-hostis in no default layout and hidden from the picker (apps/Terminal/index.tsx:51).useTaskRunnerabandons the running task.:60-63is a barews.close(). Astopmessage exists at:76-79and is never sent, and there is no re-attach path — unlikeusePipelineRunner, which re-attaches byjobId(:281-283).VideoPlayerkills the transcode on incidental unmount.apps/Jellyfin/VideoPlayer.tsx:217-223POSTsstopped, killing server-side ffmpeg, then renegotiates. Fires on every "yes" row in 5.2. Also: the comment at:215-216says the dep list "must stay empty" while the code passes[sendReport]— harmless today, misleading.- Two stale-closure sockets.
usePipelineRunner.ts:270-301(deps[]but calls auseCallbackthat changes identity — keeps the first-render copy forever) andapps/FileBrowser/AudioStreamPlayer.tsx:70-143(safe today, latent). PanelSlotdefines a component inside render.:341-348—DefaultHeaderis a new component type every render, so the header subtree remounts constantly. Harmless while stateless; a trap the moment it isn't.- The context value is a fresh literal.
WorkspaceView.tsx:145-163— everyuseWorkspace()consumer in every panel re-renders on everyWorkspaceViewrender, including each maximize animation frame. Multiplies the cost of theDesktopViewbug.
5.8 Navigation — finish the refactor
- Move the seven channel-driven apps' selection into the URL. Split today: 11 URL-driven
(everything post-refactor), 7 channel-driven, 3 on bare
useState. The channel-driven set: Music (music:cwd), Soulseek sections, SystemMonitor (monitor:scope), Chat detail (chat:selected-session), Email, Browser, and all five Settings pages (one fix point,SettingsPanel.tsx:56). This is chronological, not architectural — panels render as ordinary children of the screen's Route element (PanelSlot.tsx:333), so every one of them can calluseParamstoday. Nothing in the framework needs to change. Symptom:/soulseekreopens in your last section and/musicat your last album while the URL says the bare route.usePanelChannelis a slot on one process-global QueryClient (frontend.tsx:15-21) with no per-route scoping, andresetis called nowhere. Authority:docs/navigation-audit.md.
5.9 The context has grown an app-config section — (found 2026-08-07)
WorkspaceContext is 18 fields, of which the framework itself reads none of the first six. Apps never
touch the framework half, so the abstraction holds in one direction; the leak is entirely outbound.
Down to 16 as of 2026-08-07, and the outbound half is now workspace, cwd, root, promptPrefix.
Of those, workspace and cwd are genuinely the framework's to state — where the panel is and what
directory it is over. promptPrefix is the one remaining courier job, and it is the next item.
- Delete
initialFilePathanddefaultFileSort. DeclaredWorkspaceContext.ts:14-15, plumbed throughWorkspaceView.tsx:19-20,30, read only byapps/FileBrowser/FileBrowserApp/FileBrowserPanelWrapper.tsx:7,9,10— and set by zero callers.DefaultFileSort({field: 'name'|'size'|'type'|'date'}) is file-browser vocabulary living in the framework's type file, and it is re-exported from the barrel (Workspace/index.ts:27). Pure leak, no payoff, entirely deletable. Gone in717580f, along with the two barrel re-exports. Deleting them orphaned the props they were feeding, so585f234took out the layer below:FileBrowserApp'sinitialPath/defaultSort, the duplicateDefaultSorttype in two files, anduseFileBrowserApp'sisolated— which was!!initialPathand so had always been false. No behaviour changed at either level; both were already running on the defaults. - Move
promptPrefixonto the component, not the context.WorkspaceContext.ts:16→Chat/ChatPanelWrapper.tsx:78→useEmbeddableChat.ts:107. Set byEmailScreen.tsx:53andBrowserScreen.tsx:35, each a screen-local ~40-word system prompt. The framework is a courier for a string only one app understands, and thecomponentsprop already exists for exactly this — Email can supply a pre-configured chat by panel id. dashboardIdis a bag whose format three apps parse. It is literallyworkspace.key(WorkspaceView.tsx:163). Consumers reverse-engineer meaning from its shape:Chat/ChatPanelWrapper.tsx:51-56doesdashboardId === 'email' || dashboardId === 'screens/email'→ email context, and!startsWith('screens/')→ dashboard context — so renaming a screen key silently changes the agent's system context. Worse since 2026-08-07: the agent address book (agent_panels.dashboard_id, viauseAgentPanel) is keyed on this same string, so changing the derivation orphans every named agent on that dashboard — the rows and sessions survive and the panels can no longer find them. Promoted to Tier B inagent-coordination.md§6.3.TerminalWrapper.tsx:13-16regexes it;HostTerminalWrapper.tsx:12doesn't (§2). Give the context the parsed facts ({ kind: 'screen'|'dashboard', id }) instead of the raw key, and the three parsers collapse. Done indbe585f.workspace: WorkspaceIdentity | nullreplacesdashboardId, parsed once inWorkspaceViewbyparseWorkspaceKeyand memoised on the key — the identity lands inuseAgentPanel's query key, and a fresh object per render there is theuseClient()trap that disabled the Jellyfin playback reports. The identity carrieskeyas well askind/id, because the raw key is the addressagent_panels.dashboard_idstores: parsed halves are for deciding, the key is for storing. Two behaviours tightened deliberately — an unrecognised key is no longer treated as a dashboard (the old!startsWith('screens/')would have let a panel register an agent against a workspace with no row to hang it on), andterminalStateKeynow takes the identity, so it can no longer nest a key inside itself. Seven tests inworkspace-identity.test.ts, and the two live agents onws-layout-agent-mvpwere confirmed still addressable after the restart.WorkspaceLayout.tsx:36silently omitsroot— apps inside aDashboardPreviewfall through to thecreateContextdefaults. (initialFilePathanddefaultFileSortwere on this list too and are now deleted;workspaceis omitted there on purpose, since a preview is not a place an agent can be addressed.) Whatever survives the three items above should be constructed in one place, not twice by hand.
5.10 Channel hygiene — (found 2026-08-07)
Cheap to fix, and prerequisites for the 5.8 migration rather than alternatives to it.
- Four channels are bare string literals with no constant.
'files:refresh-signal'(repeated in 4 files),'chat:selected-session'(3 files — plus a module-privateCHANNELconst in a 4th place that only one of them uses),'chat:active-session','preview:refresh'. A typo silently yields a fresh channel pinned toinitialData; nothing errors. Export a constant per channel, next to its payload type. - Payload types are per-call-site, not per-channel.
usePanelChannel<T>takesTfrom each caller, so a publisher and a subscriber can disagree and nothing checks.'files:refresh-signal'isnumberin all four places by convention only. AdefineChannel<T>(name)helper returning a typed hook would fix both this and the item above. - Two write idioms disagree on the same channel.
files:refresh-signalis bumped withDate.now()at the Chat sites andsetRefreshSignal((n) => n + 1)at the FileViewer sites — anduseGlobal's functional form applies against the render-time snapshot (useGlobal.ts:18), so two increments in one render window collapse into one. Standardise on the nonce. (useLyricsOpen.ts:19-23already documents avoiding the functional form for this reason.) system-settings:run-commandhas no writer.run-command-channel.ts:6; the only two writes (SystemSettings.tsx:87,133) are both clears, and the sibling ServerSettings sections never import it. The panel it drives — a terminal that opens with a command pre-loaded — appears unreachable. Wire it or delete it; add to §8 either way.PanelComponentEntry.componentis typed with no props (types.ts:60-62) butPanelSlot.tsx:521passespanelIdat runtime.components-supplied panels get a prop they cannot see; registry apps get the honest{ panelId: string }. One-line type fix.
6. Decisions needed — not defects, don't guess
-
Mobile: thread
mobilePanelIdeverywhere, or delete the collapse?WorkspaceRenderer.tsx:143-165collapses any horizontal group with >1 child to a single child, choosingmobilePanelIdor falling back tochildren[0]. Only 4 of 23 consumers pass it. So/wallet,/music,/calendar,/contacts,/soulseek,/system-monitor,/headscale,/gitea,/photos,/invoices,/jellyfin,/transmissionrender the nav sidebar only — content permanently unreachable, no tab bar, no indicator, no affordance. User-created dashboards (screens/DashboardScreen.tsx:22) show only their left column, forever. Deleting the collapse repairs 16 screens (cramped but complete) at the cost of the 4 using it correctly. Threading it everywhere is 19 small edits and a design question about how you switch. Also note the collapse only applies to horizontal groups. -
The known "invisible edit form on mobile" bug is a sub-case of the above. It is a mutual exclusion, not a layout glitch: the edit pencil is reachable only while
selected === null, and the form renders only whileselected !== null. Commit4970e7ecould not have fixed it — its change is a no-op on mobile. Recorded as deliberately-unfixed inCLAUDE.mdpending the native app; worth re-deciding now that the cause is known. -
Drag-to-move: finish or delete? See 5.3. ~180 lines of dead code with a known defect in it.
7. Landed
(move items here with the commit and a one-line resolution)
-
Panels can carry per-panel settings, and keep them through a move. (
e588524, branchagent-coordination-mvp) —LayoutPanel.config, opaque to the framework, exposed asusePanelConfig(panelId). The substantive half is inlayout-utils.ts:swapPanelsandmovePanelnow carry{appType, config}as one unit via aPanelContentstype, where before they carried onlyappTypeand therefore reset any per-panel state on a drag. This is the one framework change the agent-coordination MVP needed; it is what lets a panel remember which named agent it is. Follow-ons:PanelContentsstill dropsfitContentandzoom(§5.3), and the mutators are still untested (§9). -
A write that does not land no longer looks like one that did. (
70c2f08+f4ed740, branchagent-coordination-mvp) — Tier A1 and A2 of the re-rank, and between them the whole of §2 and the second half of §3. Client:useDashboardStaterolls back and toasts instead of.catch(() => {}). Server: the PATCH dispatcher gained the three missing key families, a 400 on anything unmatched, a 404 rather than an INSERT for an unknown dashboard id, and a null that means "forget" rather than "write NULL into a NOT NULL column". Framework: one rule for deriving a terminal's state key (apps/Terminal/state-key.ts) where there had been three. The remaining member of that family — the three directclient.patch('/dashboards', …)calls inapps/Dashboards/— followed in81ad3ef. See §1. -
A bad stored value is no longer a white screen. (
64961d4+ef036df, branchagent-coordination-mvp) — Tier A3 and A4, and with them the first three items of §4. Three layers, none of which was enforcing anything before: the columns stopped defaulting to a value that could not be a layout, the read stopped handing over one that is not, and the render stopped taking the whole app down when something else does. Per-item detail in §4. What this did not do is validate on write; that followed in92b8905, closing §4. -
A layout write can no longer be quietly undone by an older one. (
b0a32ae+81ad3ef, branchagent-coordination-mvp) — Tier A5 and A7, and with them the whole of §5.5, which empties Tier A. One defect in three places, all of it the same shape: a write computed from a snapshot that a later write had already superseded.WorkspaceView's eight mutations pass updaters rather than trees;WorkspaceRenderer's resize debounce gained theuseEffectcleanup it never had, flushing on unmount rather than firing against a captured layout; and the query no longer sits onstaleTime: Infinitywith nothing to converge it — it refetches on focus, and deliberately not on mount or reconnect, because an optimistic cache makes a badly-timed refetch the same lost update again. The PATCH stopped assembling a full state blob nobody read. Worth remembering for anything new here: the cost of getting this wrong is no longer a panel size that snaps back. Panel identity lives in the layout jsonb, so a resurrected tree carries an olderconfig— the panel that was just given an agent's name goes anonymous, and the agent stops being addressable through it. -
A layout column cannot hold a non-layout. (
92b8905, branchagent-coordination-mvp) — §4's last item, and the end of §4. A CHECK on each layout column (NULL orjsonb_typeof = 'object') plus the same rule in the PATCH dispatcher, so the caller gets a 400 naming the key rather than a 500 naming nothing. Detail in §4.
8. Dead code sweep
Low priority, but each line here is a line someone will read and believe.
DragOverlay.tsx(99) +LayoutEditor.tsx(51) — imported by nothing. Gated on 5.3.WorkspaceLayout'sisMobile/mobilePanelId/onMobileBackprops — no caller passes them.fixedHeightonAppRegistryEntry— declared, read atWorkspaceRenderer.tsx:76, set by zero metas.registerApp(useAppRegistry) — zero callers.getDefaults(queries/dashboards.ts:136) — zero callers.screens.terminals/screens.hostTerminalscolumns — never written, never read.SELECTED_DASHBOARDconstant — zero consumers.preview:refreshandchat:active-sessionchannels — written byChatPanelWrapper.tsx:58-59, read by nobody. Notepreview:refreshis the exemplardocs/navigation-audit.md:126cites as the canonical good channel; fix the doc too.components/ui/hooks/use-mobile.tsx+ui/sidebar.tsx(~720 lines) — a seconduseIsMobileimplementation, and a sidebar imported by nothing.WorkspaceRenderer.tsx:84— unreachable duplicate condition insidefindChildById. While there: the doc comment says "find a panel node by id anywhere in the tree", but it returns the direct child whose subtree contains the id, which is what the mobile collapse needs. The behaviour is right and the description is wrong — fix the comment, not the code.
9. Tests
-
There are none. No
*.test.*exists under anyWorkspace*or*Screen*path.layout-utils.tsis 214 lines of pure functions over a serialisable tree — the cheapest high-value test target in the codebase, and every item in 5.2 and 5.5 is a regression test waiting to be written. Start here before the mutator work, not after. Promoted to the critical path 2026-08-07 (agent-coordination.md§6.2, A6).e588524put agent identity inside those functions —setContents,swapPanels,movePanel,setPanelConfig,collectPanelConfigs,newPanelFrom— and shipped them with no tests. A regression inswapPanelsnow silently swaps two agents' identities between panels, which is a worse failure than any layout glitch the same bug could previously cause. Resolved85452d1—layout-utils.test.ts, 47 tests, 99.56% lines / 100% functions, the first test under anyWorkspace*path. It paid for itself immediately:setAppwas preservingconfigwhenever the newappTypewas non-null, so switching a panel from chat to terminal handed the terminal the chat's{agentName}to read as its own settings — the exact leak the comment beside it claimed to prevent. Not reachable through the UI (the only route out of an app isonClearApp→null), butsetAppis exported from the barrel. Fixed in the same commit. Two tests are deliberately written to the current behaviour and markedKNOWN GAPso they fail the day it changes: a move dropszoomandfitContent(§5.3). Still untested: the other eleven files of the framework —WorkspaceView,WorkspaceRenderer,PanelSlot,normalizeLayout. §5.5's debounce lost-updates live there, not inlayout-utils. -
A panel is told where it is, instead of being handed a key to reverse-engineer. (
dbe585f+717580f+585f234, branchagent-coordination-mvp) — the first three items of §5.9. The context fielddashboardId: string— which was never an id, always the wholeworkspace.key— is nowworkspace: WorkspaceIdentity | null, parsed once byparseWorkspaceKeyand memoised on the key. Three apps had been parsing the format independently, each with its own idea of what a non-dashboard looks like; they now readkindandid. The rawkeyrides along because it is the addressagent_panels.dashboard_idstores, and a stored address must not depend on which feature wrote it. In the same pass, the two file-browser fields the context had grown were deleted, and with them the prop chain underneath that had no other caller. Net: the context is 16 fields from 18, and four dead parameters and a permanently-false flag are gone from the file browser.