Skip to content

feat(#426): Databases/Dashboards switcher and a read-only Dashboard hierarchy tree - #444

Merged
BorisTyshkevich merged 10 commits into
mainfrom
feat/dashboard-tree-426
Jul 25, 2026
Merged

feat(#426): Databases/Dashboards switcher and a read-only Dashboard hierarchy tree#444
BorisTyshkevich merged 10 commits into
mainfrom
feat/dashboard-tree-426

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What & why

Closes #426.

The upper-left pane gains an equal-role Databases | Dashboards tab row over two
persistent hosts
, and every stored Dashboard becomes reachable as a read-only
Dashboard → Filters → Panels tree. Until now #424's collection was fully persisted
and validated but only its first entry was reachable from the UI.

Switching roles only hides a host, so the schema search text and input focus,
expansion, lazily-loaded columns and scroll are preserved by construction rather
than by save/restore logic — as are the sidebar width and the upper/lower splitter,
which belong to the .side-pane nothing here replaces.

The tree

Collection order; Filters always before Panels; both group rows stay visible
while a Dashboard is expanded, including when empty (#428 will use them as stable
drop targets). Panel counts exclude filters. Search covers all six documented fields
and exposes matching paths at presentation time, so clearing it restores exactly
the expansion and scroll position the user had. Full ARIA tree: role="tree" /
role="treeitem", aria-level, aria-expanded on expandables only, a roving
tabindex, Up/Down/Left/Right/Home/End/Enter/Shift+Enter, and a keyboard-reachable
trailing action menu carrying every operation otherwise reachable only by
double-click.

Gestures are mutually exclusive, which is the one thing #426 explicitly forbids
copying from the schema tree. That tree detects a double-click after the fact — its
single action runs immediately and the double runs in addition — which is harmless
when the first action is expansion, but a panel row's single action opens a query, so
detection would flash the Query surface and write a history entry on the way to a
double-click. New pure core/tree-click-arbiter.ts (injected timers) schedules the
single for the double-click window and cancels it outright on a second press. The
disclosure chevron stays the instant path for expansion.

Strictly read-only: it never clones, repairs, rejects or rewrites a reference. A
broken reference renders a diagnostic row with Dashboard navigation still available,
while a curated filter that simply has no option source yet is treated as the
transitional state it is before #427 — not an error.

#425 integration

Repeated member navigation inside an already-open Dashboard is now delivered in
place
, through the SurfaceCommandPort that already existed (generation-stamped,
installed by the Dashboard render, cleared on every surface transition) rather than a
second port:

focusMember(member): 'ok' | 'pending' | 'missing'

pending is deliberately not a failure — it means "not deliverable in place right
now", and the caller falls back to the normal render transition. MainSurfaceState
splits #425's single focus into retained currentMember (what the tree marks) and
consumed-once pendingFocus (a DOM delivery owed), so consuming a delivery no longer
erases the highlight.

Header cleanup

The obsolete SQL Browser | Dashboard pair is gone (brand zone is now
non-interactive Altinity® SQL Browser), along with dashboardNav — which I found
was built but never appended to any parent, with a renderDashboardNav() that
only ever set hidden = false, kept in sync from three call sites. Removing it
orphaned actions.showDashboard, so that went too, with its dead CSS.

Back to query returns to the Dashboard toolbar. #437 removed it because the
header pair covered it; this PR removes the pair. Together they would have left g w
and "click a saved query" as the only routes back — and on a phone the mobile rules
drop the sidebar and the bottom nav for a full-bleed Dashboard, so there would have
been none. It comes back icon-first inside #437's single compact row.

Owner decisions at ship time

  • The mobile segmented control's upper label is Explore, not "Schema" — that pane
    now holds both roles. Internal data-seg/.schema-pane selectors unchanged.
  • Seven smaller calls are recorded in the branch commits (keying on immutable
    workspace id; upperRole session-only so "default to Databases for a fresh
    session" survives a reload; a matched Dashboard shows its full hierarchy; group rows
    toggle immediately; the Databases count is omitted while the schema loads; mid-wave
    tile focus lands immediately while a filter defers; DashboardRenderTarget.focus
    stays unrenamed).

Review

Two independent read-only review passes found seven real defects, each verified
against the code and fixed in 7e4c67f with a test named for it:

  1. In-place focus on a panel filtered out by the Dashboard's own tile search was a
    silently dead click — tileEls is a write-only cache and the layout reconcilers
    rebuild from the filtered set, so the card was detached; every DOM op no-opped
    and focusMember still returned ok.
  2. A group row's chevron was inert during a search and wrote hidden expansion that
    surfaced only after clearing it.
  3. A View/Edit switch discarded the current member, contradicting the spec.
  4. pruneTreeUi had no production caller — dead code standing in for a
    requirement.
  5. Pending click timers weren't cancelled on workspace change or disposal.
  6. The action-menu button reused the chevron glyph, which Add a Databases/Dashboards switcher and dashboard hierarchy tree #426 forbids.
  7. Menu/group clicks cancelled pending singles on unrelated rows.

The Codex pass then found four more; three verified and are fixed in 816d3b7:

  • Row keys could collide. Dashboard/tile/filter ids are schema-constrained only
    to \S, so a colon is legal and an imported bundle preserves whatever ids it
    carried — Dashboard a:tile:b produced the same key as tile b of Dashboard a.
    Two rows claiming tabindex="0", focus landing on the wrong one, and the arbiter
    reading two distinct resources as a double-click. Components are now escaped.
  • The search box did not follow a workspace switch — the tree filtered by the new
    workspace's text while the box still showed the old one.
  • "Disabled" menu items were only visually disabled, so assistive technology
    announced an enabled action and keyboard activation silently did nothing.
    MenuRow gains a semantic disabled.

Its fourth finding — "pure logic must live in src/core/ per hard rule 2" — is
rejected: src/core/ may not import src/workspace/ (check-boundaries.mjs),
and the tree model reads both the workspace aggregate and MainSurfaceState.
src/application/main-surface.ts established and documented exactly this placement
in #425 for the same reason.

Verification

  • npm test172 files / 5422 tests, per-file coverage gate met; all six new
    modules at 100% statements/lines.
  • npm run build — clean; no new runtime dependency.
  • npx playwright test232 passed on chromium + webkit, zero failures. Firefox
    cannot launch a context in this sandbox (unshare CLONE_NEWPID EPERM), so CI
    remains its signal.
  • A new raw e2e fixture mounts the real mountAppShell, because happy-dom cannot
    observe CSS layout: it verifies that a hidden role host contributes no layout, that
    a long Dashboard title ellipsizes rather than widening the sidebar, level
    indentation, that current-resource styling and keyboard focus are two distinct
    simultaneous signals, that the tree list scrolls in its own pane, and that the
    sidebar width and splitter survive a role switch.
  • Sabotage-checked the load-bearing rules (absent-vs-unresolved filter source,
    Filters-before-Panels, the mid-wave filter gate, the shared user-interaction guard,
    and withoutPendingFocus retaining currentMember) — each fails the suite when
    broken.

Non-goals held

No drag, pencil, trash, create, delete, duplicate, reorder or ownership mutation; no
lower-pane rename to Library (#427 owns that atomically); no favourite decoupling; no
persisted tree state; no URL change.

Checklist

BorisTyshkevich and others added 10 commits July 25, 2026 16:32
…er + pending delivery

`MainSurfaceState`'s dashboard branch carried ONE `focus` field doing two jobs.
#426's tree needs them apart:

  - `currentMember` — which member the user navigated to inside this Dashboard.
    Retained until another member/Dashboard/query is opened, because the tree
    paints its current-resource styling from it.
  - `pendingFocus` — a DOM focus delivery still owed to the surface. Consumed
    exactly once, so a later repaint cannot re-focus a node the user has since
    navigated away from.

They now move independently: `withoutFocus` becomes `withoutPendingFocus` and
deliberately leaves `currentMember` alone, `reconcileMainSurface` drops either
field when committed truth no longer contains that member (resolved against its
OWN collection, so a colliding tile/filter id cannot cross-resolve), and
`adoptRouteMainSurface`'s View/Edit switch preserves the current member while
owing no new delivery. `withCurrentMember` selects a member without owing one —
the in-place path lands next.

`DashboardRenderTarget.focus` is deliberately unrenamed: it genuinely is a
one-shot render request.

No behaviour change beyond the retention itself, which app.test.ts's
"delivers a focus target once" now asserts. Sabotage-checked: clearing
`currentMember` in `withoutPendingFocus`, and resolving a member against both
collections, each fail the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
…ace command port

The tree makes repeated same-Dashboard member navigation a NORMAL operation.
#425 delivered focus by re-rendering, which rebuilt the viewer session, re-ran
the Dashboard, and wrote another history entry every time — fine for a direct API
call, unacceptable for a click.

Rather than inventing a second port, this extends the one that already exists
(`app.surfaceCommands`, generation-stamped, installed by the Dashboard render and
cleared by Dashboard cleanup AND every surface transition) with:

    focusMember(member): 'ok' | 'pending' | 'missing'

`pending` is deliberately not a failure — it means "not deliverable in place right
now", and the caller falls back to the normal render transition, which owns the
deferred delivery. Two things produce it: a superseded port, and a curated filter
requested before the opening wave settles (the first publish replaces the whole
filter bar, so a node focused earlier would be detached moments later). A TILE has
no such problem, so the port is installed SYNCHRONOUSLY, before the wave's await —
a click that lands mid-load still focuses its panel.

`applyNavigationFocus` is factored into one `deliverFocus(target, opts)` body, so
the tabindex/scroll/focus/highlight sequence cannot drift into two copies.
`respectUserInteraction` is the only difference: the render-time one-shot must
yield to a user who got there first, but an in-place request IS the user's click —
sharing that guard would make every tree click after the first silently do
nothing. There is a regression test named for exactly that.

`openDashboard` uses it as the same-id/same-mode fast path, and `state`
gains the tree's explicit repaint invalidation (`dashboardTreeRevision`,
bumped through the single `app.invalidateDashboardTree` writer) plus the
session-only `upperRole`. Opening a Dashboard ROW clears the current member and
still invalidates, so the tree cannot keep marking a stale row.

Also fixes two tests the change would otherwise have made vacuous rather than
failing: "re-opening the same id and mode" is split into the in-place, pending and
missing paths, and "delivers a focus target once" now opens a DIFFERENT Dashboard
so it genuinely exercises the render path it exists to pin.

Sabotage-checked: removing the mid-wave filter gate, and sharing the
user-interaction guard with the in-place path, each fail the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
…biter

Three pure modules, no wiring yet — all at the per-file gate.

`core/tree-click-arbiter.ts` is the one behaviour #426 explicitly forbids copying
from the schema tree. That tree DETECTS a double click post hoc: the single action
runs immediately and the double runs in addition, which is harmless when the first
action is expansion. A panel row's single action opens a query, so detection would
flash the Query surface and write a history entry on the way to a double-click.
This arbitrates instead — the single is scheduled for the double-click window and
cancelled outright by a second press. Timers are injected, so it is pure. It also
becomes the shared home of `DBLCLICK_MS`, which `ui/schema.ts` now imports rather
than keeping a second copy of the same 300ms.

Subtlety worth naming: the window opens even for a row with NO single action. A
source-less transitional filter has query-open disabled but still answers a
double-click, so the second press has to be recognisable as a repeat.

`application/dashboard-tree-ui-state.ts` holds expansion/search/scroll/keyboard
state per workspace id, copy-on-write. Deliberately NOT a signal, following
`state.libraryFilter`'s precedent: a repaint effect over this would lose the caret
on every keystroke and repaint on every scroll frame. `preSearchScrollTop` is
separate from `scrollTop` because one field cannot both track the filtered list
and remember where to return to when the search clears. `clampKeyboardRow` keeps
the roving tabindex on a row that is actually rendered — a row can leave the
visible set through a collapsed ancestor or a search, which pruning cannot see.

`application/dashboard-tree-model.ts` derives the flat visible-row list. Each row
carries level/parent/expansion/match/invalid AND its own action set, so the view
gets one generic dispatch path instead of a per-kind gesture switch — that is what
keeps the view inside the coverage gate. It lives in src/application/ because it
reads both the workspace aggregate and MainSurfaceState.

It holds the transitional data rules exactly: an ABSENT filter `sourceQueryId` is
transitional (row stays, query-open unavailable, NO diagnostic), while a PRESENT
but unresolved one is a real broken reference. Conflating them is sabotage-checked,
as is Filters-always-before-Panels. Input shapes are deliberately looser than the
generated types so every never-throws guard has a malformed fixture reaching it
rather than being unreachable padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
…archy tree

The upper sidebar pane gains an equal-role tab row over TWO PERSISTENT hosts,
exactly one exposed. Switching roles only flips `hidden`, so schema search text
and input focus, schema expansion, lazily-loaded columns, and schema scroll are
preserved BY CONSTRUCTION rather than by save/restore logic — and the upper-pane
height, splitter and sidebar width are untouched, because nothing replaces the
`.side-pane` they belong to. The tab row reuses the lower switcher's
.side-tabs/.side-tab/.side-count vocabulary verbatim (#426 asks for it; DESIGN.md
requires one tab language app-wide).

`ui/dashboard-tree.ts` renders the model's flat row list 1:1: role=tree with
role=treeitem rows, aria-level, aria-expanded on expandables only, a roving
tabindex, Up/Down/Left/Right/Home/End/Enter/Shift+Enter, and a keyboard-reachable
trailing action menu carrying every operation otherwise reachable only by
double-click. Gestures run through the pure arbiter, so a panel click opens its
query only after the double-click window and a double-click cancels it outright —
no Query-surface flash, no duplicate history entry. Group rows deliberately bypass
arbitration (no competing gesture, so waiting would only feel slow), and the
chevron is the instant expansion path.

Two details worth naming. The tree repaints only on the explicit
`dashboardTreeRevision` signal; expansion, search and scroll repaint the row list
directly, which is why typing keeps the caret and scrolling costs nothing. And
focus is restored after a repaint ONLY when the tree already had it, so a
background workspace commit cannot steal focus from the editor.

Steps 4 and 5 of the plan landed together on purpose: splitting them would have
shipped a placeholder search box wired to a tree that did not exist yet, then
deleted it.

Both new UI modules are added to typography-contract.test.js's curated source
list — that gate is hand-curated, and its own comment warns that carving markup
out of a listed file into an unlisted one silently drops it from the gate.

Also removed two unreachable statements the coverage gate correctly rejected:
`focusRow`'s null bailout, and an empty-rows guard in the key handler that can
never run (the handler is not installed for an empty tree).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
…seam, and relabel the mobile segment

The header's `SQL Browser | Dashboard` pair is gone; the brand zone is now
non-interactive `Altinity® SQL Browser`. That control could only ever reach ONE
Dashboard, which the upper-left tree now supersedes. `routeButton` survives — the
Dashboard toolbar's View/Edit control is its remaining consumer, and `surface-label`
is on the typography ALLOWED list because of it.

`app.dom.dashboardNav` / `renderDashboardNav` are deleted outright: the element was
BUILT but never appended to any parent (`libraryControls` returned only
`[fileBtn, workspaceTitle]`), and the function only ever set `hidden = false`. Its
three call sites were keeping a no-op in sync. Deleting it orphaned
`actions.showDashboard`, whose only production caller was that detached button's
onclick, so that goes too. Dead CSS (`.hd-dash-nav`, `.app-surface-switch`) with it.

Each of those three call sites marked a moment the Dashboard COLLECTION may have
changed, so rather than only deleting them, the tree's invalidation now fires from
`applyCommittedWorkspace` — the one place every projection funnels through (boot, a
committed mutation, an external refresh, a workspace switch). Declared above its
first caller so no path can reach it mid-wiring.

Owner decision at ship time: the mobile segmented control's upper label becomes
**Explore** rather than staying "Schema". That pane now hosts both the Databases
and Dashboards roles, so "Schema" named only half of what it shows. The internal
`data-seg`/`data-mobile-tab` values and the `.schema-pane` selectors they key are
deliberately untouched — a label change, not a mobile-CSS restructuring (touch
behaviour stays out of scope per #426).

Dead tests are DELETED, not re-fixtured — three `dashboardNav` cases covered a seam
that no longer exists. Where a removed control carried a real regression guard, the
guard moved rather than vanishing: app.test.ts's "resolves the workspace key at
click time, not header-mount time" check now drives `showDashboardSurface`, which
is where that resolution actually lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
…ard tree

A new raw fixture mounts the REAL `mountAppShell`, so the two upper-role hosts, the
role tab row and the tree are laid out by the shipped CSS — happy-dom cannot observe
CSS layout at all, which is where every geometric claim in this change actually gets
checked:

  - a hidden role host contributes NO layout (the exposed one fills the pane
    exactly, minus the tab row) — so a Dashboard genuinely owns the pane rather
    than sharing it with an invisible sibling;
  - a very long Dashboard title ellipsizes instead of widening the sidebar or
    overflowing the page;
  - row indentation resolves to 10/24/38px per level;
  - current-resource styling and keyboard focus are two DISTINCT signals visible on
    the same row simultaneously (tonal surface + ring), which is the requirement a
    class-name assertion cannot prove;
  - the tree list scrolls inside its own pane;
  - the sidebar width and the upper/lower splitter survive a role switch.

The gesture specs are here for a different reason: real timers and real event
ordering. A panel click opens its query only after the double-click window; a
double-click cancels that outright with no Query-surface flash and exactly one
navigation.

Two fixture traps worth recording. `createApp` pulls in the CodeMirror adapters, so
the harness needs the full CM6 import map or its module silently never runs (the
raw harnesses load /src as unbundled ESM). And the fixture must mount into `#root`,
not an arbitrary element — styles.css gives `#root` the full-height flex column, and
without it `.main-row` is content-sized, which made the pane height appear to change
across a role switch. That was the harness, not the product.

The mobile header spec drops its Application-surface assertion (that group is gone)
and now asserts the brand zone has no buttons at all.

Verified: 232 e2e tests pass on chromium + webkit, zero failures. Firefox cannot
launch a context in this sandbox (unshare CLONE_NEWPID EPERM), so CI remains its
signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
Removing the header's `SQL Browser | Dashboard` pair opened a hole that neither
issue could see on its own. #437 removed the Dashboard toolbar's Back-to-query
control specifically BECAUSE the header pair covered it; #426 then removed the
header pair. Together they left `g w` and "click a saved query in the lower list"
as the only ways out of a Dashboard — and neither is reachable on a phone, where
the mobile rules drop the sidebar AND the bottom nav for a full-bleed Dashboard.

#426's own Header-cleanup section requires this control to be retained, so it comes
back — but per #437's design, as the FIRST control inside the one compact toolbar
rather than as a second row, icon-first so the label drops at phone widths through
the existing `.dash-back-label` rule. Both the rendered Dashboard and the
no-Dashboard placeholder carry it, so neither state is a dead end.

The tooltip names the `G then W` shortcut, so the keyboard path stays discoverable
instead of being the only way to find the action.

Carries a regression guard named for the interaction, since this is the kind of gap
that only appears when two separately-correct changes meet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
…ns, unwired pruning

Two independent read-only review passes over the branch found seven real defects.
Each was verified against the code before fixing.

CONFIRMED BUGS

1. In-place focus on a filtered-out panel was a silently dead click.
   `tileEls` is a write-only cache (no delete/clear anywhere), and both layout
   reconcilers rebuild the grid from the SEARCH-FILTERED tile set — so a panel
   excluded by the Dashboard's own tile search leaves a DETACHED card behind.
   `deliverFocus` never checked, so tabindex/scrollIntoView/focus/highlight all
   no-opped on an out-of-document node and `focusMember` still reported `ok`: the
   tree marked the row current while nothing moved and no diagnostic appeared.
   Now reports `pending`, so the caller's render transition rebuilds the surface
   (which resets that per-session search) and delivers the focus for real.

2. A group row's chevron was inert during a search, and clicking it wrote hidden
   expansion. `groupExpanded` ignored the user's expansion entirely while
   searching, so a group with no matches could not be opened and one with matches
   could not be closed — and the click surfaced later, after the search cleared.
   Now the forcing term is CONDITIONAL, mirroring `ui/schema.ts`'s second level
   (`tableCascadeForced`), which avoids this exact case deliberately.

3. A View/Edit switch discarded the current member, contradicting the spec's
   "switching View/Edit through Dashboard chrome preserves the current member".
   The mode differs, so `isSameDashboardSelection` is false and the request took
   the render path, where `resolveOpenDashboard` builds the surface from the
   request alone. New pure `carryCurrentMember` carries it across a same-Dashboard
   mode change only, so opening a Dashboard row still clears it.

4. `pruneTreeUi` had NO production caller — dead code standing in for the spec's
   "prune removed resource IDs". Wired into `applyCommittedWorkspace`, so a
   deleted Dashboard's expansion cannot linger for the session or make a
   RECREATED id render pre-expanded. Survivors are preserved.

5. Pending click timers were cancelled on role change and search only, not on the
   workspace change its own docstring named, nor on disposal. A deferred "open
   this query" from workspace A could fire 300ms after switching to B — and
   `openSavedQuery` with an unknown id still switches surfaces, so the user got
   yanked off the Dashboard with nothing opened. Now cancelled on a genuine
   workspace switch and in the shell disposer.

SPEC COMPLIANCE

6. The action-menu button used `Icon.chevDown()` — the same glyph as the
   disclosure chevron, which #426 explicitly forbids ("not become confused with
   the row's disclosure chevron"); an expanded row carried two identical
   chevrons at opposite ends. New `Icon.more()` (vertical ellipsis).

7. Menu-button and group-row clicks called `arbiter.cancel()` unconditionally,
   killing a pending single on an UNRELATED row, against "cancel no unrelated row
   operation". New `cancelFor(key)` is scoped to the row.

Also: the roving tabindex now moves in the DOM on click instead of waiting ~300ms
for the deferred single's repaint (the visible ring and the arrow-key origin
disagreed in that window), and the empty-collection Dashboard entry point — the
one surface transition that bypasses `applyMainSurface` — now invalidates the tree
too.

Every fix carries a test named for the defect, including the three the reviews
noted were missing outright: mode-switch member preservation, end-to-end pruning,
and workspace-change cancellation. Plus the spec assertions nothing covered: that
rendering or searching the tree executes no query and creates no viewer session,
and that it never mutates the aggregate.

Note on tooling: the Codex second opinion could not be obtained. Its sandbox
helper cannot start here (`bwrap: loopback: Failed RTM_NEWADDR: Operation not
permitted`), so every git/file read inside it failed; an earlier attempt without
`--base` reviewed an unrelated changeset it found through GitHub search. Reported
rather than counted as a clean pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
…ion move

Adds the #426 CHANGELOG entry, and CORRECTS the #437 one, which had become false:
it stated that "the application header's existing SQL Browser / Dashboard switch is
the navigation path back to Query" — this branch deleted that switch. The shipped
changelog was documenting removed behaviour.

ADR-0003 gains a #426 addendum, because the change amends the surface LIFECYCLE the
#425 addendum recorded, not just the UI: surface navigation is no longer
header-owned, repeated member navigation no longer restarts the viewer session (the
generation-stamped SurfaceCommandPort gains focusMember, with `pending` meaning
"not deliverable in place" rather than failure), and MainSurfaceState now separates
retained current-member styling from consumed-once focus delivery.

Roadmap #68's Phase 10 line is checked off with the shipped scope, including the two
owner decisions (mobile segment relabelled Explore; Back to query restored).

Filed #443 (inbox): `openSavedQuery` switches surfaces before resolving the id and
stays silent on a miss. Pre-existing, out of scope here — it was the tail of the
now-fixed deferred-click-after-workspace-switch path, and every current caller
resolves the id first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
…-disabled menu items

Three of Codex's four findings verified as real and are fixed.

1. Row keys could COLLIDE. Dashboard/tile/filter ids are schema-constrained only to
   `\S`, so a colon is legal — and an imported bundle preserves whatever ids it
   carried. Unescaped, Dashboard `a:tile:b` produced the same key as tile `b` of
   Dashboard `a`: two rows claiming `tabindex="0"`, `focusRow` targeting the wrong
   one, and the click arbiter reading clicks on two distinct resources as a
   double-click. Key components are now escaped (`%` first, so the escape cannot be
   forged), which also makes `pruneTreeUi`'s group-key split unambiguous instead of
   relying on a lastIndexOf heuristic. `encodeKeyPart` lives in the ui-state module
   so the dependency between the two pure modules stays one-way.

2. The search box did not follow a workspace switch. It is built once and outside
   the repainted list (that is what keeps the caret), so on a switch the tree
   filtered by the NEW workspace's search text while the box still displayed the old
   one — and the reverse coming back: a filtered tree above a blank box. Now synced
   during the render, guarded on inequality so an ordinary repaint never touches the
   caret (asserted by a test that counts writes to `value`).

3. "Disabled" menu items were only visually disabled. `openMenu` makes every item a
   focusable `role="menuitem"` button; `.is-disabled` blocked the pointer through
   CSS, but assistive technology still announced an enabled action and keyboard
   activation silently did nothing. `MenuRow` gains an optional `disabled`, which
   sets `disabled` + `aria-disabled` and keeps the row out of the roving-focus
   order.

REJECTED: "pure logic must live in src/core/ per hard rule 2". `src/core/` may not
import `src/workspace/` (build/check-boundaries.mjs), and the tree model reads both
the workspace aggregate and `MainSurfaceState`. `src/application/main-surface.ts`
established and documented exactly this placement in #425 for the same reason; the
model would have to move with it, and it cannot.

Codex ran via the stdin-diff invocation the ship skill documents — my earlier
attempts used `codex exec review --base`, which errors on a custom prompt and, given
`--base` alone, reviewed an unrelated changeset it found through GitHub search.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTaxTCGhPECD6PJaDeZhP5
@BorisTyshkevich
BorisTyshkevich merged commit 5237783 into main Jul 25, 2026
7 checks passed
BorisTyshkevich added a commit that referenced this pull request Jul 25, 2026
…le placement (#445)

* fix(#426): search-forced rows, phantom current member, pure-module placement

Follow-up review findings on the Dashboard tree that landed after PR #444 was
pushed:

- A row a search is HOLDING open now carries `toggleable: false` and no `single`
  command, so its chevron gets no handler and click / ArrowLeft / ArrowRight
  cannot write expansion state. #426 requires a search to expose paths without
  mutating saved expansion and to restore the pre-search state when cleared; a
  toggle there wrote state that was invisible until the search cleared.
- `resolveOpenDashboard` no longer marks `currentMember` for a tile or filter the
  resolved Dashboard does not contain, while still passing `pendingFocus` through
  unchecked so the delivery path keeps reporting the miss. The existence check is
  extracted as `presentMember`, resolved against the member's own collection, and
  shared with post-commit reconciliation.
- `dashboard-tree-ui-state.ts` moves to `src/core/` per CLAUDE.md hard rule 2: it
  is a pure leaf with no imports at all and resolves nothing against the
  workspace aggregate.
- Deferred tree clicks are cancelled on every projection, not only a workspace
  switch: deleting a Dashboard inside the 300 ms window could otherwise let the
  delayed toggle re-add a pruned id, or open a dead query id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zB6jdGRgMmPNxXvxAoSLx

* docs(#426): note the Dashboard-tree follow-up fixes in CHANGELOG

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zB6jdGRgMmPNxXvxAoSLx

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BorisTyshkevich
BorisTyshkevich deleted the feat/dashboard-tree-426 branch August 6, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a Databases/Dashboards switcher and dashboard hierarchy tree

1 participant