feat(ui): dock the right pane with dockview (PR-A) #58

Merged
lz merged 17 commits from feat/dockview-pr-a into main 2026-07-13 21:09:38 +02:00
Owner

Replaces MainSplit.svelte's hand-rolled Files/Terminal/Artifacts tab strip with a dockview-core dock.

This is PR-A of three. B (make the workspace list a dock panel) and C (panels carry their own {workerId, sessionId}, so several sessions can be watched side by side) are deliberately out of scope and get their own specs.

  • Spec: docs/superpowers/specs/2026-07-11-dockview-migration-pr-a-design.md
  • Plan: docs/superpowers/plans/2026-07-11-dockview-pr-a.md

Why

  1. The terminal died whenever you looked at a file. SessionTerminal opens its WebSocket in onMount and closes it in onDestroy; because MainSplit rendered it inside an {#if} branch, switching to Files tore the socket down. tmux made that survivable, not pleasant.
  2. Panes couldn't be viewed together — the three were mutually exclusive.
  3. There was no layout engine at all. Every pane width in the codebase was a hardcoded px value; no splitter, no drag-resize.

What's here

  • dockview-core@7.0.2 — MIT, zero runtime dependencies. Verified by unpacking the published tarball, not by reading the docs (the docs site 404s on its options page).
  • defaultRenderer: 'always' keeps a hidden panel's DOM alive (visibility: hidden) instead of detaching it — this is what stops the terminal's socket being torn down.
  • dndStrategy: 'auto' — native HTML5 DnD for mouse, pointer-events + long-press for touch. dockview 7 ships both backends, so drag and resize work on a phone; there is no locked-mobile code path and no second rendering path.
  • A new artifact opens its own tab, inactive — an agent pushing an artifact must never yank the cursor out of a half-typed command.
  • New src/lib/dock/: panel-id scheme, layout persistence, the DockDeps contract, and a ~50-line SvelteContentRenderer bridging dockview's IContentRenderer onto Svelte 5's mount()/unmount().

Things worth a reviewer's attention

Live objects never reach dockview params. params is JSON-serialized into localStorage by toJSON(). The ArtifactsStore (a $state proxy), the FileExplorerProvider, and the openArtifact callback therefore reach panels through the createComponent factory closure — params carries only { artifactId }.

Artifact panels are stripped before the layout is saved. They're scope-bound, so restoring artifact:<id> into a different session would resurrect an id that no longer exists server-side. Stripping at save time means whatever is in storage is always restorable and there is zero reconciliation logic. Cost: refreshing while reading an artifact closes that tab. Deliberate.

This collapses an existing wart. There used to be two ArtifactsStore instances polling every 5s — one in ArtifactsPanel, plus a background one in MainSplit for toasts, torn down whenever the Artifacts tab opened so the two didn't double-poll. With no active-tab to gate on, there is now one store per scope, owned by Dock.svelte.

The mobile drawer toggles moved into the panels that own them (FileExplorerPanel, ArtifactsPanel), because they used to switch on an activeTab that no longer exists. Better factoring anyway — MainSplit no longer knows a file tree exists. MainSplit lost 315 lines.

Bugs found and fixed along the way

A Svelte reactivity loop in Dock.svelte (4f2d555). The store effect both read deps.store (to stop the previous one) and wrote it. deps is a $state proxy, so the read registered deps.store as a dependency of the very effect that writes it — a self-triggering loop that would stop the store it had just created, build another, and spin until effect_update_depth_exceeded, leaking a 5s poller each round. The live store now lives in a plain untracked local.

The first artifact of a session was never announced (39e5d53) — pre-existing on main. ArtifactsStore.refresh() used knownIds.size > 0 as its "have I fetched at least once" baseline. A session normally starts with zero artifacts, so knownIds stays empty through the baseline fetch, and the poll that discovers the session's first artifact was treated as still-initializing and swallowed. On main today that means the first artifact of every session never fires a toast; with the dock it also never opened a tab. Now an explicit initialized flag, set only on a successful fetch so a failed poll can't reset the baseline.

No test could have caught that. vitest.config.ts had no Svelte plugin, so $state was an undefined identifier at runtime and any test importing a .svelte.ts module died with $state is not defined — which is why the entire runes-based store layer had zero coverage. The plugin is now registered and ArtifactsStore has tests.

Verification

Typecheck 0 errors (997 files), lint clean, 65 files / 572 tests, pnpm build succeeds.

Driven end-to-end in a real browser against a live worker + session:

  • Terminal survives a tab switch — with Files active, the terminal and its xterm are still in the DOM at visibility: hidden. Hidden, not destroyed: the component instance lives, so the WebSocket lives.
  • Artifact tab spawns without stealing focus — pushed an artifact into a fresh session; the tab appeared and the active tab stayed Terminal.
  • Zero console errors, including no effect_update_depth_exceeded.

Not verified: touch drag on real hardware. The claim that long-press-drag works on a phone rests on reading dockview's source (it ships a pointer-events backend with a LongPressDetector, 500ms/8px, with context-menu and click guards) plus a headless check — not on a physical device. Emulated touch does not exercise the pointer backend faithfully. Worth one pass on a real phone before merge.

Follow-ups (pre-existing, not this PR)

  1. WORKERS_BRIDGE_SUBNET defaults to 172.30.0.0/16 for every instance, so a nested or side-by-side Nexus tries to create a bridge on top of its own gateway and hangs rather than erroring. Hit this while standing up a test instance.
  2. dockview logs a console warning that dockview-core is an internal package and recommends the dockview package instead. Everything works; worth a look.
Replaces `MainSplit.svelte`'s hand-rolled Files/Terminal/Artifacts tab strip with a [`dockview-core`](https://dockview.dev) dock. This is **PR-A of three**. B (make the workspace list a dock panel) and C (panels carry their own `{workerId, sessionId}`, so several sessions can be watched side by side) are deliberately out of scope and get their own specs. - Spec: `docs/superpowers/specs/2026-07-11-dockview-migration-pr-a-design.md` - Plan: `docs/superpowers/plans/2026-07-11-dockview-pr-a.md` ## Why 1. **The terminal died whenever you looked at a file.** `SessionTerminal` opens its WebSocket in `onMount` and closes it in `onDestroy`; because MainSplit rendered it inside an `{#if}` branch, switching to Files tore the socket down. tmux made that survivable, not pleasant. 2. **Panes couldn't be viewed together** — the three were mutually exclusive. 3. **There was no layout engine at all.** Every pane width in the codebase was a hardcoded px value; no splitter, no drag-resize. ## What's here - `dockview-core@7.0.2` — MIT, **zero runtime dependencies**. Verified by unpacking the published tarball, not by reading the docs (the docs site 404s on its options page). - `defaultRenderer: 'always'` keeps a hidden panel's DOM alive (`visibility: hidden`) instead of detaching it — this is what stops the terminal's socket being torn down. - `dndStrategy: 'auto'` — native HTML5 DnD for mouse, pointer-events + long-press for touch. dockview 7 ships both backends, so **drag and resize work on a phone**; there is no locked-mobile code path and no second rendering path. - A new artifact opens **its own tab, inactive** — an agent pushing an artifact must never yank the cursor out of a half-typed command. - New `src/lib/dock/`: panel-id scheme, layout persistence, the `DockDeps` contract, and a ~50-line `SvelteContentRenderer` bridging dockview's `IContentRenderer` onto Svelte 5's `mount()`/`unmount()`. ## Things worth a reviewer's attention **Live objects never reach dockview `params`.** `params` is JSON-serialized into localStorage by `toJSON()`. The `ArtifactsStore` (a `$state` proxy), the `FileExplorerProvider`, and the `openArtifact` callback therefore reach panels through the `createComponent` factory closure — `params` carries only `{ artifactId }`. **Artifact panels are stripped before the layout is saved.** They're scope-bound, so restoring `artifact:<id>` into a different session would resurrect an id that no longer exists server-side. Stripping at save time means whatever is in storage is always restorable and there is zero reconciliation logic. Cost: refreshing while reading an artifact closes that tab. Deliberate. **This collapses an existing wart.** There used to be *two* `ArtifactsStore` instances polling every 5s — one in `ArtifactsPanel`, plus a background one in MainSplit for toasts, torn down whenever the Artifacts tab opened so the two didn't double-poll. With no active-tab to gate on, there is now **one store per scope**, owned by `Dock.svelte`. **The mobile drawer toggles moved into the panels that own them** (`FileExplorerPanel`, `ArtifactsPanel`), because they used to switch on an `activeTab` that no longer exists. Better factoring anyway — MainSplit no longer knows a file tree exists. MainSplit lost 315 lines. ## Bugs found and fixed along the way **A Svelte reactivity loop in `Dock.svelte`** (4f2d555). The store effect both read `deps.store` (to stop the previous one) and wrote it. `deps` is a `$state` proxy, so the read registered `deps.store` as a dependency of the very effect that writes it — a self-triggering loop that would stop the store it had just created, build another, and spin until `effect_update_depth_exceeded`, leaking a 5s poller each round. The live store now lives in a plain untracked local. **The first artifact of a session was never announced** (39e5d53) — **pre-existing on `main`.** `ArtifactsStore.refresh()` used `knownIds.size > 0` as its "have I fetched at least once" baseline. A session normally starts with zero artifacts, so `knownIds` stays empty through the baseline fetch, and the poll that discovers the session's *first* artifact was treated as still-initializing and swallowed. On `main` today that means the first artifact of every session never fires a toast; with the dock it also never opened a tab. Now an explicit `initialized` flag, set only on a successful fetch so a failed poll can't reset the baseline. **No test could have caught that.** `vitest.config.ts` had no Svelte plugin, so `$state` was an undefined identifier at runtime and *any* test importing a `.svelte.ts` module died with `$state is not defined` — which is why the entire runes-based store layer had zero coverage. The plugin is now registered and `ArtifactsStore` has tests. ## Verification Typecheck 0 errors (997 files), lint clean, **65 files / 572 tests**, `pnpm build` succeeds. Driven end-to-end in a real browser against a live worker + session: - **Terminal survives a tab switch** — with Files active, the terminal and its xterm are still in the DOM at `visibility: hidden`. Hidden, not destroyed: the component instance lives, so the WebSocket lives. - **Artifact tab spawns without stealing focus** — pushed an artifact into a fresh session; the tab appeared and the active tab stayed `Terminal`. - **Zero console errors**, including no `effect_update_depth_exceeded`. **Not verified: touch drag on real hardware.** The claim that long-press-drag works on a phone rests on reading dockview's source (it ships a pointer-events backend with a `LongPressDetector`, 500ms/8px, with context-menu and click guards) plus a headless check — not on a physical device. Emulated touch does not exercise the pointer backend faithfully. Worth one pass on a real phone before merge. ## Follow-ups (pre-existing, not this PR) 1. `WORKERS_BRIDGE_SUBNET` defaults to `172.30.0.0/16` for every instance, so a nested or side-by-side Nexus tries to create a bridge on top of its own gateway and **hangs** rather than erroring. Hit this while standing up a test instance. 2. dockview logs a console warning that `dockview-core` is an internal package and recommends the `dockview` package instead. Everything works; worth a look.
lz added 15 commits 2026-07-12 12:25:47 +02:00
Design for replacing MainSplit's tab strip with a dockview-core dock:
persistent terminal (renderer: 'always'), drag-to-split panes, and a
non-focus-stealing tab per new artifact.

Probed dockview-core@7.0.2 directly rather than trusting docs: MIT,
zero deps, and it ships a pointer-events DnD backend so touch drag and
resize work — hence one dock on every viewport, no locked mobile path.

PR-B (dockable sidebar) and PR-C (per-panel scope) are deferred.
12 TDD tasks. Pure modules (panels.ts, layout.ts) are unit-tested; the
Svelte<->dockview renderer is not, because vitest runs environment:'node'
with no DOM — Task 12's smoke test covers it instead.

Records one deliberate deviation from the spec: the default layout is
built imperatively via addPanel rather than hand-authored as
SerializedDockview JSON.
MIT, zero runtime dependencies. Framework-agnostic core; the Svelte
binding is ours (see the forthcoming lib/dock/svelte-renderer).
The store effect read deps.store (to stop the previous one) and also
wrote it. deps is a $state proxy, so that read registered deps.store as
a dependency of the very effect that writes it — a self-triggering loop
that would stop the store it had just created, build another, and spin
until effect_update_depth_exceeded, leaking a 5s poller per round.

The live store now lives in a plain untracked local; deps.store is only
mirrored out for panels to read.
fix(artifacts): announce the first artifact of a session
All checks were successful
ci / nexus (pull_request) Successful in 4m17s
ci / images (pull_request) Successful in 17m21s
39e5d5389c
ArtifactsStore.refresh() used `knownIds.size > 0` as its "have I fetched at
least once" baseline. But a session normally starts with zero artifacts, so
knownIds stays empty through the baseline fetch — and the poll that discovers
the session's FIRST artifact was therefore treated as still-initializing, and
swallowed. The result: the first artifact of every session never fired a toast,
and (once the dock landed) never opened a tab. The common case, not an edge one.

Replaced with an explicit `initialized` flag, set only after a successful fetch
so a failed poll can't reset the baseline and re-announce every artifact.

This was pre-existing on main; it surfaced only when driving the real UI, since
no test could reach it: vitest had no svelte plugin, so `$state` was an
undefined identifier at runtime and any test importing a .svelte.ts module died
with "$state is not defined". That is why this entire runes-based store layer
had zero coverage. Registering the plugin in vitest.config.ts fixes that and
brings the layer under test.
lz added 2 commits 2026-07-12 15:02:24 +02:00
An arriving artifact used to be added as a tab inside the Artifacts group,
which meant it was invisible unless you happened to be looking there. It now
SPLITS the dock beside whatever you're currently looking at, so you see it
without going hunting — while still being added `inactive`, so it never steals
focus from a half-typed terminal command.

The rules live in a pure, tested `decideArtifactPlacement` (artifact-placement.ts):

  1. An artifact group already exists -> tab into it. This is what stops the
     dock turning into confetti: five artifacts do not make five splits. The
     layout stays at two panes however many arrive.
  2. Dock narrower than 768px -> no split, just a tab. A 390px phone split in
     two gives ~195px panes (~24 terminal columns). The guard is on available
     width, not on "is it a phone", so a narrow desktop window is covered too.
  3. Otherwise split the active group: wide dock (w/h >= 1.2) -> side by side,
     tall dock -> stacked.

Verified in a browser against a live artifact: the first artifact produced two
groups at 540x807 side by side with Terminal still the active tab; the second
tabbed into the artifact group, leaving the group count at two.
fix(dock): artifact tabs were unreadable on a phone (no comment drawer)
Some checks failed
ci / images (pull_request) Has been cancelled
ci / nexus (pull_request) Has been cancelled
pr-image-cleanup / delete-pr-images (pull_request) Successful in 7s
d9bc76d2fb
ArtifactViewer renders CommentSidebar as a fixed 280px column. With
`mobileDrawer` set it collapses into an off-canvas drawer at <=768px; without
it, the sidebar renders INLINE.

Of the three ArtifactViewer call sites, ArtifactsPanel and the pop-out route
both pass `mobileDrawer`. The dock's ArtifactPanel did not — so an
auto-spawned artifact tab squeezed its markdown into roughly 110px on a 390px
phone.

Adds `mobileDrawer` + a bound `commentsOpen`, plus the toggle button to open
the drawer. ArtifactsPanel and the pop-out each host that toggle in a toolbar;
this panel has no chrome of its own, so it floats over the viewer, mobile-only.

Found by tabulating what the three artifact views actually share: they all
render the same ArtifactViewer driven by the same ArtifactDetailController, and
differ only in the shell around them. This was the one inconsistency in that
shell that was a bug rather than a choice.
lz merged commit c7a6921dd3 into main 2026-07-13 21:09:38 +02:00
lz deleted branch feat/dockview-pr-a 2026-07-13 21:09:39 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!58
No description provided.