Skip to content

Add a drag-foldable desktop left navigation with rail and focused drawers #487

Description

@BorisTyshkevich

Goal

Make the desktop left navigation foldable without adding collapse/expand buttons.

The user controls the navigation width by dragging its vertical frame border. A sufficiently narrow drag converts the current two-panel sidebar into a compact icon rail. Dragging the rail or its focused drawer wider converts it back into the normal two-panel sidebar.

The compact state must remain fully navigable: clicking a rail icon opens that section as a docked, full-height focused drawer beside the rail. The drawer consumes layout width and pushes the SQL/Dashboard work surface inward; it must not float over or obscure the work surface.

Mobile already uses a different navigation model and is explicitly out of scope.

Phases

Added during /ship (2026-07-29). Branch model: one branch and one PR per phase off fresh
main
— phases 1 and 2 are additive/inert and land green on their own, phase 3 turns the
feature on, phase 4 closes the issue. The <!-- ship-log --> comment is the state of record.

Desktop shell geometry

The application header owns the complete top row across the viewport.

Below the header, the horizontal shell is:

wide state
  [two-panel sidebar] [main Query/Dashboard surface] [optional right inspector]

rail state, drawer closed
  [rail] [main Query/Dashboard surface] [optional right inspector]

rail state, focused drawer open
  [rail] [focused left drawer] [main Query/Dashboard surface] [optional right inspector]

The rail, sidebar, focused drawer, main surface and right inspector all begin below the header.

The left navigation must participate in normal layout. No left-navigation state uses an overlay or backdrop on desktop.

Two presentation states

Wide state

The compact rail is absent.

Show the established two stacked navigation panes:

upper pane
  Databases | Dashboards

lower pane
  Queries | History

After #427, the lower pane becomes:

Library | History

Requirements:

  • preserve the existing upper/lower pane switchers;
  • preserve the horizontal divider between the two panes;
  • preserve search, expansion, scroll and lazy-loaded schema state;
  • preserve the existing sidebar-width preference range unless a new central layout constraint requires a narrower maximum;
  • the vertical frame border at the sidebar's right edge is the only primary wide/rail control;
  • do not add chevron, hamburger, pin or >|< buttons for folding the left navigation.

Rail state

Show a narrow vertical rail below the header.

Recommended visual width:

48 px

The rail contains four section launchers:

Section Icon concept Accessible label
Databases database cylinder Open Databases navigation
Dashboards dashboard/grid Open Dashboards navigation
Queries/Library bookmark or saved document Open Queries navigation before #427, Open Library navigation after #427
History clock/history Open query History

Requirements:

  • use existing icon primitives where possible;
  • show tooltips with section names;
  • show hover, keyboard-focus and active-drawer states distinctly;
  • keep every rail icon visible when a focused drawer is open;
  • the rail width must remain fixed and must not accidentally stretch to the drawer width;
  • the rail has no internal text labels in its normal state;
  • the rail begins below the full-width application header.

Focused drawer behaviour

Clicking a rail section opens that section as a docked drawer immediately to the right of the rail.

Examples:

[rail: Databases active] [Databases drawer] [SQL editor]
[rail: Dashboards active] [Dashboards drawer] [Dashboard surface]
[rail: Library active]   [Library drawer]   [SQL editor]

The focused drawer:

  • uses the remaining vertical height below the header;
  • consumes normal layout width;
  • pushes the Query/Dashboard surface instead of covering it;
  • shows exactly one section;
  • contains that section's title, search and content;
  • does not repeat Databases | Dashboards or Queries/Library | History switcher buttons;
  • remains open after selecting a database, table, Dashboard, query or history item;
  • retains the section's expansion, search and scroll state;
  • uses the same application navigation commands as the wide sidebar;
  • never executes hidden Dashboards merely because the Dashboards section is visible.

Clicking the active rail icon closes the focused drawer and leaves the rail visible.

Clicking a different rail icon switches the drawer content in place without first closing the drawer.

Escape may close the focused drawer when focus is inside it, but must not also cancel a running query or trigger another global Escape action.

