Skip to content

Keyboard Canvas Navigation & Cross Panel Requests

dazeb edited this page Sep 17, 2026 · 2 revisions

Keyboard Canvas Navigation & Cross-Panel Requests

Purpose

This page covers three tightly related mechanisms in the renderer:

  1. Keyboard-driven node traversal — a deterministic reading order over canvas nodes so keyboard users can move selection predictably.
  2. Edge reveal / keep-open behavior — the small state machine and geometry helpers that decide when the file-tree chrome reveals at a canvas edge and when it stays open.
  3. Cross-panel request buses — one-shot Zustand stores that let UI rendered outside the Canvas or sidebar provider ask the canvas or sidebar to perform an action on the next render.

These pieces are intentionally separate from the live node store: Canvas.tsx remains the single live source of truth for node state, while the helpers here are pure or request-only.

Key files

File Responsibility
src/renderer/src/state/canvas-knav.ts Pure keyboard navigation order: NAV_ORDER, nextSelection.
src/renderer/src/state/edge-reveal.ts Pure file-tree chrome state, reveal/close actions, edge hot-zone and keep-open geometry.
src/renderer/src/state/canvas-requests.ts One-shot canvas spawn/switch/organize/relay requests from outside the Canvas provider.
src/renderer/src/state/sidebar-requests.ts One-shot sidebar section requests from outside the sidebar host.
src/renderer/src/canvas/Canvas.tsx React Flow canvas; live node state; consumes canvas requests and owns canvas-side behavior.
src/renderer/src/canvas/AGENTS.md Confirms Canvas is the single live source of truth for node state and hosts canvas menus.

Keyboard traversal

Keyboard traversal is implemented as pure functions in canvas-knav.ts. The module has no DOM, React Flow, or Electron dependency, so it remains unit-testable.

NAV_ORDER

NAV_ORDER(nodes) produces a stable reading order:

  1. Filter out nodes where type === 'group'.
  2. Filter out nodes with a parentId.
  3. Sort by:
    • position.y ascending
    • then position.x ascending
    • then id.localeCompare

The exclusions mirror isOrganizable from workspace.ts: group frames and parented children do not participate in the traversal cycle. The function copies before sorting, so the input array is not mutated. It is generic over NavNode, allowing React Flow Node objects to pass through without re-wrapping.

flowchart TD
  N[nodes array] --> F["filter: type !== 'group' && !parentId"]
  F --> C[copy]
  C --> S[sort by y asc, x asc, id asc]
  S --> O[NAV_ORDER result]
  O --> NS[nextSelection]
Loading

Key nodes:

  • Filter removes group frames and parented children so keyboard focus follows the top-level reading order.
  • Stable sort gives deterministic behavior even when nodes share coordinates.
  • nextSelection consumes the order and applies direction/wrap logic.

nextSelection

nextSelection(nodes, currentId, dir) resolves the next focus target:

  • dir = 1 moves forward; dir = -1 moves backward.
  • Wraps at both ends.
  • null current id, or an id no longer on the canvas, starts at the first node for dir = 1 or the last node for dir = -1.
  • A single navigable node always resolves to itself.
  • No navigable nodes resolves to null.

This makes keyboard traversal resilient to selection loss: if the current node was deleted or is not in the navigable set, the next Tab/Shift-Tab lands at a predictable end of the reading order.

Edge reveal and keep-open behavior

edge-reveal.ts models the VS Code-style edge sidebar chrome. It defines:

type TreeSide = 'left' | 'right'
type SidebarSection = 'files' | 'source' | 'plugins'

interface FileTreeChrome {
  side: TreeSide
  open: boolean
  pinned: boolean
  section: SidebarSection
}

The initial state is:

  • side: left
  • open: false
  • pinned: false
  • section: files

Chrome state machine

applyFileTreeChrome handles these actions:

Action Effect
reveal Sets side and opens.
flipSide Toggles left/right and opens.
togglePin Toggles pinned and opens.
requestClose Closes only if not pinned.
switchSection Sets the active section and opens.

requestClose explicitly refuses to close while pinned, so a pinned sidebar is stable even if surrounding focus or pointer state requests close. The active section survives close/reopen.

stateDiagram-v2
  [*] --> Closed: initialFileTreeChrome()
  Closed --> Open: reveal / flipSide / togglePin / switchSection
  Open --> Open: reveal / flipSide / togglePin / switchSection
  Open --> Open: requestClose when pinned
  Open --> Closed: requestClose when not pinned
