Skip to content

Layout dead-ends: closing the last pane leaves an unrecoverable empty workspace; an active pane on a missing block strands keyboard nav #334

Description

@spolakh

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions