Skip to content

feat(modules): ResponsiveLayout — container-size-driven reflow (7 phases)#1391

Closed
mathuo wants to merge 9 commits into
v8-branchfrom
feat/responsive-layout
Closed

feat(modules): ResponsiveLayout — container-size-driven reflow (7 phases)#1391
mathuo wants to merge 9 commits into
v8-branchfrom
feat/responsive-layout

Conversation

@mathuo

@mathuo mathuo commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Draft — for review before docs/demo. Container-size-driven responsive layouts: the docking-tree analogue of CSS container queries.

What this is

A new ResponsiveLayoutModule that makes a dockview layout reflow as its container resizes — collapsing side-by-side groups into tabs when narrow, restacking columns to rows, hiding low-priority groups — and restoring the exact wide arrangement losslessly when there's room again. It reacts to container width (not viewport), so a dock embedded in a 400px sidebar reflows even on a 4K monitor.

Spec: enterprise-modules/responsive-layout.md. Built opt-in via a new responsive DockviewOptions field; inert (and toJSON byte-identical) unless configured.

Built in 7 reviewable phases (one commit each)

  1. Breakpoint resolution — pure hysteresis resolver (anti-thrash dead bands) + debounced size observer; fires onDidBreakpointChange.
  2. Canonical/derived split — hold the canonical (wide) layout; toJSON serializes it, never the collapsed view.
  3. CollapsePass — fold side-by-side groups to tabs by priority (folds LayoutPriority, incl. Fill).
  4. Apply — reconcile the live tree via fromJSON({ reuseExistingPanels }); reentrancy guard, per-breakpoint rules, defer-on-maximize.
  5. RestackPass + HidePass — flip axis; park Low-priority groups (visible:false).
  6. RebaseController — fold edits made while collapsed (close/add/activate) back onto canonical; rebase: auto|discard|manual + onDidRebaseConflict.
  7. Canonical API + float exclusionget/setCanonicalLayout(); floats/popouts/edge groups untouched across reflow.

Design highlights for reviewers

  • Reversibility is structural: canonical is cloned, never mutated, so widening always reproduces it byte-for-byte — proven by tests, not hoped for.
  • Pure engine, isolated apply: all transforms are pure (canonical, rules) -> SerializedDockview; the only mutation is the fromJSON apply behind an _applying reentrancy guard.
  • Anti-thrash: direction-aware hysteresis + debounce, unit-tested against a resize sweep (each boundary crossed once per direction).

Tests

86 responsive tests — pure engine (resolver, observer, reflow engine, rebase, canonical store) + jsdom integration (collapse/expand, panel reuse, canonical serialization, rebase round-trips, float survival). dockview-core 1135, dockview-modules 321, both green; core + modules typecheck clean.