Section-specific behaviour

Databases

Show the existing schema search and database/table/column tree with existing click, double-click, Shift-click, drag and documentation actions unchanged.

Dashboards

Show the Dashboard hierarchy from #426 with its search, expansion, keyboard and navigation semantics unchanged.

Queries / Library

Before #427, show the existing Queries collection.

After #427, show only the zero-owner Library projection and update the rail label/tooltip to Library. The foldable shell must not introduce a separate data projection or duplicate ownership logic.

History

Show the existing history search/list and opening behaviour unchanged.

Drag-controlled state transitions

Use the vertical navigation frame border as a resize separator.

The transition must feel deliberate and must not flicker near a single threshold. Use named constants and hysteresis rather than deriving state directly from every pointer pixel.

Recommended starting values, adjustable only through real-browser layout verification:

const LEFT_RAIL_PX = 48;
const LEFT_FOLD_THRESHOLD_PX = 140;
const LEFT_WIDE_THRESHOLD_PX = 260;
const LEFT_PANEL_MIN_PX = 180;
const LEFT_PANEL_MAX_PX = 420;

Wide -> rail

While dragging the wide sidebar border left:

  • resize normally within the wide range;
  • once the proposed width passes the fold threshold, commit rail mode;
  • remember the last useful wide width;
  • remove the wide two-pane sidebar from layout and expose the fixed rail;
  • do not leave a partially clipped wide sidebar.

Rail -> wide

While dragging the rail's right frame border to the right:

  • show deterministic resize feedback;
  • once the drag passes the wide threshold, restore wide mode;
  • restore the last useful wide width, or the threshold width when no prior width exists;
  • the rail disappears completely in wide mode.

Focused drawer resize

When a focused drawer is open, its right border is the active navigation resize separator.

  • dragging resizes the focused drawer within the desktop bounds;
  • dragging sufficiently narrow folds the focused drawer closed, leaving the rail;
  • dragging beyond the wide threshold converts the navigation to the full two-pane wide sidebar;
  • conversion must preserve the currently selected upper/lower section and all section UI state.

Do not rely on CSS width alone to decide the semantic mode after every repaint. The mode is explicit application-shell state.

Layout state

Suggested shell state:

type LeftNavigationMode = 'wide' | 'rail';
type LeftNavigationSection = 'databases' | 'dashboards' | 'library' | 'history';

interface DesktopLeftNavigationState {
  mode: LeftNavigationMode;
  wideWidthPx: number;
  focusedDrawerWidthPx: number;
  focusedSection: LeftNavigationSection | null;
}

Persistence:

  • mode, wideWidthPx and focusedDrawerWidthPx are browser preferences;
  • focusedSection is session UI state and need not reopen automatically after reload;
  • none of this state belongs in StoredWorkspaceV3, Dashboard documents or query specs;
  • selected Databases/Dashboards and Queries/Library/History roles continue to use their existing UI-state ownership.

The persisted preference decoder must clamp invalid or obsolete values safely.

Shared navigation state

Wide and focused presentations are two views over the same navigation state.

Preserve across transitions:

  • schema search text;
  • schema database/table expansion;
  • loaded-column cache;
  • schema scroll position;
  • Dashboard search and expansion;
  • Dashboard keyboard/current-row state;
  • Queries/Library search and editing state where safe;
  • History search;
  • upper/lower split percentage;
  • current application surface and editor/result state.

Do not create separate competing expansion/search stores for wide and focused presentations.

A transition may move DOM focus to the corresponding control in the new presentation, but it must not silently reset the user's state.

Interaction with drag-and-drop assignment

The implementation must remain compatible with #428.

When a Library-query drag is active in rail mode:

  • the Dashboards rail icon can be exposed as a valid hover launcher;
  • bounded hover may open the docked Dashboards focused drawer;
  • the user can then drop on Dashboard, Panels or Filters targets;
  • leaving/cancelling the drag clears temporary hover state;
  • this must never turn the drawer into an overlay.

