feat(dashboard): rebuild tabs + split-pane layout on dockview, default on - #263
Merged
Conversation
…t on Replace the hand-rolled binary-tree layout, the detached-terminal overlay (SessionMountLayer), and the hidden `groups` workspace-swap with a dockview-react dock that owns the pane topology (ADR-047). `renderer:'always'` keeps every xterm mounted across tab switches, retiring the fragile manual rect-flying overlay. UX model: click = navigate (opens a pane solo in its own group), drag = compose (builds a tab/split and binds it as a persisted workspace; clicking any member restores the group; cold reload restores what you were last viewing). Drag works in both flat and hierarchical sidebar views. Chrome polish: tab titles aligned to the sidebar size, faint active-tab outline, edge-to-edge terminals, overflow dropdown hidden, and no phantom split overlay when dragging the highlighted tab. Fixes two terminal bugs surfaced by the migration: - Sidebar active-highlight flicker while moving/splitting tabs — the dockview->store sync now gates the transient mid-drag activations and applies only the final one. - Idle terminal self-shrink — hidden panels now get a real display:none, restoring the visibility guard that disposes their WebGL context and skips fitting (prevents the GPU context-loss storm + mis-measured refits). Hardened: never send degenerate resizes to the PTY, re-fit after a WebGL context recreate. Default flips to dockview; legacy engine stays available behind the `layoutEngine` flag. Adds store unit tests for the dockview switchPane path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGMW4cbhv5Le8wka7Th8bS
nox-0x
reviewed
Jun 28, 2026
nox-0x
reviewed
Jun 28, 2026
nox-0x
approved these changes
Jun 28, 2026
nox-0x
left a comment
Collaborator
There was a problem hiding this comment.
Approving — the dockview rewrite is solid: renderer: 'always' keep-alive correctly replaces the detached overlay, the writeback guards (suppressWriteback, internalDragActive, appliedActiveId) cleanly cut the activation-loop classes that would otherwise plague this kind of bridge, the legacy engine stays intact behind the flag, and the two terminal bugs (highlight flicker, idle self-shrink) have well-reasoned fixes with degenerate-resize and post-WebGL-recreate re-fit backstops. Store unit tests pin both engines on their own terms.
Two non-blocking follow-ups posted inline:
- Stale
TEMP(preview-only)comment atstore.ts:799tells the reader to revert the default tolegacy, which contradicts the PR's stated intent ("default on") — and ADR-047 itself still phases the flip into PR-5 with defaultlegacy. Reconcile the comment + ADR. pruneDeaddoesn't garbage-collectdvWorkspaces— dead session ids stick in the bindings and the serialized blob, so restoring an old workspace can resurrect a panel for a killed agent that won't get re-pruned until the next sessions update. UX bug, not a crash; can land as a follow-up.
Harden the dockview/persistence boundary surfaced by review (blast radius is now default-on): - Guard api.fromJSON() in syncToActive: a corrupt / version-incompatible persisted workspace previously threw on every render and blanked the dashboard (no error boundary), re-throwing each reload. Now it's caught — drop the poison workspace and fall back to the pane solo. - Validate persisted dvWorkspaces/dvPaneWorkspace shape in merge(): drop entries whose paneIds isn't a string[], and reverse-map entries pointing at dropped workspaces. Untrusted localStorage no longer flows unvalidated into fromJSON. - Strip dead-session panels a stale workspace re-adds on restore (ghost-tab fix), guarded on sessionsInitialFetchDone so cold load doesn't nuke valid panes. - newWorkspaceId(): crypto.randomUUID needs a secure context — fall back to a manual id so a plain-HTTP remote deploy doesn't throw on the first compose. Cleanups: - Extract pure paneFromId/SINGLETON_* into layout/dockview/paneId.ts and isDegenerate into terminal/resize.ts; paneFromId now uses a `satisfies`-checked SINGLETON_PANES const (no double-cast, SINGLETON_TYPES derived). Adds unit tests for both pure helpers (dockview-helpers.test.ts). - Remove the stale TEMP "revert to legacy" comment + fix the LayoutEngine doc to reflect dockview as the intentional default; append-only ADR-047 update records the early default-flip decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGMW4cbhv5Le8wka7Th8bS
aterrylu
enabled auto-merge (squash)
June 28, 2026 10:40
aterrylu
added a commit
that referenced
this pull request
Jun 28, 2026
…266) dockview shipped as the default in #263; this deletes the legacy binary-tree layout, the detached-terminal overlay (SessionMountLayer), the `groups` system, the `layoutEngine` flag, and react-resizable-panels (ADR-047 "delete legacy"). Removed: layoutTree(.ts/.test), SplitLayout, PaneSlot, SessionMountLayer, LayoutContext, DropZoneOverlay, TabBar (8 files); the binary-tree/group slices of the store (layout/focusedLeafId/groups/activeGroupId + 11 split-pane actions) and their persist entries; the dual-engine branches (switchPane/open* collapse to dockview, fetchSessions legacy prune dropped); the legacy keyboard shortcuts (Ctrl+D/Ctrl+Shift+D/Ctrl+W — dockview-native keybinds are a follow-up); the DragProvider/useDragContext React context (kept DRAG_TYPE/encode/decodeDragData). Re-sourced the sidebar's on-screen-agent indicator from dockview: DockviewLayout publishes its visible panel ids to a new `visiblePaneIds` store field (via onDidLayoutChange) that the sidebar reads, replacing allTabPanes(layout). Net ~3.5k LOC removed. dockview is now the only layout engine. Claude-Session: https://claude.ai/code/session_01MGMW4cbhv5Le8wka7Th8bS Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aterrylu
added a commit
that referenced
this pull request
Jul 26, 2026
Ctrl/Cmd+clicking a `.md` path in a terminal no longer opens a preview. The feature shipped in #30 (ADR-018) and silently regressed twice: - #263 made dockview the default layout engine. Every other `open*` store action got an explicit dockview branch; `openPreview` was the only one that did not, so it set `activePane` alone -> `syncToActive` found no bound workspace for the fresh preview id -> `showSolo()` removed every panel. In practice ctrl+clicking a `.md` in an agent's terminal destroyed the terminal pane, reversing #103's "opens as a new tab". - #249 defaulted the sidebar to hierarchy view, which builds no preview rows, leaving an open preview unreachable from the sidebar entirely. Both landed green: the feature had zero test coverage. Removes PreviewPane, PreviewPage, the /preview dispatch in main.tsx, MarkdownLinkProvider, the "preview" ActivePane member with previewPanes state and openPreview/closePreview, the .prose-custom stylesheet, three codicons orphaned by the deleted toolbar, and the server's GET /api/files/read + WS /ws/files/watch (Terry approved deleting the file API; it had acquired no other consumer in four months). Drops react-markdown, remark-gfm, mermaid, dompurify and @types/dompurify -- and with them the dashboard's only two dangerouslySetInnerHTML call sites. Backward compat, no migration hook needed: `isValidActivePane` now rejects {type:"preview"} so a stale persisted pane degrades to the empty state instead of restoring into a silently blank pane (PaneContent has no "preview" case); `previewPanes` leaves localStorage on the first persisted write via partialize; leftover `preview:*` order keys are swept by the existing fetchSessions prune once its escape hatch is removed. A regression test locks in the validator rejection. Preserved: UrlLinkProvider, the OSC 8 linkHandler, deduplicatedOpen, the shared ILink/ILinkProvider types, and the `.overflow-y-auto` half of the scrollbar rules `.prose-custom` shared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJXJ8wja3pEWSGr5DeEXw
aterrylu
added a commit
that referenced
this pull request
Jul 26, 2026
#289) * refactor(dashboard): remove the broken markdown file preview (ADR-059) Ctrl/Cmd+clicking a `.md` path in a terminal no longer opens a preview. The feature shipped in #30 (ADR-018) and silently regressed twice: - #263 made dockview the default layout engine. Every other `open*` store action got an explicit dockview branch; `openPreview` was the only one that did not, so it set `activePane` alone -> `syncToActive` found no bound workspace for the fresh preview id -> `showSolo()` removed every panel. In practice ctrl+clicking a `.md` in an agent's terminal destroyed the terminal pane, reversing #103's "opens as a new tab". - #249 defaulted the sidebar to hierarchy view, which builds no preview rows, leaving an open preview unreachable from the sidebar entirely. Both landed green: the feature had zero test coverage. Removes PreviewPane, PreviewPage, the /preview dispatch in main.tsx, MarkdownLinkProvider, the "preview" ActivePane member with previewPanes state and openPreview/closePreview, the .prose-custom stylesheet, three codicons orphaned by the deleted toolbar, and the server's GET /api/files/read + WS /ws/files/watch (Terry approved deleting the file API; it had acquired no other consumer in four months). Drops react-markdown, remark-gfm, mermaid, dompurify and @types/dompurify -- and with them the dashboard's only two dangerouslySetInnerHTML call sites. Backward compat, no migration hook needed: `isValidActivePane` now rejects {type:"preview"} so a stale persisted pane degrades to the empty state instead of restoring into a silently blank pane (PaneContent has no "preview" case); `previewPanes` leaves localStorage on the first persisted write via partialize; leftover `preview:*` order keys are swept by the existing fetchSessions prune once its escape hatch is removed. A regression test locks in the validator rejection. Preserved: UrlLinkProvider, the OSC 8 linkHandler, deduplicatedOpen, the shared ILink/ILinkProvider types, and the `.overflow-y-auto` half of the scrollbar rules `.prose-custom` shared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJXJ8wja3pEWSGr5DeEXw * fix(dashboard): close retired pane types from stale saved layouts Polish pass on the preview removal. The silent-failure review found the backward-compat guard was correct but bypassable: a pane descriptor has TWO persisted carriers and only one was guarded. `isValidActivePane` covers `activePane`. But dockview's `toJSON()` also serializes every panel's `params`, so `params.pane = {type:"preview"}` survives inside `dvWorkspaces[*].serialized` and is re-created verbatim by `fromJSON` on restore -- and `merge` validates only that `serialized` is a non-null object. That rendered an empty pane under a tab titled "Tab", with no console output. The dead-panel strip does not rescue it: it is gated on `sessionsInitialFetchDone`, which never flips while `/api/agents` is failing, so the blank tab was permanent for that session. TypeScript cannot see the branch at all -- "preview" was deleted from the union, so it looks unreachable while staying live at runtime against untyped persisted JSON. Guard at the narrowest shared boundary rather than per-path: every panel path (fromJSON, showSolo, addPanel, drop) converges on PaneContent, so an unrenderable pane type is closed there with a warning naming the panel and type. StatusTab now reports `Unknown (<type>)` instead of "Tab" so the transient state is diagnosable. Closes a related bypass: `paneFromId` classifies every non-singleton id as a session, so activating such a panel wrote back `{type:"session", id:"preview-..."}` -- which then PASSES `isValidActivePane` on the next reload, laundering a retired pane into a terminal dialing /ws/terminal for a session that never existed. New `paneFromPanel` trusts a panel's own descriptor and skips the writeback when it is invalid. Verified against a real dockview blob: the retired panel is closed with its warning, no blank tab, no page errors. Adds PaneContent.dom.test.tsx and paneFromPanel coverage (265 dashboard tests, was 257). Also from the review pass: - Drop the `SidebarItem`/`DisplayItem` wrappers rather than leave single-member unions: nothing reads the discriminant now, and `sidebarItemKey` was an exact duplicate of `sessionOrderKey`. - ADR-018's superseded note moves to the file's established format (trailing `**Update (date, ADR-XXX):**`, not a leading blockquote). - Correct ADR-059: `reconcileDeadWorkspaces` prunes `paneIds`, NOT `serialized`; and the order-key prune is opportunistic, not a guaranteed migration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJXJ8wja3pEWSGr5DeEXw * fix(dashboard): bail from render before reading an absent pane descriptor Addresses nox-0x's review on #289. The retired-pane-type guard defended against a nullish descriptor in its effect (`(pane ?? {})`), but the render body read `pane.type` in the `inner` IIFE -- and render runs BEFORE effects. So a persisted panel with no `params.pane` at all (the same untrusted `dvWorkspaces[*].serialized` carrier the guard exists for) threw a TypeError into the ErrorBoundary instead of closing the panel with its diagnostic warning. The guard did not cover what its own code already assumed. PaneContent now returns null before the switch when the descriptor isn't renderable -- placed after every hook so hook order stays stable, and covering every downstream `pane` read rather than just the switch. StatusTab had the same shape across six reads (`switch (pane.type)`, `pane.id.slice`, the status ternary, and three in handleClose). All are nullish-safe now. The raw discriminant is hoisted into `declaredType` because TS narrows `pane` to `never` inside the exhausted `default`, so it cannot be inspected there. Tests: adds the absent-descriptor case nox asked for, plus a non-object one. Verified the absent-descriptor test FAILS with the guard reverted, so it is a real regression test rather than a passing assertion. 267 dashboard tests (was 265). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FcJXJ8wja3pEWSGr5DeEXw --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rebuilds the terminal tabs + split-pane layout on dockview and makes it the default. This retires three hand-rolled subsystems — the binary-tree layout (
layoutTree.ts), the detached-terminal overlay (SessionMountLayer), and the hiddengroupsworkspace-swap — in favour of a dockview-react dock that owns the pane topology. Implements ADR-047.The layout system has been a long-standing source of fragility (manual
getBoundingClientRect+ResizeObserver+ rAF geometry sync, an invisible workspace-swap on sidebar click). This is the rip-and-replace Terry called for.Problem
graph TD subgraph Before["Before — hand-rolled (fragile)"] A1["Zustand binary-tree layout<br/>(source of truth)"] --> A2["PaneSlot rects<br/>(getBoundingClientRect)"] A2 -->|"ResizeObserver + rAF + setTick"| A3["SessionMountLayer<br/>detached overlay"] A3 -->|"fly position:absolute divs<br/>over slot rects"| A4["xterm terminals"] end subgraph After["After — dockview (single source of truth)"] B1["Zustand: activePane + bound workspaces"] -->|"navigation only"| B2["DockviewLayout"] B2 -->|"owns topology"| B3["dockview dock<br/>renderer: 'always'"] B3 -->|"keeps xterm mounted (keep-alive)"| B4["xterm terminals"] endgroupsworkspace-swap: clicking a sidebar agent in another group silently swapped your whole visible layout, with near-zero affordance.Solution
dockview's
renderer: 'always'keeps every terminal's xterm DOM mounted across tab switches — eliminating the manual rect-flying overlay, the single most fragile piece. DockviewLayout owns the pane topology; the store keeps onlyactivePane+ the bound-workspace maps.Interaction model
sequenceDiagram participant U as User participant DV as DockviewLayout participant Z as Zustand (persisted) U->>DV: drag Agent B onto Agent A's pane DV->>DV: addPanel (compose tab/split) DV->>Z: bind workspace {paneIds:[A,B], serialized: toJSON()} U->>DV: click Agent C (non-member) DV->>DV: show C solo U->>DV: click Agent A (member) DV->>Z: lookup A's workspace DV->>DV: fromJSON(serialized) → restore [A,B]Chrome polish: tab titles aligned to the sidebar size; faint active-tab outline (matches the app hairline divider); edge-to-edge terminals; dockview's redundant per-group tab-overflow dropdown hidden; and no phantom split overlay when dragging the highlighted tab (you can't split a pane against itself — handled via
onWillShowOverlay).Two terminal bugs fixed (both regressions the migration surfaced)
Both are the same species — the retired abstraction left a load-bearing assumption behind:
onDidActivePanelChangeevents as it rebuilds groups mid-drag; each was mirrored intoactivePane, walking the highlight between agents. Fix: gate the writeback during an internal drag (onWillDragPanel/onWillDragGroup) and apply only the final active panel ondragend.useTerminalinferred "is this pane visible?" fromcontainer.offsetWidth > 0— true under the old overlay (hidden = 0×0) but false under dockview, which hides panels withvisibility:hiddenat full size. The guard never tripped → every terminal held a WebGL context → context-loss storm on idle → the addon recreated without re-fitting → an unguardedfloor()-basedfit()baked the drift into a shrink. Fix: forward dockview's authoritativeprops.api.isVisibleso hidden panels get a realdisplay:none(re-arms the guard for free). Hardened: never send degenerate (cols≤2/rows≤1) resizes to the PTY; re-fit on a settled frame after a WebGL context recreate; skip redundant refits.Testing
make checkgreen: biome + tsc + 613 server tests + 327 dashboard tests (added store unit tests for the dockviewswitchPanepath).display:none(keep-alive preserved)Risks & rollout
layoutEngineflag ('legacy' | 'dockview') as a fallback; the legacy code is untouched, not deleted.Alternatives considered
flexlayout-react (runner-up — keep-alive default, lighter, but less polished) and keeping
react-resizable-panels(splits-only, doesn't replace the overlay). Full shortlist + rationale in ADR-047 /docs/RESEARCH.🤖 Generated with Claude Code
https://claude.ai/code/session_01MGMW4cbhv5Le8wka7Th8bS