Notes

  • Depends on LayoutPriority.Fill (PR feat(core): LayoutPriority.Fill — one view/group fills, siblings stay fixed #1389, targeting master) — that commit is merged into this branch, so the diff includes it. For a clean merge, land Fill on v8-branch first (via master → v8 merge-forward), then this rebases to responsive-only.
  • Docs page + live CodeRunner demo are not included — they need the docs-package template infrastructure and are tracked as a separate follow-up.

🤖 Generated with Claude Code

mathuo and others added 9 commits July 2, 2026 17:23
Add `LayoutPriority.Fill` so a view (standalone gridview) or group
(dockview) absorbs all surplus/deficit space while its siblings keep
their fixed sizes — the common "central content fills, side panels stay
put" layout that proportional priorities cannot express. Closes #1378.

- splitview: fill views absorb space in layout/relayout/distributeEmptySpace;
  multiple fills split the surplus equally; hidden fills fall back to normal
- gridview: Fill propagates up the branch tree (highest precedence); the
  runtime priority setter forces a full relayout so propagation takes effect
- dockview: group-level priority via addGroup({ priority }),
  group.api.priority, and toJSON/fromJSON round-trip

Backwards compatible: every behaviour change is gated on a fill view being
present, so existing layouts and serialized state are unchanged; the
serialized `priority` key only appears when a group opts in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring LayoutPriority.Fill into the responsive-layout branch — the
ResponsiveLayoutModule folds LayoutPriority (incl. Fill) into its
collapse-priority scoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the seam + skeleton for container-size-driven responsive layouts. The
module resolves the active breakpoint from the observed container width and
fires `onDidBreakpointChange` as the container crosses thresholds. No reflow
transforms yet — those land in later phases.

- core: `responsive` option (breakpoints + hysteresis), the
  `IResponsiveLayoutHost` / `IResponsiveLayoutService` contracts + service
  slot, and `activeBreakpoint` / `onDidBreakpointChange` / `reflow()` on the
  component + public api.
- module: a pure `BreakpointResolver` (direction-aware hysteresis dead bands,
  anti-thrash), a debounced `SizeObserver` (injectable clock), and
  `ResponsiveLayoutService` wiring them to `onDidLayoutChange`.

Backwards compatible: inert unless `responsive.breakpoints` is configured;
the option is ignored when the module is absent.

Tests: 21 resolver + 8 observer + 4 integration. core 1135, modules 268 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erialization

Introduce the canonical/derived model beneath the breakpoint resolver. The
service now holds the canonical ("wide") layout, projects the derived layout
from it via a pure `deriveLayout` (identity for now), and serializes the
*canonical* through `toJSON` so a narrow-width save can never bake a collapsed
layout in. No collapse transforms yet — the derived layout still equals the
live one.

- module: `CanonicalStore` (holds the wide SerializedDockview), a pure
  `deriveLayout(canonical, rules)` (identity clone) + `diffLayouts` (zero ops
  when identical — the idempotence guard), wired into `ResponsiveLayoutService`.
- core: `IResponsiveLayoutHost` gains `toJSON()` + `onDidLayoutFromJSON`;
  `IResponsiveLayoutService` gains `serializeCanonical()`; `DockviewComponent.
  toJSON` serializes canonical grid/panels when derived, else the live tree.

Backwards compatible: `serializeCanonical()` returns undefined until the layout
is actually collapsed, so serialization is byte-identical to today (verified by
the full core suite + round-trip/idempotence tests).

Tests: +5 canonical-store, +7 reflow-engine, +3 integration (round-trip,
idempotence, inert-serialization). core 1135, modules 283 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first real reflow transform. `deriveLayout` now implements `collapseToTabs`:
side-by-side groups fold into a single tabbed group, ordered by group priority.

- Priority scoring (`computeGroupPriority`): folds `LayoutPriority` — `Fill`
  outranks `High` > `Normal` > `Low` — plus an active-group tie-break bonus, so
  the group the user is working in survives collapse longest.
- The highest-priority group is the host (its id + settings survive); remaining
  groups' panels become tabs after it in descending priority order (document
  order breaks ties); the globally-active panel stays active.
- Pure + non-destructive: canonical is cloned, never mutated — so widening
  (re-deriving with no collapse rule) reproduces it byte-for-byte.

This is the pure engine only (parallel to the Phase 4 Applier): the service
does not yet apply a collapse to the live tree — it keeps deriving identity
until the minimal-diff apply + rebase safety land, so behaviour is unchanged.

Tests: +2 priority scoring, +8 collapse (ordering, host, active preservation,
single-group no-op, non-mutation, reversibility, panels pass-through).
modules 293 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ive reflow)

The collapse now reaches the screen. On crossing into a collapsing band the
service freezes the current layout as canonical, then reconciles the live tree
to the derived target via `fromJSON({ reuseExistingPanels: true })` — panels are
re-parented, not re-created. Widening back to the canonical band restores the
frozen wide layout exactly.

- reentrancy guard (`_applying`): the apply's own layout events never
  re-trigger a reflow.
- per-breakpoint rules (`effectiveRules`): a breakpoint's own `rules` win; the
  widest band is identity; narrower bands fall back to the default `rules`.
- applied-tracking (`_appliedBreakpoint`): a layout that *starts* inside a band
  (never crossing a boundary) still applies on the first settle.
- defer-on-maximize: reflow waits for restore.
- collapse output keeps the grid root a branch (dockview's `fromJSON` requires
  it) — one leaf group filling the primary axis.

Known limitation (later phase): edits made *while collapsed* are not yet rebased
onto canonical, so saving in a collapsed state persists the pre-edit wide layout.

Tests: +5 integration — collapse/expand round-trip, panel-instance reuse,
canonical (wide) serialization while collapsed, one-event-per-transition (no
re-entrant reflow), maximize deferral. core 1135, modules 298 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more reflow transforms, and `deriveLayout` now applies its rule chain in
array order so passes compose.

- restack: flip the primary axis (columns <-> rows) by flipping the grid
  orientation; nested branches alternate from the root, so the whole tree
  restacks. Sizes carry through and rescale to fit.
- hide: park the Low-priority groups by marking them `visible: false` — the
  group and its panels survive (re-shown on widen), they are just not laid out.
  The highest-priority group is always kept visible so the layout is never left
  empty. (An overflow affordance to reach parked groups is a later refinement.)

