Skip to content

feat(dashboard): rebuild tabs + split-pane layout on dockview, default on - #263

Merged
aterrylu merged 2 commits into
mainfrom
terry/layout-dockview-phase1
Jun 28, 2026
Merged

feat(dashboard): rebuild tabs + split-pane layout on dockview, default on#263
aterrylu merged 2 commits into
mainfrom
terry/layout-dockview-phase1

Conversation

@aterrylu

Copy link
Copy Markdown
Owner

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 hidden groups workspace-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"]
  end
Loading
  • Detached-overlay geometry desynced on resize-during-load / sidebar toggle / fast drag — no single geometry source of truth.
  • Hidden groups workspace-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 only activePane + the bound-workspace maps.

Interaction model

  • Click = navigate. Clicking a sidebar agent (or Org Chart / Templates / Schedules / New Agent) opens it solo in its own group.
  • Drag = compose. Dragging into the pane area builds a tab or split, and that arrangement is saved as a bound workspace. Clicking any member later restores the whole group; it persists across reloads, and a cold reload restores exactly what you were last viewing. Works from both flat and hierarchical sidebar views.
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]
Loading

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:

  1. Sidebar active-highlight flicker while moving/splitting tabs. dockview fires many transient onDidActivePanelChange events as it rebuilds groups mid-drag; each was mirrored into activePane, walking the highlight between agents. Fix: gate the writeback during an internal drag (onWillDragPanel/onWillDragGroup) and apply only the final active panel on dragend.
  2. Terminal idle self-shrink. useTerminal inferred "is this pane visible?" from container.offsetWidth > 0 — true under the old overlay (hidden = 0×0) but false under dockview, which hides panels with visibility:hidden at full size. The guard never tripped → every terminal held a WebGL context → context-loss storm on idle → the addon recreated without re-fitting → an unguarded floor()-based fit() baked the drift into a shrink. Fix: forward dockview's authoritative props.api.isVisible so hidden panels get a real display: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 check green: biome + tsc + 613 server tests + 327 dashboard tests (added store unit tests for the dockview switchPane path).
  • Headless Playwright against the live dev server with real agents, verifying:
Scenario Result
Click = solo (1 tab)
Drag = compose (1→2 tabs) + bind workspace
Click away → click back = restore group
Cold reload restores last-viewed arrangement
Hidden tab gets real display:none (keep-alive preserved)
Drag highlighted tab → no phantom split overlay; other agent → overlay shows
Drag works in both flat + hierarchical sidebar views

Risks & rollout

  • Default flips to dockview. The legacy engine remains behind the layoutEngine flag ('legacy' | 'dockview') as a fallback; the legacy code is untouched, not deleted.
  • Reload-on-restore tradeoff: restoring a bound workspace re-mounts its panels (terminals reattach to their PTY — no scrollback loss beyond a reattach). Accepted by Terry as the keep-alive tradeoff for workspace switching.
  • Follow-ups (not blocking): (1) a Settings UI toggle to switch engines without editing persisted state — currently only the flag exists; (2) internal-tab-move workspace re-serialization is in place, but deleting the legacy tree/overlay code is a later phase per the ADR.

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

…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
Comment thread packages/dashboard/src/store.ts
Comment thread packages/dashboard/src/layout/dockview/DockviewLayout.tsx

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 at store.ts:799 tells the reader to revert the default to legacy, which contradicts the PR's stated intent ("default on") — and ADR-047 itself still phases the flip into PR-5 with default legacy. Reconcile the comment + ADR.
  • pruneDead doesn't garbage-collect dvWorkspaces — 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
aterrylu enabled auto-merge (squash) June 28, 2026 10:40
@aterrylu
aterrylu merged commit 0998a6d into main Jun 28, 2026
5 checks passed
@aterrylu
aterrylu deleted the terry/layout-dockview-phase1 branch June 28, 2026 10:52
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>
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.

2 participants