refactor(#586): SurfaceLifecycle primitive and docked right-inspector slot (phase 1) - #596
Conversation
Replace six copy-pasted overlay lifecycles (cell-detail drawer, rows viewer, Reference pane) with one shared open/close/Escape/focus-restore primitive (src/ui/surface-lifecycle.ts), and give .main-row a real, shell-owned inspectorHost slot (app-shell.ts) as a layout sibling of queryHost/dashboardHost instead of three independent position:fixed overlays. inspector-host.ts owns the "one occupant at a time" singleton slot, mirroring dialog-shell.ts's existing openHandle pattern. Deletes isTopDrawer, the .cd-backdrop DOM probes/CSS, the 'docPane' splitter branch, and drawer.ts's per-surface stateKey plumbing. cellDrawerPx/docPanePx collapse into one rightInspectorPx preference with a compat read order and a single canonical write. Docked surfaces no longer acquire the modal keyboard owner (the pre-#586 modal cell drawer blocked every app shortcut while open), so app.ts's surface transition and sign-out teardown now close whichever surface currently occupies the shared dock, not just Reference. One deliberate behavior change: since the dock holds one occupant at a time, opening Cell while Rows is open now replaces Rows instead of stacking (tool registry/persistence is #488's scope, not this phase's). The one surviving non-docked case (a cell-detail drawer inside a real detached browser tab) keeps a self-contained overlay, renamed .cell-detail-overlay, still built on SurfaceLifecycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf
… leak
Three review findings on the SurfaceLifecycle phase:
1. Two assertions could no longer fail. AC3 deletes `.cd-backdrop` repo-wide,
so `tests/e2e/editor-docs.spec.js`'s `.cd-backdrop` count probe and
`tests/unit/doc-pane.test.ts`'s `querySelector('.cd-backdrop')` both passed
unconditionally — a scrim reintroduced under any other class name would
have kept reporting green. Re-pointed at the real rendered claims: the
editor is the topmost element at its own centre (`elementFromPoint`), and
the inspector host's child IS the panel rather than a wrapper around it.
2. AC2's "not a `position:fixed` overlay" had no automated coverage at all —
happy-dom cannot evaluate CSS layout, so the unit suite could only prove
DOM sibling order, `hidden`, and the inline width write. Adds
`tests/e2e/inspector-dock-layout.spec.js`, a real-browser geometric gate
(chromium + webkit): folded contributes zero layout width, opening narrows
`.query-host` and the inspector's box never intersects it, and dragging the
handle resizes live.
3. `openSurfaceLifecycle` installs its capture-phase Escape listener before
`showInInspector`'s return value reveals whether a shell is mounted, so a
failed mount leaked a permanent unclosable document listener. `doc-pane.ts`
skipped only its `panes` bookkeeping; `results.ts` did not check the return
value at all. Both now tear the lifecycle down on failed mount, covered by
tests asserting the same listener reference is added and removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf
BorisTyshkevich
left a comment
There was a problem hiding this comment.
ChatGPT review pass 1
Reviewed head: ad88a88d25dbd96f0562c00b5140d6c2270e6ce0
I reviewed the canonical 31-file PR diff against base c470de091c1e48241fb5e757488c1e7904e88651, the current PR discussion/history, the new lifecycle/host primitives, all changed production files, and the changed tests/fixtures. The Actions run for this head is green. I could not independently clone and execute the suite in this runtime because outbound DNS to github.com is blocked, so the findings below are code-path analyses not locally reproduced failures.
1. [major] Closing or unmounting the inspector does not cancel an active shell resize drag
startDrag() deliberately returns a cancel function because a right-side surface can disappear while the mouse is still down. The surviving detached-overlay path stores that function and cancels/reverts it from its close lifecycle. The new shell path in app-shell.ts, however, calls doStartDrag(e, 'rightInspector', dragCtx) and discards the return value; releaseInspector() only hides/clears the host.
Reproduction path:
- Open Cell/Rows/Reference.
- Press and hold the inspector separator and move it.
- While still holding the mouse, close with Escape, sign out, switch surfaces, or otherwise replace/fold the occupant.
- The
windowmousemove/mouseup listeners remain active. Later pointer movement still mutatesstate.rightInspectorPxand the hidden host; the eventual mouseup persists an abandoned width.
This is specifically the lifecycle leak that the old attachDrawerResize() cancellation protected against, now reintroduced at the shell ownership boundary.
Action: make the shell own the active inspector-drag cancellation handle, invoke it from inspector fold/teardown and shell dispose(), and restore the pre-drag transient state on cancellation. Add a test that closes mid-drag, then sends later mousemove/mouseup events and asserts no style/state mutation and no preference write.
2. [major] The overlay-era 92vw maximum is unsafe for a docked layout, and the width is not re-clamped when the inspector opens
clampDrawerWidth() still permits 0.92 * viewportWidth. That was reasonable for a fixed overlay, but the new host is flex: 0 0 auto beside a non-shrinking sidebar and two separators. At a 1280px viewport, the inspector alone may claim about 1178px; adding even the minimum 180px sidebar and handles leaves no usable center surface. The current geometry test only uses the default width.
There is a second stale-width path: app-shell.ts clamps once when the shell mounts, while showInInspector() merely unhides the existing host. If the browser moves from a wide monitor to a narrow viewport while the inspector is folded, reopening uses the old inline width without a fresh clamp, despite the state comment claiming the full clamp applies whenever the inspector is opened.
Action: use a dock-aware temporary display clamp based on the actual .main-row space and a defensible center minimum; preserve the user's preferred width separately. Recompute on unfold and viewport/layout changes, not only at shell construction. Add browser tests for maximum/legacy widths and for resize-after-mount while folded and while open.
3. [major] Cell and Rows consume Escape globally even though the dock is non-modal; mobile is simultaneously full-screen but leaves background shortcuts active
Both docked Cell and Rows use escapePolicy: 'always' while deliberately not acquiring the keyboard owner. Consequently, an Escape pressed in the editor or results is captured by the inspector first and cannot execute the existing global running-query cancel action. The added unit test explicitly locks in app.actions.cancel not being called. The absence of stacked drawers does not justify global Escape capture; stacking and focus scope are independent concerns.
The same document-based dock branch is also used at mobile widths, where CSS turns .inspector-host into a fixed, full-screen surface. That branch still acquires no keyboard owner, so hardware-keyboard application shortcuts remain active behind a visually full-screen panel. This is a mobile behavior change even though mobile redesign is outside this phase.
Action: for the desktop non-modal dock, use focus-inside semantics so Escape outside preserves global behavior. For the mobile full-screen presentation, explicitly retain the modal ownership/focus behavior or introduce a presentation-aware lifecycle policy. Add real-browser tests covering Escape with focus inside vs. outside, running-query cancellation, and mobile hardware-keyboard behavior.
4. [minor] The compatibility read order selects non-empty invalid values before validating them
createState() chooses the first truthy stored string and only then calls parseInt()/clamp(). A malformed non-empty rightInspectorPx such as "bad" therefore blocks a valid docPanePx/cellDrawerPx fallback and produces NaN; clamp() preserves NaN, and the shell attempts to set a NaNpx width. This contradicts the documented “real/valid value wins” migration rule.
Action: parse and validate each candidate independently, choose the first finite valid width, then clamp it. Add cases for malformed canonical and legacy values, whitespace, and out-of-range numbers.
Verdict
Not ready to merge until the three major lifecycle/geometry/input-scope issues are addressed. The primitive extraction and failed-mount listener fix are directionally sound, and the current CI run is green, but the uncovered transition states can leave stale global listeners/preferences or make the center surface unusable.
…fix compat-read NaN Three third-party review findings against #596/#586 (SurfaceLifecycle + docked right-inspector, umbrella #593 phase 1): 1. Closing the docked right-inspector mid-drag used to leak the resize drag's window mousemove/mouseup listeners: app-shell.ts discarded startDrag's returned cancel handle, so a drag surviving Escape/sign-out/ surface-switch/occupant-replacement kept mutating a hidden host and persisted an abandoned width on the eventual mouseup. The shell now owns the cancel handle and calls it from inspector-host.ts's releaseInspector (the single choke point every close path funnels through) and from the shell's own dispose(), mirroring drawer.ts's existing cancelActive pattern including reverting the pre-drag width. 2. The docked inspector's width used clampDrawerWidth's flat [320, 92vw] bound, unsafe now that the inspector is a real `.main-row` flex sibling next to a non-shrinking sidebar and two resize handles — it could starve `.query-host`/`.dashboard-host` to nothing. Added a dock-aware clampDockedInspectorWidth (splitters.ts) that reserves a CENTRE_MIN_PX floor (320, matching the inspector's own floor) for the centre surface, and reclamps on every unfold and viewport resize via a shell-owned reclampInspectorWidth hook — not only once at construction — without ever mutating the persisted rightInspectorPx preference itself. 3. state.ts's rightInspectorPx compat read chained candidates with `||`, so a malformed canonical value both blocked a valid docPanePx/ cellDrawerPx fallback and survived as NaN through clamp, applying a literal "NaNpx" width. Each candidate is now parsed and validated independently, keeping the documented precedence for real values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf
…arsing The review fixes in 8539d81 changed user-visible behaviour that the CHANGELOG entry did not yet describe: the inspector's maximum width now reserves a centre minimum instead of allowing a flat 92vw, is recomputed on unfold and window resize, and never narrows the persisted preference; and a corrupt canonical width now falls through to a real legacy value rather than producing NaN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf
Disposition of the ChatGPT pass-1 reviewEvery finding was verified against the code before being accepted — none were taken on trust.
Verification of each accepted finding
Finding 3 — deferred, with the consequence statedRecorded in #488's body under Being explicit about what that means for this PR, so it reads as a decision and not an oversight: The desktop half cannot be fixed in isolation: switching Cell/Rows to Anti-vacuity checksTwo claims of this PR are the kind that can pass without testing anything, so both were
An earlier round had already fixed two assertions that had become unfalsifiable once Gate at this head
Follow-up filed: #595 ( |
BorisTyshkevich
left a comment
There was a problem hiding this comment.
ChatGPT review pass 2
Previously reviewed head: ad88a88d25dbd96f0562c00b5140d6c2270e6ce0
Reviewed head: dfd91413e92a2ebe297ec54f97e191b9b282a744
I compared the two new commits (8539d817448b60773417bbfc4f0ce112af68a9ab and dfd91413e92a2ebe297ec54f97e191b9b282a744) against the prior head, then re-inspected the complete 31-file PR and the current CI result. The canonical Actions run for this head is green. A local checkout/test run was not possible because this runtime still cannot resolve github.com, so the findings below are based on the canonical source, diff, tests, and CI metadata.
Prior findings reassessed
- Pass-1 finding 1 (active resize survives close): fixed. The shell now retains the drag cancel handle,
releaseInspector()and shell disposal invoke it, the pre-drag preference is restored, and normal mouseup clears the cancellation handle. - Pass-1 finding 2 (unsafe/stale width clamp): partially fixed. The new displayed-width clamp accounts for sidebar/handle reservation and is rerun on unfold and window resize. Two reachable layout transitions still violate the claimed centre-width invariant, detailed below.
- Pass-1 finding 3 (global Escape capture and mobile full-screen non-modality): unresolved. It is documented as deferred, but the user-visible regressions remain in this PR.
- Pass-1 finding 4 (invalid canonical preference produces
NaN): theNaNpath is fixed. Fully nonnumeric values now fall through; numeric-prefix garbage is still accepted as valid, detailed below.
1. [major] The new clamp still does not guarantee CENTRE_MIN_PX when the desktop row cannot fit both 320px minima
clampDockedInspectorWidth() computes ceiling = totalWidth - reservedPx - 320, but then calls clamp(px, 320, ceiling). When ceiling < 320, the generic clamp returns the 320px inspector floor, sacrificing the centre minimum that the helper and comments claim to guarantee.
This is not confined to the mobile layout. The full-screen override begins at 768px, while the dock remains active above it:
- At 900px with the default 248px sidebar, the new e2e test itself calculates a 318px centre and asserts only that the inspector is 320px.
- At 769px with the default sidebar, the centre is about
769 - 248 - 14 - 320 = 187px. - At 1024px with the allowed 420px sidebar, the centre is about
1024 - 420 - 14 - 320 = 270px.
The test named “protect the centre surface” only requires queryBox.width > 200, not >= CENTRE_MIN_PX, so it cannot enforce the stated invariant.
Action: define an explicit fallback for the infeasible range instead of relying on a clamp with hi < lo: switch to the full-screen presentation, fold/narrow the sidebar, permit a smaller inspector, or raise the presentation breakpoint based on the actual reserved width. Add browser coverage at 769/900/1024px and with sidebarPx = 420, asserting the centre width against the actual contract constant.
2. [major] A live left-sidebar resize does not re-clamp an already-open inspector
The new re-clamp runs at shell construction, inspector unfold, and window.resize. The sidebar splitter's dragCtx.apply('col', value) only changes sidebar.style.width; it does not call reclampInspectorWidth(). Therefore the reservation used to choose the inspector width becomes stale while both panels are visible.
Concrete path at 1280px: open a 600px inspector with the default 248px sidebar (centre ≈418px), then drag the sidebar to its allowed 420px maximum. The inspector remains 600px and the centre falls to roughly 246px until an unrelated window resize or fold/unfold occurs.
The new e2e sidebar test avoids this path by closing the inspector, changing the sidebar, and reopening it. The unit test likewise changes state.sidebarPx while folded, so neither covers the live transition.
Action: re-clamp the displayed inspector width during the 'col' splitter apply path (without mutating the saved inspector preference), and add a real-browser test that drags .col-resize while the inspector remains open.
3. [major] Cell and Rows still capture Escape globally, and mobile remains full-screen but non-modal
Cell and Rows still use escapePolicy: 'always' with no keyboard-owner acquisition on the docked path. Escape pressed in the editor/results is therefore consumed by the inspector before the existing running-query cancel action. Below 768px, CSS changes the same host into a fixed full-screen panel, but hardware-keyboard application shortcuts remain live behind it.
The strongest counterargument is that changing to focus-inside requires a deliberate focus-on-open policy so immediate Escape behavior is defined. That makes this a cross-cutting design decision, but deferring it does not remove the current regressions: desktop Escape outside the panel no longer has its prior global meaning, and mobile behavior changed despite mobile being a non-goal.
Action: resolve the presentation/focus policy before merge, or explicitly separate mobile and desktop lifecycle policies in this phase. Add Chromium/WebKit coverage for Escape with focus inside and outside, running-query cancellation, and mobile hardware-keyboard shortcuts.
4. [minor] The compatibility parser still treats malformed numeric prefixes as valid values
firstValidPx() uses parseInt(raw, 10) and accepts any finite result. Values such as "420px" or "1e3" therefore win over valid legacy fallbacks (420 and 1, respectively) instead of being treated as corrupt. This is narrower than the original NaN bug, but it contradicts the new fail-closed/“valid candidate” contract.
Action: validate the complete trimmed string before conversion, then select the first fully valid finite numeric candidate. Add trailing-junk and exponent-form regression cases.
Verdict
The drag-cancellation and core NaN fixes are sound, but the width fix is incomplete across reachable desktop layouts and live sidebar resizing, and the pass-1 Escape/mobile regression remains unresolved. Changes are still required before merge.
Disposition of the ChatGPT pass-2 reviewAll four findings were verified against the code. All four are real. By owner decision none is
Verification detail1 — confirmed, and the band is wider than the review states.
2 — confirmed. 4 — confirmed and narrow. Why none is a regressionPre-#586 this surface was a Accepted cost of merging as-is
Two things checked and found correctSo they are not re-litigated later: the drag path and the re-clamp path use the same reservation Additionally filed from my own reviewNot raised by either ChatGPT pass: Also noted there: the |
What & why
Closes #586. Part of #593 (phase 1 of 8 — the ADR-0004 vanilla-shell investment track).
Replaces six copy-pasted overlay lifecycles with one
SurfaceLifecycleprimitive, and gives.main-rowa real shell-owned docked right slot instead of three independently mountedposition: fixedoverlays ondocument.body.src/ui/surface-lifecycle.ts(new) — idempotent single-funnelclose(), capture-phaseEscape with an explicit
escapePolicy: 'always' | 'focus-inside' | 'none', optionalkeyboard-owner acquisition, and a
returnFocusToelement-or-resolver contract (the resolveris called at close time, so it can hand back whatever is on screen then rather than a
possibly-detached element captured at open time).
src/ui/inspector-host.ts(new) — the "one occupant of the shared dock" manager, keyed bya
WeakMapper host element rather than a module global.src/ui/app-shell.ts—inspectorHost+inspectorResizemount as real layout siblingsof
queryHost/dashboardHost, with a new'rightInspector'splitter axis and folded stateexpressed as
hidden(the patternshowHostalready uses).isTopDrawer, the.cd-backdropDOM probes and CSS, the'docPane'splitterbranch, and
drawer.ts'sstateKeyplumbing.cellDrawerPx/docPanePxcollapse into onerightInspectorPxpreference — compat readorder
rightInspectorPx→docPanePx→cellDrawerPx→ 480px default, with a singlecanonical write (
splitters.ts:140).drawer.ts,doc-pane.tsandresults.tsthat documented the old three-surface split as deliberate(Version-exact ClickHouse reference documentation in Workbench #60/Cell-detail right-hand drawer: add horizontal resize #101/Panels: visualization registry + Panel drawer tab + Library panel field #166/Docs Phase 1: rich function reference in CM6 hover and docs pane #313) are rewritten to describe the shared-lifecycle model, so a future reader
cannot conclude the split is still intentional.
Two deliberate behaviour changes
pre-refactor(shell): one SurfaceLifecycle primitive and a docked right-inspector slot #586 modal cell drawer disabled every app shortcut (Run, Save, …) while open, because
shortcuts.ts:264bails whenownsKeyboard(app). Consequence:app.ts's Query↔Dashboardsurface transition, sign-out, and connection-scope teardown now close whichever surface
occupies the dock (
closeInspector) rather than only Reference — the old code's commentexplicitly relied on cell-detail's modality to explain why it didn't need closing there.
of stacking a second panel. Tool-registry/tab persistence semantics belong to Unify cell detail, rows and documentation in a foldable desktop right inspector #488, which
this issue deliberately does not implement.
The one surviving non-docked case — a cell-detail drawer opened inside a real detached browser
tab — keeps a self-contained overlay (renamed
.cell-detail-overlay), still built onSurfaceLifecycle.Review findings fixed in
ad88a88.cd-backdroprepo-wide (AC3) lefttests/e2e/editor-docs.spec.js's.cd-backdropcount probe andtests/unit/doc-pane.test.ts'squerySelector('.cd-backdrop')passing unconditionally — ascrim reintroduced under any other class name would still have reported green. Re-pointed at
real rendered claims: the editor is the topmost element at its own centre
(
elementFromPoint), and the inspector host's child is the panel, not a wrapper.position: fixedoverlay" had no automated coverage. happy-dom cannotevaluate CSS layout, so the unit suite could only prove DOM sibling order,
hiddenand theinline width write — the geometric claim that is this phase's whole point rested on one manual
browser check. Adds
tests/e2e/inspector-dock-layout.spec.js(chromium + webkit): foldedcontributes zero layout width, opening narrows
.query-hostand the inspector's box neverintersects it, and dragging the handle resizes live.
openSurfaceLifecycleinstalls its capture-phase Escape listener beforeshowInInspector's return value reveals whether a shell is mounted, so a failed mount left apermanent unclosable document listener —
doc-pane.tsskipped only itspanesbookkeeping andresults.tsdidn't check the return value at all. Both now tear the lifecycle down, covered bytests asserting the same listener reference is added and removed.
Verification beyond a green suite
Because two of this PR's central claims are the kind that pass vacuously, both were
sabotage-checked rather than trusted:
.inspector-hosttoposition: fixedfails the new geometry spec (2 of 3 tests);tests/unit/state.test.ts.The compat-order tests use three distinct values (500/420/560) per source, so they discriminate
ordering rather than passing on equal expected values.
Full gate, run explicitly (
.npmrcsetsignore-scripts=true, sonpm testalone does not runpretest):npm test203 files / 6696 tests, coverage 100 / 96.86 / 100 / 100 withperFile: true;npm run check:typesclean;npm run check:archclean (185 files, 9 rules);npm run buildclean;npx playwright test --project=chromium --project=webkit404 passed,4 skipped, 0 failed.
Follow-up filed: #595 (
inbox) —DashboardApp.prefsis vestigial now that Dashboard'scell-detail always docks; deliberately not folded into this PR.
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 changed🤖 Generated with Claude Code
https://claude.ai/code/session_01Da66KLYSmCey6Gi7RMFGcf