Both compose with collapseToTabs and, like it, are pure + non-destructive:
canonical is cloned, never mutated, so widening reproduces it exactly. They
reach the live layout through the existing Phase 4 apply.

Tests: +6 pure (restack flip/reversible, hide park/keep-top/no-op, rule-chain
ordering) + 2 integration (restack keeps panels through narrow/widen; hide
parks a Low group while keeping its panel alive). modules 306 green
(core unchanged, 1135).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…le collapsed)

Close the last correctness gap: edits a user makes *while collapsed* are now
folded back onto the canonical (wide) layout, so widening keeps them instead of
resurrecting the pre-edit arrangement.

- rebase (`responsiveRebase.ts`, pure): reconcile canonical against the live
  derived layout by panel id — closes remove the panel (and prune emptied
  groups / collapse single-child branches), adds insert into the canonical
  active group, and active-tab changes update canonical's active. Moves/resizes
  within the collapsed layout don't change the panel set and are ignored.
- service: on `onDidMutateLayout` while collapsed (and not mid-apply), rebase
  per `rebase` mode — `'auto'` (default) reconciles, `'discard'` freezes,
  `'manual'` fires `onDidRebaseConflict`. Conflicts (e.g. an add with no target
  group) surface the event instead of silently losing the edit.
- core: `rebase` option; host `serializeLiveLayout()` (raw live tree, bypassing
  the canonical `toJSON` hook — refactored `toJSON` to build on it) +
  `onDidMutateLayout`; `onDidRebaseConflict` on the service, component + api.

Tests: +7 pure (close, prune, add, activate, no-op, non-mutation, conflict) +4
integration (auto keeps a close/add through widen; discard drops it; manual
fires the conflict). core 1135, modules 317 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t exclusion

Completes the module's public surface and hardens the out-of-scope guarantee.

- public API: `getCanonicalLayout()` / `setCanonicalLayout()` on the service,
  component + api. `getCanonicalLayout` reports the wide layout even while
  collapsed; `setCanonicalLayout` swaps the wide baseline and re-derives for the
  current width. Both fall back to `toJSON`/`fromJSON` when the module is absent.
- float/popout exclusion: the apply now overlays the *live* floating groups,
  popouts and edge groups onto the derived target (new `applyGrid` helper), so a
  collapse/restore never tears them down or reverts a float moved while
  collapsed. The pure transforms already pass them through untouched.

Tests: +4 — getCanonicalLayout reports wide-while-collapsed, setCanonicalLayout
replaces + re-derives, a floated group survives collapse/widen intact, and the
pure transform passes floatingGroups through. core 1135, modules 321 green.

Docs page + live demo are the remaining discoverability piece (they need the
docs-package template infrastructure) — tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mathuo

mathuo commented Jul 2, 2026

Copy link
Copy Markdown
Owner Author

Closing — opened prematurely; review happening on the branch directly before a PR is raised.

@mathuo mathuo closed this Jul 2, 2026
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

⚠️ Generated files are out of date

Run npm run gen and commit the result.

Diff
diff --git a/__generated__/dockview-core-exports.txt b/__generated__/dockview-core-exports.txt
index 7c61537..f09526b 100644
--- a/__generated__/dockview-core-exports.txt
+++ b/__generated__/dockview-core-exports.txt
@@ -35,6 +35,7 @@ DismissableLayerOptions
 DistributeSizing
 DockviewActivePanelChangeEvent
 DockviewApi
+DockviewBreakpointChangeEvent
 DockviewComponent
 DockviewComponentOptions
 DockviewCompositeDisposable
@@ -77,6 +78,7 @@ DockviewPanelPinnedChangeEvent
 DockviewPanelRenderer
 DockviewPopoutGroupOptions
 DockviewReadyEvent
+DockviewResponsiveOptions
 DockviewTabGroupChangeEvent
 DockviewTabGroupCollapsedChangeEvent
 DockviewTabGroupColor
@@ -168,6 +170,8 @@ IPaneviewComponent
 IPaneviewPanel
 IPinnedTabsHost
 IPinnedTabsService
+IResponsiveLayoutHost
+IResponsiveLayoutService
 ISerializedBranchNode
 ISerializedLeafNode
 ISerializedNode
@@ -244,7 +248,9 @@ Position
 PositionResolver
 PositionResolverArgs
 PositionResolverResult
+ReflowRule
 RendererChangedEvent
+ResponsiveBreakpoint
 SashState
 SerializedDockview
 SerializedEdgeGroups

@sonarqubecloud

sonarqubecloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

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.

1 participant