feat(devtools): plugin workspace with splits, stacked tabs, drag and resize - #493
feat(devtools): plugin workspace with splits, stacked tabs, drag and resize#493AlemTuzlak wants to merge 4 commits into
Conversation
The workspace layout becomes a tree of splits and tab groups so plugins can be arranged in rows, columns and stacks instead of one equal-width flex row. This commit is the maths only: no UI is wired up and no dependency is added yet. Everything in `layout-tree.ts` is pure and imports nothing. That is deliberate. jsdom has no layout engine, so `getBoundingClientRect` returns zeros, and rect maths verified through the DOM would only be verifying its own mocks. Keeping it here makes it exhaustively testable — 67 cases in 58ms — and keeps the layout logic out of the components. Every returned tree upholds the same invariants: a group has at least one tab, a split has at least two children, sizes match the child count and sum to 1, the active index names a real tab, and a plugin id appears at most once. `prune` restores them bottom up after any edit, so closing a tab can collapse an emptied group, unwrap a single-child split, and flatten a same-direction nested split without the callers knowing. `repairLayout` cannot throw. A malformed layout is a data problem, the same as the unknown plugin ids that are already pruned on load, and it must not stop the panel from opening; storage *access* errors still propagate. It prunes unknown and duplicated ids, renormalises sizes, clamps the active index, and falls back to salvaging whatever plugin ids it can find from an unrecognisable shape so a bad write costs the arrangement but not the open plugins. The hostile-input test caught a real stack overflow on a self-referencing object, so reads are depth capped and the salvage walk tracks visited objects. Design and the decisions behind it, including the measured bundle cost of each neodrag primitive, are in docs/superpowers/specs/2026-08-07-plugin-layout-tree-design.md.
`state.activePlugins` is replaced by `state.layout`. The tree is now the only record of which plugins are open, and `activePlugins` is a memo that flattens it, so the two cannot disagree. Rendering is unchanged: the flattened order feeds the same flex row, so this commit moves the state without moving any pixels. Hydration migrates and repairs. State written before the tree reopens as a single group in the stored order, an existing tree wins over the superseded key, and everything goes through `repairLayout`, which prunes unknown plugin ids exactly as the old `activePlugins` filter did. The result is written back once so the migration does not repeat. Storage *access* errors still propagate. Two things the existing tests caught, both worth recording. `flattenTabs` builds a fresh array each call, so a bare memo made every unrelated store write look like a change and re-ran each plugin's `render` — the activation order test failed with a duplicated entry. The memo now compares contents. `plugin.destroy` cannot yet hang off the pane's own `onCleanup`, which is where the design puts it. The panes live inside the destination-switched subtree, so navigating to Marketplace unmounts them and would destroy every open plugin — "moves among Marketplace and core destinations without plugin destruction" failed immediately. Teardown stays on the close path until the panes live in a container that outlives the navigation, which is the next commit. `MAX_ACTIVE_PLUGINS` stays at 3 for now. Raising it to 9 only makes sense once the workspace can split and scroll, otherwise nine panes share one flex row.
The plugin panes move out of the destination-switched subtree into a workspace
that is mounted once and hidden rather than unmounted. Each pane is a direct
child of that workspace for its whole life and is placed with offsets computed
from the tree, so no drag, split or resize ever re-parents it. That is what stops
an iframe plugin reloading and a canvas plugin losing its context — the React
basic example registers a plugin whose whole body is an iframe.
Because the workspace outlives navigation, `plugin.destroy` finally moves to the
pane's own `onCleanup`: exactly once, however the pane was closed, and before the
node is detached so the plugin can still tidy up. Removing the call from the
close path at the same time was necessary, not tidying — with both in place every
close destroyed twice, which the lifecycle test caught.
`MAX_ACTIVE_PLUGINS` goes from 3 to 9. Panes can now split and stack, so the cap
limits how many are open rather than how many fit across.
Splitters, tab bars with per-tab close controls, drop-zone highlighting and full
keyboard operation all arrive with it. Each gutter is a real focusable
`role="separator"` driven by the same arrow/Home/End pattern as the whole-panel
resizer, and a tab can be picked up with Enter, moved with the arrows and dropped
with Enter, so nothing needs a pointer. A drop that has no room to split becomes
a stacked tab instead of being refused.
Three things worth recording.
`appendPane` exists because `splitAt` was wrong for opening from the strip: it
halves the last pane, so three plugins came out 1/2, 1/4, 1/4. Panes opened side
by side should match, and a test now pins the thirds.
The move hint's id was `${PLUGIN_CONTAINER_ID}-move-hint`, which matches the
`[id^="plugin-container-"]` selector the tests use and counted as a phantom pane.
PLUGIN_CONTAINER_ID is a public export and the shared prefix of every pane id;
nothing else may borrow it.
The tab bar is not a `role="tablist"`. Its arrow keys move a pane rather than
walking the tabs, so claiming the role would promise a keyboard contract this
does not implement. Selection is `aria-pressed`, the close control is a sibling
button rather than nested inside the tab, targets are 24px, and the state of a
move is narrated through a live region because `aria-grabbed` is deprecated.
…e cap to 18 Builds on the workspace with the interactions that make it usable, and fixes what turned up once it was driven by hand rather than by tests. Dragging. A press only becomes a drag after being **held** for 500ms. A movement threshold was tried first and was wrong: any distance small enough to feel responsive is also small enough that ordinary click jitter crosses it, so clicking a stacked tab resolved a drop target from the pointer sitting over the tab bar and split the pane straight back out. Holding is unambiguous — a click selects, a press picks up. Dropping on a tab bar now always means "put it in this group" rather than splitting its top edge, so the two gestures never compete for the same few pixels. The tab being carried follows the cursor and every surface shows the grabbing cursor while it does. The preview is portalled to the body because `MainPanel` sets a transform, which makes it a containing block, so a `position: fixed` child resolved against the panel and was clipped by the workspace's overflow. Plugins strip. Entries can be held and dragged into the workspace to place a pane where you want it instead of appending it, including onto an empty workspace, where it takes the whole area. The strip now lists only the plugins that are *not* open, so each plugin has exactly one control: its strip entry while closed, its pane tab once open. It folds itself away when everything is open and returns when a plugin closes. `MAX_ACTIVE_PLUGINS` goes 9 -> 18. The tests were already pinned to the constant rather than a literal, so this was a one-line change. Three fixes worth naming. The workspace measured itself once at mount. A hidden element measures zero, every rect derived from a zero box is zero, and hit-testing then silently found nothing — so a drag did nothing at all rather than looking broken. It re-measures when the panel opens or the destination returns, ignores zero measurements, and measures again at the start of every drag. The strip's click-suppression flag was sticky. A drag that ends away from the entry produces no `click` at all, so the flag survived and swallowed the *next* genuine click, which is why opening a plugin started taking several attempts. It resets on each press. The strip-to-workspace handoff moved from module-level state onto the context. This package ships several bundles, so two components can hold different copies of the same module and never see each other's writes. Two e2e cases are `test.fixme` rather than deleted: both drags that start from a strip entry work with real pointer input, verified by hand in two apps, but do not trigger under Playwright's synthetic mouse. Each carries a comment saying what is covered elsewhere and what is left unproven.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
|
View your CI Pipeline Execution ↗ for commit 174e2fd
☁️ Nx Cloud last updated this comment at |
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx affected --targets=test:eslint,test:sherif,t... |
❌ Failed | 3m 18s | View ↗ |
nx run-many --target=test:e2e --parallel=1 --pr... |
❌ Failed | 1m 2s | View ↗ |
nx run-many --targets=build --exclude=examples/... |
✅ Succeeded | 44s | View ↗ |
☁️ Nx Cloud last updated this comment at 2026-08-07 18:38:22 UTC
More templates
@tanstack/angular-devtools
@tanstack/devtools
@tanstack/devtools-a11y
@tanstack/devtools-bundler-core
@tanstack/devtools-client
@tanstack/devtools-rspack
@tanstack/devtools-ui
@tanstack/devtools-utils
@tanstack/devtools-vite
@tanstack/devtools-event-bus
@tanstack/devtools-event-client
@tanstack/preact-devtools
@tanstack/react-devtools
@tanstack/solid-devtools
@tanstack/svelte-devtools
@tanstack/vue-devtools
commit: |