Loading

Key transitions:

  • Any reveal-like action forces open: true.
  • requestClose is the only action that can close, and only when unpinned.
  • The section is independent of open/closed state.

Geometry helpers

Two pure geometry functions decide reveal and keep-open behavior:

  • edgeHotZone(x, width, zone = 12)

    • Returns null if width <= 0.
    • Returns 'left' if x <= zone.
    • Returns 'right' if x >= width - zone.
    • Otherwise returns null.
  • shouldKeepTreeOpen({ x, width, side, panelWidth })

    • For side === 'left', keeps open while x <= panelWidth.
    • For side === 'right', keeps open while x >= width - panelWidth.

These helpers are pointer-position based: the hot zone determines when the pointer is hugging a canvas edge, and the keep-open check determines whether the pointer is still over the open panel or its hot strip.

Cross-panel request buses

The Canvas provider and sidebar host do not wrap every piece of UI. The settings panel renders outside the Canvas provider, and the cog menu in the app toolbar renders outside the sidebar host. Those components cannot call useCanvas() or the sidebar directly.

To preserve Canvas as the single source of truth, the app uses two one-shot Zustand request stores.

useCanvasRequests

canvas-requests.ts carries a one-shot CanvasSpawnRequest:

type CanvasSpawnRequest =
  | { kind: 'agentLogin'; command: string }
  | { kind: 'browser'; url: string }
  | { kind: 'switchProject'; projectId: string }
  | { kind: 'organize' }
  | { kind: 'relayTerm'; term: string; title?: string }

The store shape is:

interface CanvasRequestsState {
  request: CanvasSpawnRequest | null
  spawn(request: CanvasSpawnRequest): void
  consume(): void
}

Canvas consumes the request on its next render and then clears it. Requests never mutate Canvas node state directly.

useSidebarRequests

sidebar-requests.ts carries a one-shot sidebar section request:

interface SidebarRequestsState {
  request: SidebarSection | null
  openSection(section: SidebarSection): void
  consume(): void
}

The cog menu publishes a section request; the file tree consumes it on its next render.

Request flow

sequenceDiagram
  participant External as Settings panel / cog menu
  participant Bus as useCanvasRequests / useSidebarRequests
  participant Canvas as Canvas.tsx
  participant Tree as FileTree / sidebar host

  External->>Bus: spawn(request) or openSection(section)
  Bus-->>Canvas: request visible on next render
  Canvas->>Bus: consume()
  Bus-->>Tree: section request visible on next render
  Tree->>Bus: consume()
Loading

Key nodes:

  • External producer — any component outside the Canvas/sidebar provider.
  • Request bus — a transient field, not a persistent command queue.
  • Consumer — Canvas or sidebar host reads and then calls consume().
  • Single source of truth — Canvas applies the requested mutation itself; the bus only carries intent.

Boundaries and invariants

  • canvas-knav.ts is pure: no DOM, no React Flow, no Electron.
  • edge-reveal.ts is pure state/geometry logic; it does not render or directly observe pointers.
  • canvas-requests.ts and sidebar-requests.ts are one-shot buses. If a consumer does not call consume(), the request remains visible on subsequent renders.
  • Canvas remains the single live source of truth for nodes; request buses must not become a second node store.
  • Group frames and parented children are excluded from keyboard traversal by design.
  • requestClose cannot close a pinned sidebar.
  • edgeHotZone returns null for non-positive widths, avoiding edge reveals on invalid measurements.
  • nextSelection handles empty, single-node, missing-current-id, and wrap-around cases explicitly.

Extension points

  • Add a new canvas-side command by extending the CanvasSpawnRequest union and handling it in Canvas’s consume path.
  • Add a new sidebar section by extending SidebarSection; existing close/reopen behavior preserves the active section.
  • Adjust keyboard traversal exclusions or ordering inside NAV_ORDER if a future node kind should be navigable or skipped.
  • Tune the edge reveal hot-zone width via the zone parameter of edgeHotZone.
  • Reuse the one-shot request pattern for any other UI that renders outside the provider owning the target state.

Sources: src/renderer/src/state/canvas-knav.ts, src/renderer/src/state/edge-reveal.ts, src/renderer/src/state/canvas-requests.ts, src/renderer/src/state/sidebar-requests.ts, src/renderer/src/canvas/AGENTS.md, src/renderer/src/state/AGENTS.md

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally