feat(ui): collapsible workspace rail (PR-B) #61

Merged
lz merged 7 commits from feat/dockview-pr-b into main 2026-07-13 21:09:49 +02:00
Owner

Stacked on #58 — this PR targets feat/dockview-pr-a, not main. Review #58 first.

Collapses the 360px workspace sidebar to a 52px rail, so the dock gets the space back.

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

This is NOT "make the sidebar a dock panel"

PR-B was originally scoped that way. It isn't what this builds, and the change is deliberate.

The requirement was not closable, but collapsible to a tiny sidebar. That's a request for space back, not for dockability — nobody wanted to drag, float, or stack the workspace list. And dockview has no collapse-to-rail concept: a "collapsed" dock panel is a group shrunk to a sliver with its tab header still rendering. Building it as a panel would have meant fighting the grid to produce a worse sidebar, and it would have introduced a close-footgun (operator closes their only navigation) that a rail avoids by construction.

So the list stays a first-class left column, outside the dock. Dockview keeps the content panes, where it earns its keep. PR-B got substantially smaller as a result.

What's here

  • Two states, one persisted flag. Expanded (today's list) or a 52px rail. « / » toggle. No drag-resize — it would put a second resize idiom on a page that already has dockview's sashes.
  • The rail shows both levels. A status-coloured 2-letter chip per workspace (nexus-mainNM), with its sessions as dots beneath. Clicking a dot selects that session and does not expand the rail. Hovering any chip or dot reveals its name.
  • workspace_shell rows get no dot — the workspace shell is the workspace scope, which the chip already represents.
  • No rail on mobile. Below 768px the toggle isn't rendered and the persisted flag is ignored, so a phone can never restore into a rail it has no affordance to leave. Today's behaviour (full-width list, dock slides over on select) is untouched.
  • Selecting a session does not change the active dock tab. That's today's behaviour, so it's zero new code — and PR-C makes several sessions' tabs coexist, at which point any "jump to Terminal" logic would be dead on arrival.

The one genuinely fiddly part

The rail scrolls, and overflow-y: auto forces overflow-x to compute to auto too — so anything extending past its 52px edge is silently clipped, an ::after pseudo-element as readily as a child div. A tooltip anchored to a dot inside a 52px rail necessarily extends past that edge.

The tooltip is therefore portalled to <body> at position: fixed, positioned from getBoundingClientRect() and dismissed on scroll and resize. It stays cheap only because it is non-interactive (pointer-events: none): no dismissal policy, no keyboard trap, no pointer-travel grace period.

This is exactly why the hover-flyout alternative was rejected: it needs the identical portal plus all of that machinery — and it would have hidden the sessions that were meant to be visible while collapsed.

Discovered by building a prototype, not by reasoning. The first version's tooltips were simply invisible.

Bug found while verifying — pre-existing on main

setScope was losing the scope on every reload. It built the href as:

u.search = scopeToSearch(next).toString();
goto(u.pathname + (u.search ? `?${u.search}` : ''))

URLSearchParams.toString() returns no leading ?, but URL.search's getter adds one — so the result was /??scope=session&worker=…. URLSearchParams then parsed the first key as '?scope', scopeFromSearch's get('scope') returned null, and the selection was silently dropped on every refresh.

Verified in a browser before and after: selecting a session then refreshing used to drop you back to "No workspace selected"; it now restores the scope. The identical line is on main, unchanged by PR-A — this has been true the whole time.

Replaced with a pure scopeToHref(pathname, scope) so it can't recur, with a regression test asserting exactly one ? and a round-trip back through scopeFromSearch.

Verification

Typecheck 0 errors (1004 files), lint clean, 68 files / 597 tests, pnpm build succeeds.

Driven end-to-end in a real browser against a seeded instance (4 workspaces, 6 sessions + 1 workspace_shell):

  • Rail renders at 52px with chips NM / TR / UL / 2S and 4 dots — the workspace_shell row correctly produced none.
  • Hover tooltip: on <body>, 126×21, pointer-events: none, and extends past the rail's right edge — i.e. it genuinely escapes the clipping.
  • Clicking a dot changes the scope with the correct basePath and leaves the rail at 52px (does not expand).
  • Collapse survives a reload; scope now survives a reload too.
  • Mobile gate: at 390×844 with collapsed=true still in localStoragerailPresent: false, toggleVisible: false, full list renders. A phone cannot land in a rail.

Deliberately not done

Dock.svelte is untouched. Its permanent-panel top-up still re-adds a closed Files/Artifacts panel on reload, so "close" doesn't stick. PR-C replaces fixed permanent panels with per-session panels and rewrites that logic entirely — fixing it here would be churn on code C deletes. The footgun that made it urgent under the original framing (operator closes their only navigation) does not exist here, because the workspace list is not a dock panel and cannot be closed at all.

**Stacked on #58 — this PR targets `feat/dockview-pr-a`, not `main`.** Review #58 first. Collapses the 360px workspace sidebar to a 52px rail, so the dock gets the space back. - Spec: `docs/superpowers/specs/2026-07-12-dockview-migration-pr-b-design.md` - Plan: `docs/superpowers/plans/2026-07-12-dockview-pr-b.md` ## This is NOT "make the sidebar a dock panel" PR-B was originally scoped that way. It isn't what this builds, and the change is deliberate. The requirement was *not closable, but collapsible to a tiny sidebar*. That's a request for **space back**, not for dockability — nobody wanted to drag, float, or stack the workspace list. And dockview has **no collapse-to-rail concept**: a "collapsed" dock panel is a group shrunk to a sliver with its tab header still rendering. Building it as a panel would have meant fighting the grid to produce a worse sidebar, and it would have introduced a close-footgun (operator closes their only navigation) that a rail avoids *by construction*. So the list stays a first-class left column, outside the dock. Dockview keeps the content panes, where it earns its keep. PR-B got substantially smaller as a result. ## What's here - **Two states, one persisted flag.** Expanded (today's list) or a 52px rail. `«` / `»` toggle. No drag-resize — it would put a second resize idiom on a page that already has dockview's sashes. - **The rail shows both levels.** A status-coloured 2-letter chip per workspace (`nexus-main` → `NM`), with its sessions as dots beneath. Clicking a dot selects that session and **does not expand** the rail. Hovering any chip or dot reveals its name. - **`workspace_shell` rows get no dot** — the workspace shell *is* the workspace scope, which the chip already represents. - **No rail on mobile.** Below 768px the toggle isn't rendered and the persisted flag is **ignored**, so a phone can never restore into a rail it has no affordance to leave. Today's behaviour (full-width list, dock slides over on select) is untouched. - **Selecting a session does not change the active dock tab.** That's today's behaviour, so it's zero new code — and PR-C makes several sessions' tabs coexist, at which point any "jump to Terminal" logic would be dead on arrival. ## The one genuinely fiddly part The rail scrolls, and **`overflow-y: auto` forces `overflow-x` to compute to `auto` too** — so *anything* extending past its 52px edge is silently clipped, an `::after` pseudo-element as readily as a child div. A tooltip anchored to a dot inside a 52px rail necessarily extends past that edge. The tooltip is therefore **portalled to `<body>` at `position: fixed`**, positioned from `getBoundingClientRect()` and dismissed on scroll and resize. It stays cheap only because it is **non-interactive** (`pointer-events: none`): no dismissal policy, no keyboard trap, no pointer-travel grace period. This is exactly why the hover-*flyout* alternative was rejected: it needs the identical portal **plus** all of that machinery — and it would have *hidden* the sessions that were meant to be visible while collapsed. Discovered by building a prototype, not by reasoning. The first version's tooltips were simply invisible. ## Bug found while verifying — pre-existing on `main` **`setScope` was losing the scope on every reload.** It built the href as: ```js u.search = scopeToSearch(next).toString(); goto(u.pathname + (u.search ? `?${u.search}` : '')) ``` `URLSearchParams.toString()` returns **no** leading `?`, but `URL.search`'s **getter** adds one — so the result was `/??scope=session&worker=…`. `URLSearchParams` then parsed the first key as `'?scope'`, `scopeFromSearch`'s `get('scope')` returned `null`, and **the selection was silently dropped on every refresh.** Verified in a browser before and after: selecting a session then refreshing used to drop you back to "No workspace selected"; it now restores the scope. The identical line is on `main`, unchanged by PR-A — this has been true the whole time. Replaced with a pure `scopeToHref(pathname, scope)` so it can't recur, with a regression test asserting exactly one `?` and a round-trip back through `scopeFromSearch`. ## Verification Typecheck 0 errors (1004 files), lint clean, **68 files / 597 tests**, `pnpm build` succeeds. Driven end-to-end in a real browser against a seeded instance (4 workspaces, 6 sessions + 1 workspace_shell): - Rail renders at 52px with chips `NM` / `TR` / `UL` / `2S` and **4 dots — the `workspace_shell` row correctly produced none**. - Hover tooltip: on `<body>`, 126×21, `pointer-events: none`, and **extends past the rail's right edge** — i.e. it genuinely escapes the clipping. - Clicking a dot changes the scope with the correct `basePath` and leaves the rail at **52px** (does not expand). - Collapse survives a reload; scope now survives a reload too. - **Mobile gate:** at 390×844 with `collapsed=true` still in `localStorage` → `railPresent: false`, `toggleVisible: false`, full list renders. A phone cannot land in a rail. ## Deliberately not done `Dock.svelte` is untouched. Its permanent-panel top-up still re-adds a closed Files/Artifacts panel on reload, so "close" doesn't stick. PR-C replaces fixed permanent panels with per-session panels and rewrites that logic entirely — fixing it here would be churn on code C deletes. The footgun that made it urgent under the original framing (*operator closes their only navigation*) **does not exist here**, because the workspace list is not a dock panel and cannot be closed at all.
lz added 7 commits 2026-07-12 15:06:18 +02:00
Reframed from the original 'make the sidebar a dock panel'. The operator's
constraint (not closable, collapsible to a tiny sidebar) is a request for
space back, not for dockability — and dockview has no collapse-to-rail
concept, so building it as a panel would mean fighting the grid for a worse
sidebar. The list stays outside the dock as a collapsible rail.

Rail shows a status chip per workspace with session dots beneath; clicking a
dot selects without expanding; hover reveals names via a tooltip portalled to
<body> (the rail scrolls, and overflow-y:auto forces overflow-x:auto, so
anything past its edge is clipped — found by building the prototype).

No rail on mobile. Dock.svelte untouched: PR-C rewrites its permanent-panel
handling anyway.
5 tasks. Two pure modules (sidebar-state, rail initials) are TDD'd; the rail
component is verified in a real browser instead, since vitest runs node with
no DOM.

Task 5 asserts the two things typecheck cannot: that the hover tooltip is
portalled to <body> and NOT clipped by the rail's scroll container, and that
a persisted collapsed=true flag does not produce a rail on a phone.
fix(main-page): stop losing the scope on reload (double '?' in the URL)
All checks were successful
ci / nexus (pull_request) Successful in 5m34s
ci / images (pull_request) Successful in 11m53s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 6s
3b9a5c0ae3
setScope built the href as:

  u.search = scopeToSearch(next).toString();
  goto(u.pathname + (u.search ? `?${u.search}` : ''))

URLSearchParams.toString() returns NO leading '?', but URL.search's GETTER
adds one — so the result was `/??scope=session&worker=…`. URLSearchParams
then parsed the first key as '?scope', so scopeFromSearch's get('scope')
returned null and the selection was silently lost on every reload.

Verified in a browser before and after: selecting a session then refreshing
used to drop you back to "No workspace selected"; it now restores the scope.

Pre-existing on main — the identical line is there, unchanged by PR-A. Found
by driving the real UI while verifying the rail, not by any test.

Replaced with a pure `scopeToHref(pathname, scope)` in scope.ts so the
mistake can't recur, with a regression test asserting exactly one '?' and a
round-trip back through scopeFromSearch.
lz changed target branch from feat/dockview-pr-a to main 2026-07-13 21:09:38 +02:00
lz merged commit 9e0e1e8603 into main 2026-07-13 21:09:49 +02:00
lz deleted branch feat/dockview-pr-b 2026-07-13 21:09:50 +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!61
No description provided.