Found while building layout-driving extensions downstream (spolakh fork). Three related ways the panel layout reaches a state where the app looks alive but keyboard navigation is dead, with no runtime recovery. All verified against current master (ec49e85b); line numbers from there. Repro recipes at the bottom were confirmed in a live tab.
1. Closing the last pane leaves an unrecoverable empty workspace
close_current_panel (src/shortcuts/defaultShortcuts.ts:304-312) calls deletePanelRow with no last-pane guard. For the last top-level row, nextActivePanelAfterClose walks to the layout session, finds no sibling, and returns undefined (src/utils/panelLayoutProjection.ts:126-144) — so the row is deleted, activePanelId becomes undefined, and zero panel rows remain. The only "can't close the last pane" protection is the cosmetic canClosePanel gate on the X button; the keyboard binding and API callers bypass it.
Consequences of the zero-row state:
- The layout renders an empty canvas (header only).
- NORMAL_MODE is activated per rendered block surface — with no blocks rendered it never activates, so every block-scoped shortcut is dead (j/k, leaders, even Ctrl+W itself). Only GLOBAL actions still work (command palette, and any GLOBAL binding that routes through
navigate(), which is the accidental escape hatch).
- There is no runtime repair:
applyCurrentLayoutUrl returns {kind:'empty'} and does nothing (src/utils/panelLayoutProjection.ts:709-730); the empty→landing repair runs only in bootstrapWorkspace. A comment in src/bootstrap/workspaceBootstrap.ts ("later empty-layout navigation until a full reload") suggests this is at least partially known. In practice the user is stuck until a full reload.
The same state is reachable from the API: reconcilePanelRows(repo, session, []).
Suggested fix: guard the last-row case in deletePanelRow (either refuse, or swap the pane's content to the workspaceLandingFacet-resolved landing block instead of deleting the row) — and/or make the runtime {kind:'empty'} result trigger the same landing resolution that bootstrap uses.
2. A pane whose block is missing ("Loading block...") strands keyboard nav when it's the active panel
A panel row whose topLevelBlockId points at a deleted or never-synced block renders a perpetual MissingDataRenderer "Loading block..." (src/components/renderer/MissingDataRenderer.tsx) with nothing focusable inside. When that pane is the active panel:
- no block inside it can ever be
blockInFocus,
- every other pane fails
panelSurfaceActive (activePanelId !== panelId, src/extensions/useShortcutSurfaceActivations.ts:65-67),
so NORMAL_MODE has zero active instances app-wide — both panes look fine, but only GLOBAL shortcuts respond. Recovery requires a pointer click on a healthy pane (invisible affordance for a keyboard-driven user); clicking the void pane keeps it active and dead.
Reachable via: any client deleting a block that another pane/device currently displays; stale URLs/sessions carrying since-deleted ids (parseSlot treats any token as a block id); API callers passing a bad id.
(Related, now largely mitigated: reconcilePanelRows repairs a dangling activePanelId via activePanelIdAfterReconcile — nice — but the "valid row, missing block" case above has the same user-facing symptom and no repair.)
Suggested fix: treat "active panel's block confirmed missing" as repairable — e.g. MissingDataRenderer (or the panel shell) offering a close/go-home affordance, or a repair effect that closes/swaps panes whose block is tombstoned.
3. reconcilePanelRows accepts unvalidated slot ids → ghost rows the URL projection cannot remove
reconcilePanelRows/createPanelRowInTx accept any string as a target block id, including ''. An empty-id row renders nothing, and layoutSlotsFromRows drops blockId-less rows from the serialized layout (src/utils/panelLayoutProjection.ts:227-228) — so the ghost row exists in the session and the DOM grid but is invisible to every URL-driven reconcile (sameLayoutSlots never sees it), and can never be cleaned up by navigation. Session rows / URL / rendered panes end up mutually inconsistent.
Suggested fix: validate/filter slot ids at the reconcilePanelRows boundary (non-empty at minimum; optionally repo.exists for stronger hygiene given #2).
Repro (live tab, dev console or agent eval)
const { getUIStateBlock, getLayoutSessionBlock } = await import('@/data/stateBlocks.js')
const { getLayoutSessionId } = await import('@/utils/layoutSessionId.js')
const { reconcilePanelRows } = await import('@/utils/panelLayoutProjection.js')
const ui = await getUIStateBlock(repo, repo.activeWorkspaceId, repo.user, {})
const session = await getLayoutSessionBlock(ui, getLayoutSessionId())
await session.load()
// #1 — empty workspace (or: reduce to one pane and press Ctrl+W)
await reconcilePanelRows(repo, session, [])
// → blank canvas; j/k and leader keys dead; palette works; only reload recovers.
// #2 — void pane; make it active by clicking its (empty) column
await reconcilePanelRows(repo, session, ['00000000-dead-4bad-a000-000000000000', '<some real block id>'])
// → left pane stuck on "Loading block..."; with it active, keyboard dead app-wide.
// #3 — ghost row
await reconcilePanelRows(repo, session, ['', '<some real block id>'])
// → session has 2 panel rows, URL serializes 1, and no navigation removes the ghost.
Downstream we're shipping an extension-side watchdog (zero-row repair via navigate(), ghost-row cleanup, grace-perioded void-pane swap) as a workaround, but #1 especially feels like it wants the core guard — happy to provide more detail or test a fix.
Found while building layout-driving extensions downstream (spolakh fork). Three related ways the panel layout reaches a state where the app looks alive but keyboard navigation is dead, with no runtime recovery. All verified against current
master(ec49e85b); line numbers from there. Repro recipes at the bottom were confirmed in a live tab.1. Closing the last pane leaves an unrecoverable empty workspace
close_current_panel(src/shortcuts/defaultShortcuts.ts:304-312) callsdeletePanelRowwith no last-pane guard. For the last top-level row,nextActivePanelAfterClosewalks to the layout session, finds no sibling, and returnsundefined(src/utils/panelLayoutProjection.ts:126-144) — so the row is deleted,activePanelIdbecomes undefined, and zero panel rows remain. The only "can't close the last pane" protection is the cosmeticcanClosePanelgate on the X button; the keyboard binding and API callers bypass it.Consequences of the zero-row state:
navigate(), which is the accidental escape hatch).applyCurrentLayoutUrlreturns{kind:'empty'}and does nothing (src/utils/panelLayoutProjection.ts:709-730); the empty→landing repair runs only inbootstrapWorkspace. A comment insrc/bootstrap/workspaceBootstrap.ts("later empty-layout navigation until a full reload") suggests this is at least partially known. In practice the user is stuck until a full reload.The same state is reachable from the API:
reconcilePanelRows(repo, session, []).Suggested fix: guard the last-row case in
deletePanelRow(either refuse, or swap the pane's content to theworkspaceLandingFacet-resolved landing block instead of deleting the row) — and/or make the runtime{kind:'empty'}result trigger the same landing resolution that bootstrap uses.2. A pane whose block is missing ("Loading block...") strands keyboard nav when it's the active panel
A panel row whose
topLevelBlockIdpoints at a deleted or never-synced block renders a perpetualMissingDataRenderer"Loading block..." (src/components/renderer/MissingDataRenderer.tsx) with nothing focusable inside. When that pane is the active panel:blockInFocus,panelSurfaceActive(activePanelId !== panelId,src/extensions/useShortcutSurfaceActivations.ts:65-67),so NORMAL_MODE has zero active instances app-wide — both panes look fine, but only GLOBAL shortcuts respond. Recovery requires a pointer click on a healthy pane (invisible affordance for a keyboard-driven user); clicking the void pane keeps it active and dead.
Reachable via: any client deleting a block that another pane/device currently displays; stale URLs/sessions carrying since-deleted ids (
parseSlottreats any token as a block id); API callers passing a bad id.(Related, now largely mitigated:
reconcilePanelRowsrepairs a danglingactivePanelIdviaactivePanelIdAfterReconcile— nice — but the "valid row, missing block" case above has the same user-facing symptom and no repair.)Suggested fix: treat "active panel's block confirmed missing" as repairable — e.g.
MissingDataRenderer(or the panel shell) offering a close/go-home affordance, or a repair effect that closes/swaps panes whose block is tombstoned.3.
reconcilePanelRowsaccepts unvalidated slot ids → ghost rows the URL projection cannot removereconcilePanelRows/createPanelRowInTxaccept any string as a target block id, including''. An empty-id row renders nothing, andlayoutSlotsFromRowsdrops blockId-less rows from the serialized layout (src/utils/panelLayoutProjection.ts:227-228) — so the ghost row exists in the session and the DOM grid but is invisible to every URL-driven reconcile (sameLayoutSlotsnever sees it), and can never be cleaned up by navigation. Session rows / URL / rendered panes end up mutually inconsistent.Suggested fix: validate/filter slot ids at the
reconcilePanelRowsboundary (non-empty at minimum; optionallyrepo.existsfor stronger hygiene given #2).Repro (live tab, dev console or agent eval)
Downstream we're shipping an extension-side watchdog (zero-row repair via
navigate(), ghost-row cleanup, grace-perioded void-pane swap) as a workaround, but #1 especially feels like it wants the core guard — happy to provide more detail or test a fix.