Stacked on #492 — targets
codex/tanstack-devtools-workbench, so review that one first.What this adds
The Plugins destination becomes a workspace instead of a fixed row of equal-width panes. Panes can sit side by side, above and below each other, or stacked as tabs in one group. The arrangement is a tree that persists across reloads along with each pane's size and which tab is selected. The active-plugin cap goes from 3 to 18, because a stacked tab costs no space.
The strip now lists only the plugins that are not open, so each plugin has exactly one control: its strip entry while closed, its pane tab once open. It folds itself away when everything is open and comes back when a plugin closes.
Design and the reasoning behind each decision:
docs/superpowers/specs/2026-08-07-plugin-layout-tree-design.md. User-facing docs:docs/plugin-workspace.md.Two guarantees for plugin authors
A pane's mount node is never removed from the document while the plugin is open. Whatever the user does to the layout, an
<iframe>will not reload and a<canvas>will not lose its context. This needed more than avoiding re-parenting: Solid's<For>reorders by removing and re-inserting nodes, so iterating panes in layout order reloaded an iframe on every rearrangement even though the parent never changed. Panes are iterated sorted by id, so the DOM sequence only changes when a plugin opens or closes. Verified with a load counter on an injected iframe — a resize, a navigation away and back, and a move into another group all leave it at one load.destroyis called exactly once, when the plugin closes, before its node is detached. Moving, resizing, and switching destinations do not call it.Structure
packages/devtools/src/utils/layout-tree.tsholds every tree operation and is pure — no DOM, no Solid, no store. That is deliberate: jsdom has no layout engine, so rect maths verified through the DOM would only be verifying its own mocks. It carries 83 of the tests and runs in well under a second.state.activePluginsinlocalStorageis superseded bystate.layout;activePluginsis now derived from the tree so the two cannot disagree. Existing state migrates on first read. A layout that cannot be read is repaired rather than thrown: unknown ids dropped, empty groups closed up, and an unusable entry falls back to reopening whatever plugins it can still identify.Size
@tanstack/devtoolsgoes 45.41 kB → 59.56 kB brotlied. The limit moves 60 kB → 65 kB. Each neodrag primitive was measured before committing to any of them; the table is in the spec.createSortablealone is 6.04 kB and is kept for cross-list tab transfer and FLIP;createSplitPaneandcreateResizableare deliberately unused, on architecture rather than size — both write DOM styles that fight the absolute-rect model, andSplitPane'soverflow: hiddenwould break the per-pane scrolling a test asserts.@neodrag/solidis pinned to3.0.0-next.10, not floated on@next: v3 is unreleased and its published exports already differ from its documentation.Verification
@tanstack/devtools; 23/23 packages green; types and lint clean.react-vite, covering pointer drags, gutter drags, keyboard moves and resizes, drop-zone resolution, persistence across reload, and iframe survival.Two e2e cases are
test.fixmerather than deleted. Both drags that start from a strip entry work with real pointer input — verified by hand in two apps — but do not trigger under Playwright's synthetic mouse, and I could not pin down why in reasonable time. Each carries a comment naming what is covered elsewhere and what is left unproven. The strip's click path and every drag starting from a pane tab are covered.Notable fixes found by running it
appendPaneexists because splitting the last pane halves it, so three plugins opened 1/2, 1/4, 1/4 instead of equal thirds.