This integration may be completed by #428 if #428 has not yet landed, but the left-navigation API must provide a deterministic openFocusedSection('dashboards') seam.

Main-surface resizing

Every width or mode change must notify/reflow the active centre surface correctly:

  • CodeMirror/editor geometry;
  • result table and virtualised rows;
  • Query editor/results splitter;
  • Dashboard layout engines and charts;
  • sticky Dashboard/filter/toolbars;
  • optional right inspector.

Use existing resize observers/layout hooks rather than arbitrary delayed resize events.

Left and right docked panels may be open simultaneously. Clamp widths so the centre retains a documented minimum usable width.

Keyboard and accessibility

The vertical frame border is a keyboard-operable separator:

role="separator"
aria-orientation="vertical"
tabindex="0"
aria-valuemin / aria-valuemax / aria-valuenow

Keys:

  • Left/Right Arrow resize by a small step;
  • Shift+Left/Right resize by a larger step;
  • Home folds to rail;
  • End restores wide mode;
  • announce semantic mode changes through an appropriate live/status mechanism without excessive chatter.

Rail buttons must expose aria-expanded and aria-controls for the focused drawer.

Closing a focused drawer returns focus to its rail icon. Converting to wide mode restores focus to the corresponding section tab/search/tree when possible.

Mobile

At the existing mobile breakpoint:

  • do not render the desktop rail or desktop focused drawer;
  • retain the established mobile segmented and bottom navigation;
  • ignore desktop folding preferences for effective mobile layout;
  • preserve those preferences for the next desktop session.

Suggested code boundaries

App shell layout controller

Owns:

  • explicit wide/rail mode;
  • widths and thresholds;
  • vertical resize separator;
  • focused drawer host;
  • preference persistence;
  • centre-width clamping and resize notifications.

Navigation section registry

Maps rail sections to existing views and labels without duplicating their domain state.

Existing navigation views

Continue to own their tree/list rendering, search and application actions. They must not manipulate shell widths directly.

Delivery phases

Each phase's definition of done is its implement list below, the ## Tests subsections it
names, and the subset of ## Acceptance criteria it claims. ## Non-goals applies to all four.

Phase 1 — Layout core and preferences

Pure layout-decision logic plus its persisted preferences. No visible change.

Implement:

  • src/core/left-nav-layout.ts (new, pure, 100% covered): the named constants
    (LEFT_RAIL_PX, LEFT_FOLD_THRESHOLD_PX, LEFT_WIDE_THRESHOLD_PX, LEFT_PANEL_MIN_PX,
    LEFT_PANEL_MAX_PX), the LeftNavigationMode / LeftNavigationSection types, the
    hysteresis reducer mapping (current mode, proposed px) to the next mode and width,
    wide-width clamping, and the keyboard step resolver (small step, large step, Home folds
    to rail, End restores wide);
  • src/state.ts: mode, wideWidthPx and focusedDrawerWidthPx as persisted browser
    preferences, focusedSection as session-only UI state, their KEYS entries, and decoders
    that clamp invalid or obsolete values back to the documented defaults;
  • src/application/app-preferences.ts: the new keys added to PreferenceKey.

Tests: ## TestsState and preferences, in full — including the assertion that none of
this state reaches StoredWorkspaceV3, Dashboard documents or query specs.

Also here, because the mobile rule is a layout decision and not a rendering one:
effectiveLeftNavigationLayout(layout, isMobile), the projection that makes mobile ignore
rail mode and any open drawer while leaving both persisted widths untouched.

Moved out of this phase during implementation: the centre-width clamp. Review established
that a single total-width-in/total-width-out signature is the wrong shape — there is no inverse
from a total back to (mode, panel width), so it can return a width no mode can render (220px
is neither the rail, nor a legal wide sidebar, nor rail+drawer). Phase 3 owns it, as
layout-in/layout-out: phase 3 is what turns the feature on, and parking a required safety
constraint in phase 4 would leave a merged interval in which left and right docked panels can
starve the centre surface. Deferring an unused primitive out of phase 1 is CLAUDE.md hard rule
5; deferring a safety constraint past the feature it protects is not. (This paragraph said
"phase 4" until phase 2 reconciled it — the phase 3 and 4 sections below, and the
<!-- ship-log --> comment, have said phase 3 since phase 1 shipped.)

