feat(dock): panels carry their own target — retire the single scope (PR-C2) #63

Merged
lz merged 22 commits from feat/dockview-pr-c2 into main 2026-07-13 21:11:08 +02:00
Owner

Targets feat/dockview-pr-c1 (#62), not main. Stack: main ← PR-A #58 ← PR-B #61 ← PR-C1 #62this. Final PR of the dockview arc.

Every dock panel now carries its own {workerId, sessionId}. Several sessions can be watched side by side, and an artifact landing in a session you're not looking at opens its own tab without stealing focus. That last sentence is what this whole three-PR arc was pointed at.

Spec: docs/superpowers/specs/2026-07-12-dockview-migration-pr-c-design.md §4
Plan: docs/superpowers/plans/2026-07-12-dockview-pr-c2.md

What changed

A panel is a (view, scope) pair — id terminal:w1:s2, files:w1:@workspace. ExplorerScope is all scalars, so the target rides in dockview's params and survives a reload for free: no side table, no id-to-state reconciliation. DockDeps shrinks from "the single-scope model in object form" to services only.

  • Services are per-target, derived from the panel list. One ArtifactsStore + one WorkerFsProvider per target that has an open panel — not one per panel, not one globally. Lifecycle is derived from dock.panels on every layout change rather than refcounted, because a counter can drift out of sync with the dock and a derivation cannot.
  • Dead panels are pruned; uncertainty prunes nothing. liveness() is three-valued. A session that is confirmed gone has its panels closed; a failed poll reports unknown and closes nothing. Same contract as convergeSessions, which never hard-deletes a session row when the worktree probe fails (AGENTS.md fact #15).
  • Tabs are [view icon] + session name, tinted per session. Two terminals were otherwise indistinguishable. Colour is derived from the target key, so it survives reloads and agrees between list and dock with nothing persisted. It is redundant encoding — the name is always there — so a colour-blind operator loses nothing.
  • Fill = status, ring = identity. The dots already used colour for status (running/stopped/unhealthy). Recolouring them by session identity would have destroyed that. So identity goes on a ring, which appears only when that target has panels open — and the ring's presence doubles as the "open" indicator the list needed anyway.
  • The right-pane toolbar is gone. The scope chip described a scope that no longer exists; "reset layout" restored a default layout that no longer exists. On desktop the dock reclaims the space; on mobile a back arrow remains.
  • @lucide/svelte (ISC, tree-shaken, Svelte 5 native). terminal / folders / gallery-vertical-end, icon-only, native title tooltips.

Buried

scope, scopeLabel, terminalScope, the whole scope↔URL layer, handleCardTap, PERMANENT_PANEL_IDS, Dock.resetLayout(), and the permanent-panel top-up loop. The 250ms terminal debounce also died — it existed only to stop one shared terminal thrashing as you clicked through sessions, and clicking a session now opens a panel rather than re-pointing one, so the thrash is structurally impossible.

Accepted loss: the URL no longer carries the session. With N panels open there is nothing sensible to encode, so localStorage layout is the sole state and a session is no longer deep-linkable.

Three bugs the test suite could not see

vitest runs environment: 'node' with no DOM, so .svelte components are structurally untestable. All three of these were design errors of mine, and all three were caught by driving the real UI:

  1. Panels froze on "Connecting…" forever. The service registry's Map is deliberately plain (making it reactive would invite the self-triggering-effect trap), but panels read it through $derived(services.providerFor(scope)) — an ordinary call into a plain Map, with no reactive dependency to invalidate it. It computed once, at mount, and froze. And mount is too early: dockview mounts panels synchronously inside addPanel/fromJSON, while onDidLayoutChange fires on a microtask. So opening Files as a session's first panel — or reloading with one saved — never resolved. Fixed with an epoch counter bumped only when the registry's contents actually change (bumping on every sync() would churn ArtifactPanel's controller on every drag frame).

  2. Tabs had no close button. Our custom SessionTab replaces dockview's default tab, which is what provided the close action. Combined with deleting "reset layout" on the grounds that panels are individually closable, the dock became a one-way door. Two reviewers missed this; only clicking around found it.

  3. Closing the last panel didn't stick. saveLayout bailed when nothing survived the artifact strip — harmless in PR-A, where three permanent panels made an empty dock impossible. C2 deleted permanent panels, so an empty dock is legitimate, and refusing to save it left the previous layout in storage, resurrecting on reload the very panels the operator had just closed.

Each fix is pinned by a mutation-verified test (revert the fix → exactly the intended test fails, for the right reason).

Verification

pnpm typecheck0 errors (4762 files). pnpm test643 passing (71 files), up from 617.

Browser pass on a seeded throwaway instance (dummy credentials; the operator's real nexus.db and claude session were never touched):

check result
Files as a session's first panel resolves
Two/three sessions docked at once distinct colours, independent panels
Ring vs fill ring matches tabs; status fill preserved
Reload panels restore, correctly targeted, all resolve
Artifact panels persisted? no — correctly stripped
Session removed its panels pruned
Server killed for 33s panels survive — uncertainty deletes nothing
Artifact → unfocused session tab spawns, splits into view, focus stays put, toast names the right session, tab inherits the session's colour
Close a tab list's ring + lit icon clear immediately
Close all → reload stays empty; watermark
Mobile 390×844 list ⇄ dock, panels intact behind the back arrow, no horizontal scroll
Console 0 errors

Still unverified, across the whole arc

Touch drag on real hardware. It rests on reading dockview's pointer backend (LongPressDetector, 500ms/8px) plus headless checks, not a physical device. Worth one pass on a real phone before any of A/B/C merges.

**Targets `feat/dockview-pr-c1` (#62), not `main`.** Stack: `main` ← PR-A #58 ← PR-B #61 ← PR-C1 #62 ← **this**. Final PR of the dockview arc. Every dock panel now carries its own `{workerId, sessionId}`. Several sessions can be watched side by side, and an artifact landing in a session you're *not* looking at opens its own tab without stealing focus. That last sentence is what this whole three-PR arc was pointed at. Spec: `docs/superpowers/specs/2026-07-12-dockview-migration-pr-c-design.md` §4 Plan: `docs/superpowers/plans/2026-07-12-dockview-pr-c2.md` ## What changed A panel is a `(view, scope)` pair — id `terminal:w1:s2`, `files:w1:@workspace`. `ExplorerScope` is all scalars, so **the target rides in dockview's `params` and survives a reload for free**: no side table, no id-to-state reconciliation. `DockDeps` shrinks from "the single-scope model in object form" to services only. - **Services are per-target, derived from the panel list.** One `ArtifactsStore` + one `WorkerFsProvider` per target that has an open panel — not one per panel, not one globally. Lifecycle is derived from `dock.panels` on every layout change rather than refcounted, because a counter can drift out of sync with the dock and a derivation cannot. - **Dead panels are pruned; uncertainty prunes nothing.** `liveness()` is three-valued. A session that is *confirmed gone* has its panels closed; a failed poll reports `unknown` and closes nothing. Same contract as `convergeSessions`, which never hard-deletes a session row when the worktree probe fails (AGENTS.md fact #15). - **Tabs are `[view icon] + session name`, tinted per session.** Two terminals were otherwise indistinguishable. Colour is derived from the target key, so it survives reloads and agrees between list and dock with nothing persisted. It is **redundant** encoding — the name is always there — so a colour-blind operator loses nothing. - **Fill = status, ring = identity.** The dots already used colour for status (running/stopped/unhealthy). Recolouring them by session identity would have destroyed that. So identity goes on a *ring*, which appears only when that target has panels open — and the ring's presence doubles as the "open" indicator the list needed anyway. - **The right-pane toolbar is gone.** The scope chip described a scope that no longer exists; "reset layout" restored a default layout that no longer exists. On desktop the dock reclaims the space; on mobile a back arrow remains. - **`@lucide/svelte`** (ISC, tree-shaken, Svelte 5 native). `terminal` / `folders` / `gallery-vertical-end`, icon-only, native `title` tooltips. ## Buried `scope`, `scopeLabel`, `terminalScope`, the whole scope↔URL layer, `handleCardTap`, `PERMANENT_PANEL_IDS`, `Dock.resetLayout()`, and the permanent-panel top-up loop. **The 250ms terminal debounce also died** — it existed only to stop one shared terminal thrashing as you clicked through sessions, and clicking a session now *opens a panel* rather than re-pointing one, so the thrash is structurally impossible. **Accepted loss:** the URL no longer carries the session. With N panels open there is nothing sensible to encode, so localStorage layout is the sole state and a session is no longer deep-linkable. ## Three bugs the test suite could not see `vitest` runs `environment: 'node'` with no DOM, so `.svelte` components are structurally untestable. All three of these were design errors of mine, and all three were caught by driving the real UI: 1. **Panels froze on "Connecting…" forever.** The service registry's `Map` is deliberately plain (making it reactive would invite the self-triggering-effect trap), but panels read it through `$derived(services.providerFor(scope))` — an ordinary call into a plain Map, with **no reactive dependency to invalidate it**. It computed once, at mount, and froze. And mount is too early: dockview mounts panels *synchronously* inside `addPanel`/`fromJSON`, while `onDidLayoutChange` fires on a microtask. So opening Files as a session's first panel — or reloading with one saved — never resolved. Fixed with an epoch counter bumped **only when the registry's contents actually change** (bumping on every `sync()` would churn `ArtifactPanel`'s controller on every drag frame). 2. **Tabs had no close button.** Our custom `SessionTab` replaces dockview's default tab, which is what provided the close action. Combined with deleting "reset layout" *on the grounds that panels are individually closable*, the dock became a **one-way door**. Two reviewers missed this; only clicking around found it. 3. **Closing the last panel didn't stick.** `saveLayout` bailed when nothing survived the artifact strip — harmless in PR-A, where three permanent panels made an empty dock impossible. C2 deleted permanent panels, so an empty dock is legitimate, and refusing to save it left the *previous* layout in storage, resurrecting on reload the very panels the operator had just closed. Each fix is pinned by a mutation-verified test (revert the fix → exactly the intended test fails, for the right reason). ## Verification `pnpm typecheck` → **0 errors** (4762 files). `pnpm test` → **643 passing** (71 files), up from 617. Browser pass on a seeded throwaway instance (dummy credentials; the operator's real `nexus.db` and claude session were never touched): | check | result | |---|---| | Files as a session's **first** panel | resolves | | Two/three sessions docked at once | distinct colours, independent panels | | Ring vs fill | ring matches tabs; status fill preserved | | Reload | panels restore, correctly targeted, all resolve | | Artifact panels persisted? | no — correctly stripped | | Session removed | its panels pruned | | **Server killed for 33s** | **panels survive** — uncertainty deletes nothing | | Artifact → **unfocused** session | tab spawns, splits into view, focus stays put, toast names the right session, tab inherits the session's colour | | Close a tab | list's ring + lit icon clear immediately | | Close all → reload | stays empty; watermark | | Mobile 390×844 | list ⇄ dock, panels intact behind the back arrow, no horizontal scroll | | Console | 0 errors | ## Still unverified, across the whole arc **Touch drag on real hardware.** It rests on reading dockview's pointer backend (`LongPressDetector`, 500ms/8px) plus headless checks, not a physical device. Worth one pass on a real phone before any of A/B/C merges.
lz added 22 commits 2026-07-13 06:32:59 +02:00
Three decisions the first pass left open:

- Tabs render as [view icon] + session name, tinted per session. Two terminals
  were otherwise indistinguishable. Verified cheap: dockview's ITabRenderer and
  IWatermarkRenderer share IContentRenderer's shape, so PR-A's Svelte adapter
  covers all three unchanged.
- Session identity goes on a RING around the status dot, not in its fill. The
  dots already encode status by colour; recolouring them by identity would have
  destroyed that signal. The ring's presence doubles as the 'has open panels'
  indicator the list needed anyway.
- The right-pane toolbar is deleted outright. The scope chip described a scope
  that no longer exists, and 'reset layout' would restore a default layout that
  no longer exists.
16 tasks, 72 steps. Tasks 1-6 are pure modules with real unit tests and land
first; 7-12 rewrite the dock; 13-14 the list UI; 15 buries the old model; 16 is
the browser pass, which is the only real test this PR gets.

Tasks 8-11 leave typecheck red on purpose — the window closes at Task 12. The
alternative is one enormous unreviewable commit.
Panel ids become terminal:w1:s2 / files:w1:@workspace. A bare 'terminal' id said
nothing about which session it belonged to — which is exactly why only one
session could ever be shown.
Derived from the target key, not stored — so it survives reloads and agrees
between the sidebar and the dock with no state anywhere. Redundant encoding:
the session name is always present, so colour is never the only signal.
Uncertainty never deletes — 'unknown' removes nothing. Same contract as
convergeSessions, which never hard-deletes a session row on a failed probe. A
boolean would make a flaky poll indistinguishable from a removed session and eat
the operator's layout.
Session B's artifacts must never tab into session A's artifact group. The
with-artifacts-panel fallback dies with the permanent Artifacts panel — a dock
too narrow to split now tabs into the active group.

NOTE: Dock.svelte still calls the old signature; it is rewritten in a later
commit on this branch.
A v1 blob has bare terminal/files ids and no params — under C2 it would restore
as permanently targetless panels. Discarded, not migrated: a v1 layout cannot
say which session it meant, which was the whole problem.
One ArtifactsStore + one WorkerFsProvider per target that has an open panel.
Lifecycle is derived from the panel list, not refcounted — a counter can drift
out of sync with the dock; a derivation cannot.

A session with any open panel polls its artifacts even when unfocused. That is
the feature, not an oversight: it is what makes an artifact landing in session B
spawn its tab while you are working in session A.
Coverage instrumentation showed the branch that re-keys a workspace target's
store when its shell session id changes never executed — every test set the
shell state before the first sync(). It is the branch that makes an already-open
Artifacts panel start working when the operator starts the workspace agent, and
it looked unreachable, which is how code gets deleted as dead.

Also restores a colon-in-artifact-id case dropped with panels.test.ts.
Left behind by vitest --coverage runs; one 'git add -A' from being committed.
ITabRenderer and IWatermarkRenderer share IContentRenderer's shape, so this is
the existing adapter pattern applied twice.
It used to hold the single scope, the single store, the single provider and
their labels — the single-scope model in object form. A panel now reads its own
target from params and looks its services up by that target.

NOTE: panels and Dock.svelte are updated in the next commits on this branch;
typecheck is red in between.
Each panel reconstructs its ExplorerScope from the flat scalars dockview handed
back, and looks its services up by that target. Artifact panels now carry their
scope too: the artifact id alone cannot say which worker/session to fetch from.

NOTE: Dock.svelte is rewritten in the next commit; typecheck is red in between.
Tabs are [view icon] + session name with the session's accent colour, so two
terminals are distinguishable and a session's tabs group at a glance. Colour is
redundant — the name is always there.

Adds @lucide/svelte (ISC, tree-shaken, Svelte 5 native).
Opens a panel per (view, session) instead of re-pointing three shared ones.
Services are derived from the panel list; dead panels are pruned on each poll
(and never on uncertainty). The permanent-panel top-up loop is gone: a closed
panel now stays closed, and an empty dock shows the watermark.

NOTE: MainSplit is rewritten in the next commit; typecheck is red in between.
No selected session any more — the operator opens panels and the dock owns which
are open. The scope↔URL layer and the right-pane toolbar are gone: the chip
described a scope that no longer exists, and 'reset layout' restored a default
layout that no longer exists. On desktop the dock reclaims that vertical space.
Icon-only (Lucide), native title tooltips, lit when that panel is open.
'Selected' is gone — several sessions can be open at once, so the list shows
WHICH targets have panels in the dock. The dot's FILL still means status; a RING
means it has panels open, coloured to match its tabs. Recolouring the fill by
identity would have destroyed the status signal.

The card-wide click is deleted, and with it handleCardTap — the guard that only
existed because setting the scope hid the Start/Stop button mid-tap on mobile.
Deletes panels.ts, the scope↔URL layer, and every selectedScope/onSelectScope
thread. ExplorerScope survives as the panel TARGET — all scalars, which is what
lets it ride in dockview params and persist for free.

The URL no longer carries the session. With N panels open there is nothing
sensible to encode; localStorage layout is the sole state. Accepted cost: a
session is no longer deep-linkable.
The registry's Map is deliberately plain, but panels read it through
$derived(services.providerFor(scope)) — an ordinary call into a plain Map,
with no reactive dependency to invalidate it. It computed once, at mount, and
froze. And mount is too early: dockview mounts panels synchronously inside
addPanel/fromJSON, while onDidLayoutChange fires on a microtask.

So opening Files as a session's first panel — or reloading with one saved —
sat on 'Connecting…' forever. An epoch counter bridges the plain Map to the
deriveds, bumped only when the contents actually change.
Our custom SessionTab replaces dockview's default tab, which is what provided
the close action — so no panel could be closed, ever. And the spec deleted the
'reset layout' button on the grounds that panels are individually closable,
which made the dock a one-way door.

The close callback rides in the tab's PROPS, not dockview's params: props are
never serialized, so a live callback there is safe.
fix(dock): persist an empty dock instead of silently skipping it
All checks were successful
ci / nexus (pull_request) Successful in 4m45s
ci / images (pull_request) Successful in 6m34s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 7s
914c501f7f
saveLayout bailed when nothing survived the artifact strip. That was harmless
in PR-A, where three permanent panels made an empty dock impossible. PR-C2
deleted permanent panels, so an empty dock is now legitimate — and refusing to
save it left the PREVIOUS layout in storage, resurrecting on reload the very
panels the operator had just closed.
lz changed target branch from feat/dockview-pr-c1 to main 2026-07-13 21:10:43 +02:00
lz merged commit f8263324d4 into main 2026-07-13 21:11:08 +02:00
lz deleted branch feat/dockview-pr-c2 2026-07-13 21:11:08 +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!63
No description provided.