refactor(#587): a side-panel registry so adding a navigation panel is a one-file change (phase 2) - #600
Conversation
`core/side-panels.ts` is the one place a panel's id/pane/persisted-key is declared (`SIDE_PANELS`, an `as const satisfies` manifest); every id/key union elsewhere is derived from it via `typeof`, and the load-boundary decoder (`decodeSidePanelKey`) fails closed to the Library panel for any unrecognized `asb:sidePanel` value, including the registry's own id `'library'` (never a persisted value). `ui/side-panel-registry.ts` is the generic, DOM-owning half: persistent per-panel hosts built once and never rebuilt, a mount-once/activate-per- transition lifecycle (`MountedSidePanel`), pane-scoped `showPanel`, and one tab-row renderer shared by both the upper and lower sidebar panes. Neither module is wired into the app shell yet — that lands in the next commit, alongside the migration of `state.ts`/`saved-history.ts`/`app-shell.ts`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…ell, and the workbench One atomic commit (R2.11 step 3) — narrowing state.sidePanel/state.upperRole, the shell composition, saved-history.ts's panel conversion, the workbench's onRunComplete hook, and every typed fixture all land together, since none of them is independently green (e.g. narrowing sidePanel's type breaks saved-history.ts's old switchTo(panel: string) mid-migration). - state.ts: `sidePanel: Signal<SidePanelKey>` (decoded fail-closed via `decodeSidePanelKey` at the load boundary — this IS the 'library' <-> 'saved' bridge #587's context section describes; #591 must not re-do it, and the CHANGELOG/ADR entries say so). `upperRole: Signal<UpperPanelId>`, derived from the manifest rather than a hand-written union. - app-preferences.ts: `PreferenceValues` makes `save` generic over its key (#587 AC4 met literally — `prefs.save('sidePanel', 'library')` is now a compile error, not just a runtime discipline). - sidebar-upper.ts: drops the tab-row vocabulary it used to own (`renderUpperRoleTabs`, the `UpperRole` tab meta) — `databasesPanelDef`/ `dashboardsPanelDef` hand the same label/icon/count facts to the registry instead. Still builds the two upper bodies (schema search+list wiring stays in app-shell.ts; the Dashboard search+tree wiring stays here) and now also owns `dashboardsPanelDef`'s `deactivate` (cancels a pending tree click) and `render` (repaints the tree) hooks. - saved-history.ts: no longer builds a tab row or owns `app.dom.savedList`/ `savedSearch`/`savedTabsRow` (deleted from `AppDom`). `libraryPanelDef`/ `historyPanelDef` each build one persistent search+list host via `mountLowerPanel`, guarded by `ownsTheList` (`!host.hidden`) so a stale hidden panel's leftover search input can't rewrite the shared `state.libraryFilter` or repaint the other panel's list — shipped with its own regression test (sabotage-checked: deleting the guard turns it red). `renderSavedHistory(app)` survives as a thin compatibility export (`app.shell?.sidePanels.refreshActiveSidePanels()`), absorbing all but two of its 17 former call sites with zero further edits, and staying a safe no-op both before any shell mounts and after one is disposed (#587 R2.5). - app-shell.ts: both the upper and lower tab rows render through the same generic `renderSidePanelTabs`, and `AppShellHandle.sidePanels` exposes the registry (with `refreshActiveSidePanels` wrapped to also repaint the Library tab's live count, since a star/delete/rename doesn't bump any signal the reactive effect depends on). `mainRow` keeps #586's `inspectorResize`/`inspectorHost` children unchanged. - workbench-session.ts: `WorkbenchStateSlice` drops `sidePanel` entirely (#587 AC3's stronger clause — this service now knows no panel id exists at all); `WorkbenchHooks.onRunComplete` replaces `renderSavedHistory` and fires unconditionally on a clean run, dispatch to the active panel handled entirely by the hook's own wiring in app.ts. - app.ts: `app.shell` mirrors the `ensureShell`/`disposeShell` lifecycle so the registry is reachable (or safely null) from any module holding `app`; `app.recordHistory` and the workbench's `onRunComplete` hook both delegate to `app.shell?.sidePanels.notifyRunComplete()`. - fake-app.ts (#587 R2.10): the `dom` fixture is now its own `satisfies AppDom` literal (`defaultDom`) built BEFORE spreading, so a field `AppDom` drops (as the three deleted here would have) is a compile error next time, not a silent rot only an `rg` sweep would catch. Adapted acceptance criteria (recorded here, detailed in the phase report): mount-once-per-shell lifecycle (AC6 wins over the issue's literal "per activation" Tests wording, which contradicts persistent hosts); the two-file registry split (`core/side-panels.ts` + `ui/side-panel-registry.ts`, AC5's "one file" parenthetical). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…, fix the e2e fixture `.side-panel-host` mirrors `.upper-role-host`'s existing flex/hidden contract (flex:1, min-height:0, column layout; hidden -> display:none) for the Library/History hosts `side-panel-registry.ts` builds generically. Deliberately NOT a rename of `.upper-role-host` itself — that class's hosts are addressed directly by tests/e2e/dashboard-tree.spec.js (9 references to `.upper-role-host[data-role=...]`), and reusing it verbatim for the lower pane too would risk an unrelated selector collision for zero benefit over a second, identically-shaped class. tests/e2e/dashboard-membership.html no longer hands `renderSavedHistory` three ad-hoc divs via `app.dom.savedTabsRow`/`savedSearch`/`savedList` (fields `AppDom` no longer has) — it builds the same two-panel registry app-shell.ts does and hands it hosts of its own choosing, since a `mount(host)` registry owns which host a panel paints into and cannot honour externally-supplied ones. Verified in a real browser: `npx playwright test --project=chromium --project=webkit` is 414 passed / 4 skipped / 0 failed, including dashboard-membership.spec.js, sidebar-tabs-narrow.spec.js, and every dashboard-tree.spec.js case that addresses `.upper-role-host`/`.upper-role-tabs`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
Sabotage-checked (see the phase report): commenting out app-shell.ts's registry.dispose() call in mountAppShell's dispose() turns this red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…legs
R2.10: an injected-fake-panel test alone proves a generic builder accepts
injected data, not that adding a REAL panel avoids app-shell.ts/
app-preferences.ts/state.ts/workbench-session.ts (types are erased at
runtime). Two more legs, alongside the runtime one already in
side-panel-registry.test.ts:
- Compile-time: app-preferences.test.ts's `@ts-expect-error` on
`prefs.save('sidePanel', 'library')` — sabotage-checked by widening
`AppPreferencesStateSlice['sidePanel']` back to `string`, which makes
`tsc --noEmit` (a repo gate) report "Unused '@ts-expect-error' directive",
then restored.
- Source-contract: side-panel-source-contract.test.ts reads app.ts,
workbench-session.ts, app-preferences.ts, and state.ts (comments stripped)
and asserts none of them contain a panel-id string compare or a hard-coded
tab label. Sabotage-checked both named scenarios: reintroducing
`sidePanel === 'history'`-shaped code into workbench-session.ts, and a
hard-coded `'Databases'` literal into state.ts — both turned it red, then
restored.
tests/types/node-fs-url.d.ts is a minimal ambient declaration for
`node:fs`/`node:url`/`node:path` (the repo carries no `@types/node`
devDependency — ADR-0002 is dev-time-only strict TS over the browser-shipped
source; tests/types/node-crypto.d.ts is the existing precedent for this
pattern).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
Dense-style [Unreleased] entry (the #586 entry is the model) covering the manifest/registry split, the 'library' <-> 'saved' load-boundary bridge (so #591 does not redo it), the generic-save AC4 compile-time contract, and the WorkbenchHooks.onRunComplete rename. ADR-0004 gets a short addendum recording that its shell-primitive investment now includes this second delivered primitive, plus how AC4 (met literally) and AC5 (two files, not one) were adapted and why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…uard Four review findings, all verified against the code before accepting: - `showPanel` was a single pass over `entries`, so a switch rendered the incoming panel before an outgoing sibling's `deactivate` had run. Because `library` is registered before `history`, a History -> Library switch painted Library while `state.libraryFilter` still held History's search text: the Library search box showed leftover text and its list reported no matches for a query the user never typed there. Library -> History worked only by luck of registration order, and the pinned "clears the filter when switching tabs" test covered just that lucky direction. `showPanel` is now two-pass — deactivate every pane sibling, then activate/render the target — which is the same close-before-mount invariant #586 established for the docked inspector. Adds the missing mirror test plus an ordering test that fails if the two-pass structure is reverted. - The downgrade-safety test performed the very write it claimed to forbid: `@ts-expect-error prefs.save('sidePanel', 'library')` suppresses only the TYPE error, so it still called `saveStr(KEYS.sidePanel, 'library')`, and the test's only assertion counted calls without inspecting arguments. The compile-time trap now lives in a function that is never invoked (tsc still checks an uncalled body), and the raw storage seam is asserted negatively. - Activation freshness was proven only for injected fake panels. Adds two real-panel tests asserting rendered DOM: history recorded while History is hidden appears the moment it activates, and likewise for a Library mutation. - Three comments claimed `renderSavedHistory` has "17 call sites" — a pre-implementation estimate the implementation invalidated. Recounted with `rg`: 10. Corrected in all three places. Also fixes a self-contradiction in the ADR-0004 addendum, which said two acceptance criteria were adapted and then described AC4 as met literally. AC4 did land literally; the two adapted items are AC5's two-file split and the mount-once lifecycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
ChatGPT review pass 1Reviewed head: Major — adding a panel still requires editing
|
…ssibleLabel PR #600 review findings 1 and 3. Finding 1 (MAJOR): app-shell.ts imported and listed all four concrete panel defs directly, which is exactly what AC5 forbids ("adding a panel must not touch app-shell.ts") — the file's own comment softened this to "never touching this file's composition below", and side-panel-source-contract.test.ts never checked app-shell.ts at all, so the violation stayed green. Moves the production wiring into side-panel-registry.ts's new buildProductionSidePanelRegistry, which is now the one place the four real defs are listed; app-shell.ts calls it with only the two upper hosts it built and `app`, naming no concrete panel def or id. Extracted the registry's type-only interfaces (MountedSidePanel/ SidePanelDef/SidePanelEntry/SidePanelRegistry) into a co-located side-panel-registry.types.ts, since sidebar-upper.ts/saved-history.ts need those types while side-panel-registry.ts now needs their concrete *PanelDef factories at runtime — pointing both edges through side-panel-registry.ts would be a real module-graph cycle. side-panel-registry.ts re-exports the types verbatim so no existing importer changes. Extended side-panel-source-contract.test.ts with a check that app-shell.ts names no concrete panel-def symbol or panel-id literal. Finding 3 (MINOR): renderSidePanelTabs never applied SidePanelDef's required, documented accessibleLabel — only aria-pressed and visible text reached the DOM, and the registry test explicitly dodged this ("accessibleLabel-independent structure"). Now emits aria-label on each tab button; the test asserts the exact strings (all four values satisfy WCAG 2.5.3 — each accessible name contains its visible label). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…column PR #600 review finding 2 (MAJOR): UpperPanelId/LowerPanelId were hand-written literal unions (Extract<SidePanelId, 'databases' | 'dashboards'>, etc.) — a second authority listing the same ids by hand, so a new SIDE_PANELS row never extended either automatically. The "extended manifest" tests only exercise runtime helpers over copied arrays, never these two types, so the drift had no test catching it. Both types now derive from SIDE_PANELS's own `pane` column via a PanelIdInPane<P> helper (Extract over the manifest's precise element-union type, narrowed by `pane`), so a row's pane assignment is the only thing that decides which union it joins. Added tests/types/side-panels.test-d.ts — a compile-time-only assertion file (tsconfig.json's `include` covers tests/types/**/*.ts, so `tsc --noEmit` type-checks it; vitest's include glob is scoped to tests/unit/**/*.test.{js,ts} so it never executes, matching the existing tests/types/state.test-d.ts precedent) with two never-called functions whose bodies pin, against the live manifest type: (1) every SidePanelId is assignable to UpperPanelId | LowerPanelId (coverage), and (2) Extract<UpperPanelId, LowerPanelId> is never (disjointness). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
…ked test The previous commit's tests/types/side-panels.test-d.ts header claimed the "uncalled function body is still type-checked" idiom was already relied on elsewhere by this repo's check:arch/ADR-0002 tooling. Checked: build/check- boundaries.mjs and tests/types/state.test-d.ts's own assertType helper (which IS called, with real values) don't do this — no such precedent exists. Rewords the comment to describe only what this file itself does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
ChatGPT review pass 2Previously reviewed head: Reviewed head for this pass: Major — pass-1 finding 1 was moved, not closed: a new upper panel is registered but never mounted into the shell DOM
The acceptance failure remains one step later, though. const registry = buildProductionSidePanelRegistry(app, upper);
const schemaPane = h('div', ...,
app.dom.upperRoleTabs, upper.databasesHost, upper.dashboardsHost);By contrast, the lower pane immediately derives every host from The extended source-contract check does not constrain this. Fix: build the upper pane from registry entries exactly like the lower pane: const upperHosts = registry.entries
.filter((entry) => entry.pane === 'upper')
.map((entry) => entry.host);Append Major — the
|
…rrect side-panels.ts derivation comment app-shell.ts composed schemaPane by naming upper.databasesHost/ upper.dashboardsHost directly, so a future upper panel registered only in buildProductionSidePanelRegistry would get a tab-row entry but no route into the document — the same host-by-name bug the lower pane's own registry.entries-derived composition already avoided. Derive schemaPane's children from registry.entries the same way, and add a runtime guard (every registered entry's host must be document-connected after mount) plus a source-level check rejecting a literal .databasesHost/.dashboardsHost accessor in app-shell.ts. Also correct side-panels.ts's own comment: tests/types/side-panels.test-d.ts only proves UpperPanelId/LowerPanelId cover and partition TODAY'S manifest — for the current four-row SIDE_PANELS, the derived unions and the old hand-written Extract<SidePanelId, 'databases' | 'dashboards'> literals produce identical types, so that test alone cannot catch a plain revert to hand-written literals. Add the missing backstop: a source-level check in side-panel-source-contract.test.ts that side-panels.ts declares no type alias containing a literal panel-id string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
… tab buttons
renderSidePanelTabs set aria-label={entry.accessibleLabel} on every tab
button, but the button's descendants already render the visible label plus
tabAdornment() (the live .side-count badge, e.g. "· 3"). An explicit
aria-label on a button REPLACES its content-derived accessible name, so
screen-reader users went from hearing "Databases · 3" to "Open Databases
navigation" — the live count silently dropped out of the accessible name.
That was a regression introduced by the previous pass's fix for a "dead
contract surface" finding, not a genuine improvement.
Remove the aria-label so the accessible name is content-derived again.
accessibleLabel stays on SidePanelDef/SidePanelEntry (#587 AC6) — its doc
comment now states its real consumer (a future icon-only presentation with
no visible text to derive a name from) and explicitly warns against
emitting it on a tab that already has a visible label plus a count. The
four exact accessibleLabel strings are still pinned, but at the
registry-meta level rather than on the DOM, and a new regression test
asserts a counted tab has no aria-label and that its text content includes
the count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
ChatGPT review pass 3Previously reviewed head: Reviewed head: Pass-2 fix reassessment
Major — the manifest is not actually an exhaustive authority for the production registry
The production test at This is not only theoretical metadata drift. For a new lower row, Fix: make the production factory exhaustive by id and derive its order from Minor — the new literal-union source guard misses equivalent double-quoted aliases
export type UpperPanelId =
Extract<SidePanelId, "databases" | "dashboards">;The repository's scripts contain no lint/format gate that enforces single quotes, so the test does not fully enforce its stated “no literal panel-id string” contract. The same check should recognize both quote styles, or use an AST-level assertion. The adjacent comment also says multiline aliases escape the regex, but its negated character classes actually span newlines; that description should be corrected if the regex remains. Full-PR verificationI re-walked the two-pass The exact-head GitHub Actions Verdict: request changes; not yet mergeable. The current four entries happen to match, but the central future-panel contract is still unenforced, and the new source guard can still pass the exact class of literal-union regression it claims to catch. |
…fest
buildProductionSidePanelRegistry hard-coded four factory calls in an array
independent of SIDE_PANELS, so a manifest row added without extending that
array (or a def whose pane disagreed with its own manifest row) stayed
green — including a real runtime crash path through decodeSidePanelKey ->
showPanel -> registry `entry()`'s "unknown panel id" throw. Replace the
array with an exhaustive Record<SidePanelId, factory> (a missing key is now
a tsc error) and build the def list by mapping over SIDE_PANELS itself, so
order comes from the manifest. Add a parity test asserting the registry's
full {id, pane} sequence equals SIDE_PANELS' own sequence exactly, since the
exhaustive record proves every id has a factory but not that each factory
agrees with its manifest row.
SidePanelDef keeps its own id/pane fields rather than deriving them from the
manifest: buildSidePanelRegistry's generic seam (the AC5 runtime proof, the
dashboard-membership.html e2e fixture) takes an arbitrary injected def list
that isn't tied to SIDE_PANELS at all, so those fields can't be dropped
without narrowing that seam.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
The literal-panel-id allowlist check in side-panel-source-contract.test.ts
only checked for single-quoted literals (`'${id}'`), so a hand-written
Extract<SidePanelId, "databases" | "dashboards">-style regression using
double quotes stayed green. Check all three quoting styles TypeScript
allows for a string literal type: single quotes, double quotes, and
backticks.
Also correct that test's comment claiming "a multi-line type alias would
slip past this pattern" — false: the pattern's [^=]*/[^;]* are negated
character classes, which do match newlines, so a multi-line alias is
matched whole (verified against a literal sample). The pattern's real
blind spot is a generic parameter list with a default type argument (e.g.
`type Foo<T = SidePanelId> = ...`): the optional `<[^=]*>` group forbids
`=` inside the angle brackets, so the whole statement fails to match and
is silently dropped from consideration, rather than being matched but
unflagged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
Review round 4. Manifest uniqueness was assumed by every lookup and enforced by nothing: - `Record<SidePanelId, factory>` cannot catch a duplicate, because a TypeScript union collapses duplicates — a second manifest row reusing an existing id needs no additional key. - The manifest-parity test cannot catch one either: a duplicate appears on BOTH sides of its comparison, so the sequences still match and it stays green. Its comment claimed the exact-equality check proved "no duplicates", which was false; corrected. A duplicate id breaks the registry concretely rather than harmlessly: `byId` keeps only the LAST entry; the initial normalize loop leaves BOTH hosts visible, since each one's id equals its pane's default active id; and `showPanel` skips every candidate whose id equals its target, so it can never hide the shadowed sibling — a permanently double-rendered pane. Duplicate persisted keys are the same shape of problem: `sidePanelKeyFor`, `decodeSidePanelKey` and `lowerIdForKey` are all first-match `PANELS.find(...)`, so one row would become unreachable. `buildSidePanelRegistry` now throws on a duplicate def id at construction. That check belongs there, not in the manifest, because the same seam accepts arbitrary INJECTED defs (the AC5 fake-panel proof, the e2e fixture) which are not manifest-backed at all. Three manifest invariants are asserted directly: ids unique, defined persisted keys unique, and a persisted key present for exactly the lower-pane rows — the last being what makes `sidePanelKeyFor`'s non-null assertion sound. The uniqueness assertions read `persistedKey` through `'persistedKey' in spec` rather than off the union directly: `SIDE_PANELS` is `as const`, so its element type is a union whose upper-pane members have no such property, and reading it directly is a TS2339 error — the same reason side-panels.ts keeps a typed `PANELS` view for its own lookups. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
… exact-head certification Replace the review-heavy ship cycle with the redesigned one after PR #600 needed four review rounds whose findings were mostly unenforced invariants: - plan step now requires a risk classification and, for medium/high risk, an invariant map (enforcement + proof + sabotage case per claim) and a root-cause circuit breaker that stops patch-chains during review; - review budgets: at most one plan review and one targeted internal review, chosen not stacked; ChatGPT certification stops at the first clean exact-head pass (three passes are a failure ceiling, not a ritual), and nothing may be pushed after certification; - attended mode keeps the human merge gate as the authority over contested findings; unattended keeps the strict automatic-merge proof; - the local gate is now the explicit check chain (ignore-scripts=true means a green `npm test` alone was never the gate); - repo-specific lessons move to references/repo-footguns.md instead of being dropped or inlined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R32bb4VZGPgNo3tB9iVSKF
What & why
Part of #593 (phase 2 of 8). Implements #587: replaces the hard-composed left sidebar with a
side-panel registry, so adding a navigation panel is a registry-plus-module change instead of
touching seven files.
Adapts the proven
nav-sections.tsdesign salvaged from #487 phase 2 (branchfeat/nav-section-registry-487p2, PR #573, removed frommainby the 2026-08-03 force-reset)rather than re-deriving it — retaining all four decisions #587's AC6 names: icon-as-factory, a
separate
accessibleLabel, pane-scoped exposure, and a persisted-key bridge decoded at thestate-load boundary.
Shape
Two modules, split by CLAUDE.md hard rule 2 rather than by preference:
src/core/side-panels.ts— oneas const satisfiesmanifest (SIDE_PANELS: id, pane,persisted key). Every id/pane/key union elsewhere derives from it via
typeof, so there is nosecond authority to drift. Also holds
decodeSidePanelKey, the fail-closed load-boundary decoder.Pure: no DOM, no globals.
src/ui/side-panel-registry.ts— the DOM-owning half: persistent per-panel hosts built onceand never rebuilt, a mount-once/activate-per-transition lifecycle (
MountedSidePanel), pane-scopedshowPanel, and one tab-row renderer shared by both panes.app-shell.ts,sidebar-upper.tsandsaved-history.tsnow address panels only through theregistry.
sidebar-upper.tsstill builds the two upper bodies but no longer owns their tab-rowvocabulary (
renderUpperRoleTabsis deleted);saved-history.tsno longer builds the lower tab rowat all.
upperRoleis nowSignal<UpperPanelId>, derived from the manifest instead of a closedhand-written union.
Behaviour-relevant changes
state.sidePaneldecodes its stored value fail-closed at load. Before this PR there was nobridge at all — a raw, unvalidated
localStorageread — so an unrecognised stored value silentlypainted the History body with neither tab visually active. This is the
'library' ↔ 'saved'bridge, and refactor(state): fail-closed decoders for persisted domain records #591 (phase 3) must not re-implement it.
'library'is never itself persisted (Librarywrites exactly
'saved'), so a revert of this PR needs no data rollback. Asserted at the rawsaveStrseam, not only onstate.sidePanel.value.app-preferences.ts'ssaveis now generic over aPreferenceValuesmap, soprefs.save('sidePanel', 'library')is a compile error rather than a runtime discipline.workbench-session.tsdropssidePanelfromWorkbenchStateSliceentirely and renamesWorkbenchHooks.renderSavedHistory→onRunComplete; the "only repaint History if it's active"decision moves out of the service module and into the registry.
AppDomlosessavedList/savedSearch/savedTabsRow..side-countadornments (Databases, Dashboards, Library) keep theirexisting visibility rules, via a generic
tabAdornmentcapability rather than a shell branch.Two acceptance criteria landed in an adapted form (deliberate, recorded in ADR-0004)
src/application/may not importsrc/ui/— andcheck-boundaries.mjs:12countsimport typetoo — while pure logic belongs insrc/core/.AC5's actual prohibition is met in full: adding a panel touches neither
app-shell.ts,app-preferences.ts,state.ts, norworkbench-session.ts.exactly once per activation") contradicts the persistent-host decision AC6 makes binding. AC6 wins;
switching panels changes visibility only and never destroys DOM.
AC4 landed literally — the generic
save<K>above is what makes the persisted-value type derivedrather than hand-maintained.
Review history
Two independent plan reviews before any code was written; both found real defects, and one blocked the
original plan. The material correction: under the originally-planned "adopt the upper pane's hosts and
delegate exposure" design, adding an upper panel would still have required editing
state.ts(
upperRolewas a closed union) with labels and icons left insidebar-upper.ts— breaking AC1 andAC5. So both panes became genuinely registry-driven rather than half of one.
A post-implementation review then found a user-visible bug a fully green gate could not see:
showPanelwas single-pass, so a History → Library switch rendered Library before History'sdeactivatecleared the sharedstate.libraryFilter— Library painted filtered by text the usernever typed into it. The pinned "clears the filter when switching tabs" test only covered the
direction that worked by luck of registration order.
showPanelis now two-pass(deactivate-siblings-then-activate-target), matching the close-before-mount invariant #586 established
for the docked inspector, with a mirror test and an explicit ordering test that fails if it regresses.
Three other review findings fixed: a downgrade-safety test whose
@ts-expect-errorline stillexecuted the forbidden
saveStr(…, 'library')write and never inspected the arguments;activation-freshness proven only against injected fake panels, not the real Library/History defs; and
three comments asserting a "17 call sites" figure the implementation had made wrong (the real count is
10, recounted and corrected).
Filed separately
inbox) — a pre-existing resize-then-measure race in refactor(shell): one SurfaceLifecycle primitive and a docked right-inspector slot #586's own e2e spec(
setViewportSizefollowed immediately byboundingBox()with no wait) that makes two specs flakyunder parallel runs. Not introduced here.
Checklist
npm testpasses (the per-file coverage gate is non-negotiable)npm run buildsucceeds (single-filedist/sql.html)src/core/, network insrc/net/(injected fetch), DOM insrc/ui/CHANGELOG.md([Unreleased]) updated if behavior or the deployed surface changedFull gate run explicitly (
.npmrcsetsignore-scripts=true, so a greennpm testalone does notimply
pretestran):check:schemas,check:examples,check:arch,check:types,npm test,npm run build— plusplaywright --project=chromium --project=webkit.Part of #593