Acceptance subset: claims none of the eight global criteria; all eight defer to phases 3
and 4. This phase's gate is the State-and-preferences list plus 100% coverage of the new core
module.

Phase 2 — Navigation section registry

Make the four navigation sections hostable by any container, over one shared store per
section. The wide sidebar renders exactly as it does today.

Implement:

  • the navigation section registry: each LeftNavigationSection mapped to its label, icon,
    accessible label and its persistent host element — built once and never rebuilt,
    extending the buildSidebarUpper pattern (Add a Databases/Dashboards switcher and dashboard hierarchy tree #426) from Databases/Dashboards to the lower
    pane's Library/History;
  • src/ui/app-shell.ts: compose today's two-pane sidebar out of the registry's hosts;
  • no second competing search/expansion/scroll store — the registry hands out the same hosts
    both presentations will later use, so state survives by construction.

Tests: extend tests/unit/app-shell.test.ts and tests/unit/sidebar-upper.test.ts — the wide
pane composition, and exactly one host instance per section. ## TestsWide state
bullets 1–4.

Acceptance subset: claims none; this phase's gate is "existing navigation behaviour is
unchanged".

Phase 3 — Rail, focused drawer and the resize separator

The feature turns on.

Implement:

  • src/ui/left-rail.ts: the LEFT_RAIL_PX rail with four launchers reusing the existing
    database / dashboard / layers / history icon primitives, tooltips, distinct hover /
    keyboard-focus / active-drawer states, aria-expanded + aria-controls, a width that
    cannot stretch to the drawer's, and no internal text labels; mounted below the full-width
    header;
  • Deviation, shipped instead of a separate src/ui/left-drawer.ts: the existing
    .sidebar element is RE-PRESENTED as the drawer via one data-nav-mode attribute
    ('wide' | 'rail' | 'drawer'), written by one function (app-shell.ts's
    applyEffectiveLeftNavigationLayout) that is the sole authority for every presentation
    attribute. No section host ever moves between containers — phase 2's persistent-host
    guarantee ("wide and focused presentations share and preserve all navigation state") holds
    by construction rather than by a second module's own restoration logic. See the
    <!-- ship-log --> comment's phase-3 handoff for the full rationale;
  • openFocusedSection(section) on the app controller — the deterministic seam Add Library drag assignment to Dashboard panels and variables #428 requires;
  • src/ui/app-shell.ts: render either the wide sidebar or rail + drawer from the explicit
    shell mode, keeping every section host mounted across the transition;
  • Deviation, shipped instead of a new splitters.ts axis: a dedicated
    src/ui/left-nav-separator.ts module REPLACES splitters.ts's old 'col' axis outright
    (deleted, not left beside the new module) — feeding proposed widths through phase 1's
    reducer via a resize session, with safe termination on blur/visibilitychange. A fifth
    competing axis would have kept two sidebar-width authorities (the old bare clamp and the
    new mode reducer) alive at once;
  • the separator itself: role="separator", aria-orientation="vertical", tabindex="0",
    aria-valuemin / aria-valuemax / aria-valuenow, Left/Right, Shift+Left/Right, Home,
    End, and a status announcement on semantic mode change only;
  • Escape closes the focused drawer when focus is inside it without reaching any global Escape
    action; closing returns focus to the rail icon; converting to wide restores focus to the
    corresponding section tab, search or tree;
  • mobile: neither rail nor focused drawer renders below the breakpoint, and the desktop
    preferences are preserved for the next desktop session;
  • the centre-width clamp, as a layout-in/layout-out function so its result always names a
    renderable (mode, panel width) rather than a bare total no mode can express. Moved out of
    phase 1 (its caller lives here, and the total-in/total-out shape it had was wrong) but
    deliberately not deferred to phase 4: this is the phase that turns the feature on, and
    shipping activation without it would leave a merged interval where left and right docked
    panels can starve the centre surface. A required safety constraint cannot land after the
    feature it protects;
  • mode-aware aria-valuetext on the separator. The numeric range is honest at both
    extremes, but its interior is not continuous (49–179 is no resting width in any mode) and one
    number can mean either a wide sidebar or rail+drawer, so a bare aria-valuenow does not
    communicate position.

Tests: ## TestsRail state, Focused drawer and Drag transitions in full, plus
Wide state bullet 5.

Acceptance subset: claims bullets 1–6 and 8. Defers bullet 7 (header, main surface and
optional right inspector resize correctly) to phase 4.

Phase 4 — Reflow, integration and verification

Implement:

  • reflow the active centre surface on every width or mode change through the existing
    resize observers and layout hooks — CodeMirror geometry, the result table and its
    virtualised rows, the editor/results splitter, Dashboard layout engines and charts, sticky
    Dashboard/filter toolbars, and the optional right inspector — never arbitrary delayed
    resize events;
  • verify the centre-width clamp landed in phase 3 holds up under real reflow;
  • the Add Library drag assignment to Dashboard panels and variables #428 integration: during an active Library-query drag in rail mode, bounded hover on the
    Dashboards rail icon opens the docked Dashboards drawer via openFocusedSection('dashboards'),
    drops land on Dashboard / Panels / Filters targets, leaving or cancelling the drag clears the
    temporary hover state, and the drawer never becomes an overlay;
  • tests/e2e/left-nav-fold.spec.js and a real-browser verification pass — happy-dom sees no
    CSS layout, so the recommended threshold values are confirmed here.

Tests: ## TestsReflow and regressions, in full, plus the new e2e spec.

Acceptance subset: claims bullet 7 and re-verifies 1–8 end to end. This phase closes the
issue.

Tests

State and preferences

  • fresh desktop session starts from the documented default;
  • valid mode/width preferences restore;
  • invalid widths clamp safely;
  • left state is never written to workspace JSON;
  • mobile ignores desktop effective state while preserving preferences.

Wide state

  • rail is absent;
  • upper and lower panes render together;
  • switchers and horizontal splitter remain;
  • existing navigation behaviour is unchanged;
  • dragging left below the fold threshold enters rail mode once.

Rail state

  • rail starts below the full-width header;
  • exactly four section icons remain visible;
  • icons remain visible while a drawer is open;
  • active icon is marked;
  • clicking active icon closes the drawer;
  • clicking another icon switches content;
  • rail never expands to drawer width.

Focused drawer

  • drawer is docked and reduces centre width;
  • no overlay/backdrop is created;
  • no redundant role switchers render inside it;
  • selected resources do not auto-close it;
  • search/expansion/scroll state survives section and mode changes;
  • Escape and focus restoration are correct.

Drag transitions

  • wide -> rail uses hysteresis without oscillation;
  • rail -> wide restores the prior width;
  • focused drawer can fold closed or expand to wide;
  • pointer cancellation and window blur terminate drag safely;
  • keyboard separator operations match pointer transitions.

Reflow and regressions

  • editor, results and Dashboard resize correctly;
  • a running query is not cancelled by folding navigation;
  • Query/Dashboard surface state is preserved;
  • right inspector can coexist;
  • database tree and Dashboard tree gestures are unchanged;
  • Add Library drag assignment to Dashboard panels and variables #428 hover-opening can target the docked Dashboards drawer;
  • no mobile regression.

Acceptance criteria

  • Desktop users can fold the left sidebar into a compact rail solely by dragging the vertical frame border.
  • Dragging wider restores the normal two-pane sidebar without a separate expand button.
  • Rail icons open Databases, Dashboards, Queries/Library or History as full-height docked drawers.
  • Focused drawers push the work surface and never float over it.
  • The rail remains visible and correctly sized while a drawer is open.
  • Wide and focused presentations share and preserve all navigation state.
  • Header, main surface and optional right inspector resize correctly.
  • Mobile behaviour is unchanged.

Non-goals

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions