Skip to content

feat(devtools): plugin workspace with splits, stacked tabs, drag and resize - #493

Open
AlemTuzlak wants to merge 4 commits into
codex/tanstack-devtools-workbenchfrom
feat/plugin-layout-tree
Open

feat(devtools): plugin workspace with splits, stacked tabs, drag and resize#493
AlemTuzlak wants to merge 4 commits into
codex/tanstack-devtools-workbenchfrom
feat/plugin-layout-tree

Conversation

@AlemTuzlak

Copy link
Copy Markdown
Collaborator

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.

  • Drag a pane's tab onto the edge of another pane to split it, its middle to stack, or another tab bar to move it there.
  • Drag the gutter between two panes to resize: one grows by exactly what the other loses, and neither shrinks below a readable minimum.
  • Drag an entry out of the Plugins strip to place a pane where you drop it instead of appending it, including onto an empty workspace, where it fills the area.
  • Hold to drag. A press becomes a drag after 500ms; a click stays a click.
  • Keyboard parity for everything above, because the pointer gestures are suppressed while detached into picture-in-picture.

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.

destroy is 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.ts holds 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.activePlugins in localStorage is superseded by state.layout; activePlugins is 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/devtools goes 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. createSortable alone is 6.04 kB and is kept for cross-list tab transfer and FLIP; createSplitPane and createResizable are deliberately unused, on architecture rather than size — both write DOM styles that fight the absolute-rect model, and SplitPane's overflow: hidden would break the per-pane scrolling a test asserts.

@neodrag/solid is pinned to 3.0.0-next.10, not floated on @next: v3 is unreleased and its published exports already differ from its documentation.

Verification

  • 323 unit tests in @tanstack/devtools; 23/23 packages green; types and lint clean.
  • 29 e2e passing in react-vite, covering pointer drags, gutter drags, keyboard moves and resizes, drop-zone resolution, persistence across reload, and iframe survival.
  • Driven by hand in the React example: splits, stacks, reordering, resizing, keyboard moves, the empty state, and persistence.

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, 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

  • The workspace measured itself once at mount. A hidden element measures zero, so every derived rect was zero and hit-testing silently found nothing — a drag did nothing rather than looking broken.
  • The strip's click-suppression flag was sticky, so a drag ending away from an entry swallowed the next real click. That was the "takes several tries to open a plugin" symptom.
  • The strip-to-workspace handoff lived in module-level state. This package ships several bundles, so two components could hold different copies of the module and never see each other's writes; it is on the context now.
  • appendPane exists because splitting the last pane halves it, so three plugins opened 1/2, 1/4, 1/4 instead of equal thirds.

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.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ad896bc5-021c-4d7f-b04e-b9c1ae40ef0f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​neodrag/​solid@​3.0.0-next.10771009987100

View full report

@socket-security

Copy link
Copy Markdown

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.

Action Severity Alert  (click "▶" to expand/collapse)
Warn Medium
Low adoption: npm @neodrag/solid

Location: Package overview

From: packages/devtools/package.jsonnpm/@neodrag/solid@3.0.0-next.10

ℹ Read more on: This package | This alert | What are unpopular packages?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Unpopular packages may have less maintenance and contain other problems.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@neodrag/solid@3.0.0-next.10. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@nx-cloud

nx-cloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 174e2fd

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 44s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-07 18:35:36 UTC

@nx-cloud

nx-cloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit 174e2fd

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-devtools

npm i https://pkg.pr.new/@tanstack/angular-devtools@493

@tanstack/devtools

npm i https://pkg.pr.new/@tanstack/devtools@493

@tanstack/devtools-a11y

npm i https://pkg.pr.new/@tanstack/devtools-a11y@493

@tanstack/devtools-bundler-core

npm i https://pkg.pr.new/@tanstack/devtools-bundler-core@493

@tanstack/devtools-client

npm i https://pkg.pr.new/@tanstack/devtools-client@493

@tanstack/devtools-rspack

npm i https://pkg.pr.new/@tanstack/devtools-rspack@493

@tanstack/devtools-ui

npm i https://pkg.pr.new/@tanstack/devtools-ui@493

@tanstack/devtools-utils

npm i https://pkg.pr.new/@tanstack/devtools-utils@493

@tanstack/devtools-vite

npm i https://pkg.pr.new/@tanstack/devtools-vite@493

@tanstack/devtools-event-bus

npm i https://pkg.pr.new/@tanstack/devtools-event-bus@493

@tanstack/devtools-event-client

npm i https://pkg.pr.new/@tanstack/devtools-event-client@493

@tanstack/preact-devtools

npm i https://pkg.pr.new/@tanstack/preact-devtools@493

@tanstack/react-devtools

npm i https://pkg.pr.new/@tanstack/react-devtools@493

@tanstack/solid-devtools

npm i https://pkg.pr.new/@tanstack/solid-devtools@493

@tanstack/svelte-devtools

npm i https://pkg.pr.new/@tanstack/svelte-devtools@493

@tanstack/vue-devtools

npm i https://pkg.pr.new/@tanstack/vue-devtools@493

commit: 174e2fd

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