Skip to content

Dock: canonical navigator across desktop & mobile - #909

Merged
srid merged 33 commits into
masterfrom
thin-sash
May 15, 2026
Merged

Dock: canonical navigator across desktop & mobile#909
srid merged 33 commits into
masterfrom
thin-sash

Conversation

@srid

@srid srid commented May 15, 2026

Copy link
Copy Markdown
Member

The Dock is now the canonical live-terminal navigator across both desktop and mobile. Closes #903 and #904. On desktop it sits on the left edge as a three-level surface (rail / cards / mega); on mobile the same model lives behind a left-edge swipe drawer. In maximized-tile mode it renders as a real flex-sibling sidebar — the maximized terminal flows next to it instead of underneath. Cards is the default level, so context shows up immediately on first load.

Three levels of detail (desktop)

Level What you see When
rail narrow strip of repo-colored swatches, one per terminal — ambient signal with breathe / pulse on live attention opt-in collapse from cards
cards (default) recency-sorted rows: awaiting → full card with xterm tail + reply input; working → compact pill; idle → faded one-liner; parked → tiny dim row; no-agent → foreground process title (e.g. pu connect srid1) first load
mega search + repo facets + agent-state columns (idle ladder leads — 4–12h / 12–24h / 24–48h / 48h+) ⌘⇧K, the chrome-bar magnifier, or the search icon in the dock header

⌘B toggles rail ↔ cards from anywhere; the chrome bar carries a matching dock-toggle button (mirror of the inspector toggle).

Keyboard navigation

Cmd+1..9 (Ctrl+1..9 on Linux/Windows) targets dock row order (recency-sorted, bucket-priority tiebreak) instead of insertion order — the shortcut activates whatever the user visibly sees at row N. Because that mapping shifts as agents transition, holding the same modifier paints a small numeric badge on the first nine rows so the binding is legible on demand. Same modifier for "what would this do" and "do it" — release to clear the hints.

Active-terminal indicator

A 2px accent strip pinned to each dock row's left edge marks the active terminal — visible in both rail and cards modes, outside per-body theming so it reads as "selected" against any tile background.

Mobile

MobileChromeSheet (top pull-down) keeps global controls — palette, settings, inspector. A new MobileDockDrawer lives behind a thin left-edge handle and renders the same recency-sorted terminal list as the desktop dock. Awaiting/working rows get richer treatment (larger height, agent indicator, time-ago, PR line); idle/parked rows stay compact one-liners; no-agent rows surface the foreground process. No reply input or buffer-tail on mobile by design — the v1 intent is "switch to that other terminal", not "respond inline".

Maximized-mode sidebar (#904)

The dock renders as a flush left-panel flex sibling of the canvas in maximized posture (mirror of RightPanelLayout's right panel). The maximized terminal naturally takes the remaining width via flex-1. Non-active xterm instances stay mounted (visibility: hidden) inside the tiled section so their PTY streams keep filling buffers — the dock's preview rows stay populated regardless of which terminal you're focused on.

One subtle: the active terminal is filtered out of the tiled section when maximized, because the maximized branch renders its own copy. terminalRefs is keyed by id and a second registration would race the first's cleanup.

File layout & naming

  • canvas/dock/ now holds Dock.tsx, MobileDockDrawer.tsx, DockMega.tsx, dockRowChrome.ts, and dockRowRanking.ts. The mega search panel was previously SearchPanel.tsx; the row display helpers were previously chrome.ts (confusingly close to the unrelated ChromeBar component). dockRowRanking.ts is the single source of "what terminals does the dock show, in what order" — read by Dock.tsx, MobileDockDrawer.tsx, and the Cmd+1..9 shortcut path, so the visual row order and the chord's activation target can never disagree.
  • canvas/dockModel.ts (hoisted from canvas/workspace-switcher/model.ts) — terminal classification + search model for the mega level (4-bucket scheme). Distinct from dockRowRanking.ts's 5-bucket row scheme; co-locating the two would invite label-collision bugs because both share four labels but disagree on the fifth (parked).
  • canvas/workspace-switcher/ is gone entirely — the chrome-bar surface it served retired in Consolidate workspace switcher + Activity dock into a single 3-level surface #903.
  • Type renames drop the stale WorkspaceSwitcher* prefix: DockEntry, DockSourceEntry, DockModel, DockColumn, IdleSubBucket, RepoFacet, AgentBucketKind, AGENT_BUCKETS, buildDockModel, sortDockEntriesByRecency.

What's gone

  • Chrome-bar workspace switcher (WorkspaceSwitcher.tsx, Collapsed.tsx)
  • Hover-opened search panel (its content moved to dock-mega)
  • compactGroups field + compactGroupsFor helper + IDLE_PILLS_PER_REPO / COMPACT_VISIBLE_PER_REPO caps — the compact pill strip retired with the chrome-bar switcher
  • prLine string helper (only prSummary is in use)
  • workspaceSwitcherSwitchTip (unused export)
  • .chrome-bar-surface:has([data-open]) frosted-surface CSS rule
  • activeId plumbing through Dock / MegaBody (only fed the retired compact-groups active-hoist)

Test coverage

dock.feature (11 scenarios) covers rail/cards defaults, Claude-mock surfacing, mode toggles via header chevron / ⌘B shortcut / chrome-bar button, the maximized-tile sidebar render path, the foreground-process line on no-agent rows, Cmd+1 targeting the recency-leading row, the modifier-held shortcut hints, and the new active-terminal indicator. Mobile drawer scenarios live in mobile-dock-drawer.feature. workspace-switcher.feature was rewired to operate on the dock's mega level. All scenarios pass locally.

Try it locally

nix run github:juspay/kolu/thin-sash

Generated by /do on Claude Code (model claude-opus-4-7).

srid added 9 commits May 14, 2026 22:05
…ebar

Closes #903 and #904. Retires the chrome-bar workspace switcher and the
hover-opened search popup; the canonical live-terminal navigator now
lives in the left-edge activity dock with three progressive levels of
detail (rail / cards / mega). Cards is the new default — surfaces real
context first, ambient compression on opt-in.

- Rail: narrow strip of repo-colored swatches; click to jump.
- Cards: awaiting → full card with xterm tail + reply input; working →
  compact pill; idle → faded one-liner; parked → tiniest dim row.
- Mega: search + repo facets + agent-state columns (Idle, Awaiting,
  Working, No agent), inlined from the retired chrome-bar panel.
  Opens via Mod+Shift+K with the search auto-focused.

In maximized-tile mode (#904) the dock renders as an opaque flush-left
sidebar and the maximized terminal reflows next to it (`left: dock-width`
on `CanvasTile`). Non-active tiles stay mounted (visibility: hidden) so
their xterm instances keep consuming the PTY stream and the dock's
buffer previews remain populated regardless of which terminal is active.

The active terminal is filtered out of the tiled section when maximized
to avoid double-mounting alongside the maximized branch — `terminalRefs`
is keyed by id and a second registration would race the first cleanup.
CanvasTile used to import both `dockMaximizedWidth` and the raw
`dockMode` signal to compute its maximized-mode left inset. That
leaked the dock's mode-to-pixel policy across a module boundary —
any future change (user-resizable sidebar, density-responsive
widths) would force edits in two modules. Encapsulate the mapping
inside ActivityDock and expose only the derived inset accessor.
ActivityDock used to hold the mega search query, repo filter, focus-on-
open impulse, and the workspace-switcher model alongside its rail/cards
orchestration. That conflated two activities (level-transition sequence
vs mega search state) in one component body. Mega is also where the
openMegaRequest impulse logically lands — opening was already done by
the orchestrator, but the focus half belongs to mega's own state
machine. Move all four signals + the focus-impulse effect into MegaBody
so the mega activity encapsulates its own volatility; the dock just
orchestrates the level mount and the onSelect/onClose callbacks.
@srid

srid commented May 15, 2026

Copy link
Copy Markdown
Member Author

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey animClass is a static const — SolidJS reactivity bug Fixed in this PR
2 Hickey CanvasTile imports module-scope dockMode — cross-module coupling Fixed in this PR
3 Hickey "mega" persists to localStorage — contradicts its own comment Fixed in this PR
4 Hickey Nested <Show fallback> in RowBody encodes a switch on DockBucket Fixed in this PR
5 Lowy dockMode leaks dock internals into tile shell Fixed in this PR (subsumes Hickey #2 via the dockTileInset accessor)
6 Lowy Mega-level state lives in orchestrator, not in its body Fixed in this PR
7 Lowy MegaBody adds no encapsulation No-op (subsumed by #6 — promoting MegaBody to own its signals resolves both)
8 Hickey (cross-validating Lowy) Splitting openMegaRequest across ActivityDock and MegaBody would fragment the mega lifecycle Fixed in this PR (threaded openRequest through to MegaBody so the open + focus halves stay co-located)

Hickey rationale

Four findings on the diff. animClass was captured at mount via const, so the breath/pulse animation would stick to a stale row across awaiting → working → idle transitions while the opacity classList on the same element updated correctly — diagnostically confusing. The dockMode import in CanvasTile reaches across a sibling-module boundary the tile shouldn't know about; every dock-mode toggle re-runs style computation for all mounted tiles in a 20-tile workspace. The "mega" deserializer arm contradicted its own comment ("surviving a reload to mega isn't useful"), letting the dock open into the search overlay after the last terminal closed mid-mega. The nested <Show fallback> pair encoded a switch over DockBucket<Switch>/<Match> exists for exactly this.

In the cross-validation pass, Hickey accepted Lowy's dockTileInset proposal (strict vocabulary reduction in CanvasTile) and flagged that moving only mega's state into MegaBody while leaving the openMegaRequest effect in ActivityDock would split the mega lifecycle across two components held together by a prop-passing invariant.

Lowy rationale

Three findings on volatility boundaries. CanvasTile's import of the raw dockMode signal made it part of the dock-width policy's blast radius — a future change (user-resizable sidebar, density-responsive widths) would force edits in two modules. The stable interface is the derived inset, not the mode. ActivityDock's body held mega search state (query, repoFilter, focusSearchOnOpen, megaModel) alongside rail/cards orchestration — two activities with different volatility axes in one module body; mega search is a distinct activity and should own its state. MegaBody's pre-fix shape was a nine-prop passthrough wrapper around WorkspaceSearchPanel adding zero encapsulation; subsumed by the state-promotion above.

In the cross-validation pass, Lowy reinforced Hickey's CanvasTile finding and added no new findings of its own.

@srid

srid commented May 15, 2026

Copy link
Copy Markdown
Member Author

/do results

Step Status Duration Verification
sync 0s git fetch ok; forge=github
research 5m 19s Mapped ActivityDock, WorkspaceSwitcher, TerminalCanvas, CanvasTile, terminalRefs, posture, xterm visibility lifecycle
branch 8s Feature branch thin-sash
implement 14m 45s Rewrote ActivityDock (tri-state mode, sidebar in maximized, idle/parked rows, mega via SearchPanel); retired chrome-bar switcher; tile reflow + visibility:hidden tiles for buffer previews; test migration
check 2m 2s just check exit 0
docs 2m 14s README.md + packages/surface/README.md
fmt 13s just fmt clean
commit 25s Primary commit, 18 files / +848 / -794
hickey+lowy 12m 17s 4 + 3 first-pass + 1 cross-validation finding; 5 commits, no deferrals
police 8m 41s 3 elegance fixes, separate commits
test 53s 24 scenarios / 142 steps in 6.6s
create-pr 1m 16s This PR + hickey/lowy comment
ci 53s nix build .#default exit 0 at HEAD (CI scope limited per user)
evidence 16s Skipped (user-deferred)
Total 49m 40s

Slowest step: implement (14m 45s)

Optimization suggestions

  • implement dominated at 30% — the volume came from migrating e2e test selectors (workspace-switcher-pillactivity-dock-row) and rewriting workspace-switcher.feature after deleting the chrome-bar surface. Pre-cataloguing which step definitions and feature files reference a retired test-id (e.g. rg 'workspace-switcher-pill' packages/tests before starting) would have let the rewrite happen as one batch instead of interleaving with implementation.
  • hickey+lowy and police combined to 21 minutes for 8 + 3 commits across two files. Most of the time was in serial commit-and-push cycles. For PRs with many structural-review findings, batching per-skill (one git push after all hickey commits, one after all police commits) without dropping the per-finding granularity in git log would cut push overhead — but per /do's explicit "push after each commit" rule, this would need a workflow flag rather than ad-hoc behavior.
  • research ran in 5+ minutes because the change touched five tightly-coupled UI components. Pre-loading Explore agent with a sharper question (e.g. "map every consumer of data-testid=\"workspace-switcher-pill\" along with the dock's collapse state machine") instead of a broad architecture sweep would have produced the same map in less context.

Workflow completed at 2026-05-14.

srid added 10 commits May 15, 2026 03:13
Previously the dock in maximized mode was absolutely positioned inside
the canvas (`inset-y-0 left-0`) and the maximized tile was offset by
`left: dockTileInset()px`. Two visible failures: (1) the maximized
tile at z-40 covered the dock at z-30, so mega's right columns
disappeared behind the terminal; (2) rail mode's button overflow leaked
canvas-grid background through the gap between the 28px-wide aside
and the 40px-ish effective content width.

Make the dock a real flex sibling of the canvas in maximized posture
(mirror of `RightPanelLayout`'s right panel). The canvas takes the
remaining width via `flex-1`, so the tile no longer needs an inset and
mega/cards/rail widths are honored without z-index gymnastics. Bump
rail width from 28→40px so the 24px header buttons + 8px padding
actually fit inside the aside. Drop `overflow-hidden` from the
classList exception list — apply it uniformly.

`dockTileInset` and `dockMaximizedWidth` exports retired (no more
external readers); replaced by an internal `dockWidth(mode)` that
drives only the aside's own `width` style.
Reported: the dock occasionally disappears completely until a full page
refresh. Trigger: enough posture toggles between tiled and maximized.

Root cause: the previous wiring stored the dock JSX in a `const dock =
<ActivityDock … />` and referenced it from two sibling `<Show>` blocks.
SolidJS JSX expressions are evaluated eagerly — `<ActivityDock />` is a
DOM node creation call, not a deferred template. Putting the same
evaluated node behind two Shows means one Show's cleanup tears down
the reactive scope (effects disposed, listeners removed) before the
other Show appends the now-corpse node into its branch. The element
ends up in the DOM but renders empty, and the only way back to a live
reactive scope is a full reload.

Fix: mount the dock once, unconditionally, as a flex sibling of the
canvas. The dock's outer aside already owns its posture-conditional
positioning — `relative shrink-0` in maximized (real left-panel flex
flow) vs `absolute z-30 top-20 left-4` in tiled (floats over the
canvas). The parent flex container becomes `relative` so the absolute
coordinates in tiled mode resolve to the same `top: 5rem, left: 1rem`
they did when the dock lived inside the canvas div.
Adds a dock-toggle button to the chrome bar's control cluster (mirror
of the inspector toggle, sits just left of it) plus a `Cmd+B` keyboard
shortcut. Both drive the same rail/cards toggle the dock-header
chevron does — and when mega is open, they close mega back to its
prior level first.

- `DockToggleIcon` mirrors `InspectorToggleIcon`: square with a
  divider on the left edge; left half fills when the dock is
  expanded (cards or mega) and goes empty in rail mode.
- `Cmd+B` is the same shortcut VS Code uses for its primary
  sidebar. The inspector (right panel) keeps `Cmd+Alt+B`.
- `toggleRailCards` and `dockExpanded` exported from ActivityDock
  so the chrome bar drives them without re-implementing the state
  machine.
Mobile mirror of the desktop dock split (#903): the chrome drawer
(top pull-down) keeps the global controls — palette, settings,
inspector — while a new left-edge swipe drawer hosts the terminal
navigator. Native iOS/Android nav-drawer pattern.

- `MobileDockDrawer`: recency-sorted terminal list (matches the
  desktop dock's "what just changed?" ordering, not the previous
  alphabetical-by-repo grouping), bucket-classified rows with
  repo color + agent indicator + unread dot. No reply input or
  buffer-tail preview by design — the v1 intent on mobile is
  "switch to that other terminal", not "respond inline".
- `MobileChromeSheet`: stripped to identity + control cluster.
  The terminal list moved out entirely.
- `MobileTileView`: two Corvu drawers as siblings (not nested —
  nesting put the chrome trigger in the dock-drawer context,
  breaking tap-to-open). Plain buttons drive each `open` signal;
  the chrome handle keeps its drag-down-to-open behavior.
- Left-edge handle: thin colored strip (`bg-fg-3/30`) pinned to
  the viewport's left middle, just enough affordance for "open
  the nav" without competing with iOS Safari's back-swipe.

E2e: 8 scenarios across `mobile-drawer.feature` (chrome) and the
new `mobile-dock-drawer.feature`, all passing.
Mirrors the desktop dock's awaiting-card / working-pill / quiet-row
hierarchy on the mobile drawer. Awaiting and working rows now get:
- Larger headline type (0.95rem vs 0.8rem)
- More vertical padding (py-3 vs py-2)
- An agent-indicator + time-ago meta line
- A PR line (#N + title) when one is resolved

Idle, parked, and no-agent rows stay as compact one-liners — they're
the "navigate to it later" bucket, not the "needs you now" bucket, so
they don't earn the extra height. The cap (parked = lastActivityAt >
4h ago) routes long-stale rows to the quiet variant regardless of
prior agent state.
"Activity" was a vestige of #897 when the surface only carried
awaiting/working agents. With idle and parked terminals now first-
class citizens, the name overstates the scope. The mobile drawer
already dropped "activity" (MobileDockDrawer, mobile-dock-handle); the
desktop surface follows suit.

- File: canvas/ActivityDock.tsx → canvas/Dock.tsx
- Component: ActivityDock → Dock
- localStorage key: kolu-activity-dock-mode → kolu-dock-mode
  (forces a reset for anyone with `rail` persisted, naturally
  delivering the expanded-by-default experience)
- Test IDs: activity-dock-* → dock-*
- Feature/step files: activity-dock.feature → dock.feature,
  activity_dock_steps.ts → dock_steps.ts
- README + package READMEs updated

`useActivityAlerts` keeps its name — "activity" there refers to the
alert system (Badging API, OS notifications), not the dock.
…rigger

Two refinements:

1. **Foreground process surfaced for plain shells**. Non-agent rows
   (idle / parked / no-agent buckets) used to read as bare
   `repo · branch` — a `~ ~` home-dir shell looked indistinguishable
   from any other. Now they carry the foreground process title (e.g.
   `pu connect srid1`, `nix build`) as a second line, pulled from
   `meta.foreground.title || meta.foreground.name`. Mirror on the
   mobile dock too: non-live rows (idle/parked/none) get the same
   process line, while live rows (awaiting/working) keep the existing
   agent-indicator + PR line treatment.

2. **Mega-search button switched from `⏵` glyph to `SearchIcon`**.
   The dock header had two chevron-shaped icons sitting next to each
   other — `⏵` for "open mega search" and a real `ChevronDownIcon`
   (rotated) for "collapse to rail". Both read as right-pointing
   arrows, ambiguous at a glance. The mega trigger *is* a search
   affordance, so a magnifier icon distinguishes intent visually:
   magnifier = search, chevron = collapse.
# Conflicts:
#	packages/client/src/canvas/TerminalCanvas.tsx
srid added 3 commits May 15, 2026 10:44
…itcher/

The `canvas/workspace-switcher/` folder was a vestige of the chrome-bar
surface that retired with #903. After the dock refactor and the
mobile-dock-drawer addition, its content had drifted into two
unrelated responsibilities (domain model + dock mega renderer), and
the surrounding dock files were scattered across the source tree
(`canvas/Dock.tsx`, `src/MobileDockDrawer.tsx`, dock mega in
`workspace-switcher/`). Two structural moves:

1. **Co-locate dock UI under `canvas/dock/`.** Holds:
   - `Dock.tsx` (was `canvas/Dock.tsx`)
   - `MobileDockDrawer.tsx` (was at `src/` root)
   - `DockMega.tsx` (was `SearchPanel.tsx`, the dock's mega renderer)
   - `dockRowChrome.ts` (was `chrome.ts` — display-string helpers;
     the old name collided semantically with the unrelated `ChromeBar`
     component)

2. **Hoist the domain model to `canvas/dockModel.ts`.** Terminal
   classification (`agentBucket`, `entryBucket`, `buildDockModel`),
   recency sort, and the search/facet/column types are consumed by
   `Dock`, `MobileDockDrawer`, and `CanvasMinimap` — none of them
   "owns" the model, so it sits as their peer at the canvas root.

Type renames to drop the stale "WorkspaceSwitcher" prefix:
- `WorkspaceSwitcherEntry` → `DockEntry`
- `WorkspaceSwitcherSourceEntry` → `DockSourceEntry`
- `WorkspaceSwitcherModel` → `DockModel`
- `WorkspaceSwitcherColumn` → `DockColumn`
- `WorkspaceSwitcherIdleSubBucket` → `IdleSubBucket`
- `WorkspaceRepoFacet` → `RepoFacet`
- `WorkspaceAgentBucket` → `AgentBucketKind` (avoids collision with
  `agentBucket()` and Dock.tsx's local `DockBucket` which adds
  "parked")
- `WORKSPACE_AGENT_BUCKETS` → `AGENT_BUCKETS`
- `buildWorkspaceSwitcherModel` → `buildDockModel`
- `sortBySwitcherOrder` → `sortDockEntriesByRecency`

Dead code removed in the same pass:
- `prLine` (string-form PR helper; only `prSummary` is in use)
- `WorkspaceSwitcherRepoGroup`, `WorkspaceSwitcherCompactItem` types
- `compactGroups` field on the model
- `compactGroupsFor` helper + `IDLE_PILLS_PER_REPO` / `COMPACT_VISIBLE_PER_REPO`
  caps. The "compact pill strip" they served retired with the
  chrome-bar switcher; mobile no longer consumes the field either.
- 6 tests in `dockModel.test.ts` that exercised the removed
  compact-groups behavior
- `activeId` plumbing through `Dock` / `MegaBody` (was only used to
  feed compact-groups' active-hoist)

After the move, `canvas/workspace-switcher/` is gone entirely.

E2E unchanged (test-IDs still use `workspace-switcher-*` for the dock's
mega panel — those are a separate cleanup tracked for follow-up).
32/32 scenarios pass.
…ound row

dock.feature had 4 scenarios covering rail/cards defaults and the Claude
mocking path. The new bug-fix surface from this PR sat without e2e
coverage. Add scenarios for:

- `Cmd+B` keyboard shortcut → toggles rail ↔ cards
- Chrome-bar `[data-testid="dock-toggle"]` button → toggles rail ↔ cards
- Maximized-tile mode → dock stays visible and carries `data-maximized=""`
  (asserts the real left-panel sidebar render path from #904)
- Foreground process line on quiet rows → `sleep 5` populates the new
  `dock-quiet-foreground` line on a no-agent shell

Four new step definitions in dock_steps.ts; total dock coverage is now
8 scenarios / 37 steps. Full sweep (dock + workspace-switcher +
activity-alerts + mobile-drawer + mobile-dock-drawer): 36/36 passing.
@srid srid changed the title Activity dock as canonical navigator with maximized sidebar Dock: canonical navigator across desktop & mobile May 15, 2026
srid added 4 commits May 15, 2026 11:14
Per the #903 open question (which order should the positional
shortcuts target?), retarget `Cmd+1..9` from `store.terminalIds()`
(insertion order) to `dockOrderedIds` (the same recency-sorted list
the dock and mobile drawer render from). The shortcut now activates
whatever the user visibly sees at row N, instead of a stable-but-
invisible "first terminal I opened".

Because the dock's recency ordering shifts as agents transition, the
visible mapping needs an on-demand affordance — held-Alt paints a
small `Cmd+N` hint badge on the first nine rows. Module-scope
`altHeld` signal driven by window `keydown`/`keyup` + a `blur` /
`visibilitychange` reset (the latter catches tab-aways where the
keyup never reaches the page and the badge would otherwise stick).
Each `DockRow` consumes its `index` (zero-based) from the `For`
iterator and renders the hint when `altHeld() && index < 9`.

E2E: three new scenarios on `dock.feature` — Cmd+1 returning to the
recency-leading row, hints visible while Alt is held, hints clear on
release. 10 dock scenarios / 49 steps, full sweep 38 scenarios / 216
steps, all passing.
…match

Hickey #1 (= Lowy #2): `App.tsx`'s `orderedIds` and `Dock.tsx`'s
`liveIds` were two derivations of the same concept with different
secondary-sort rules. The Alt-held Cmd+N hint painted onto dock rows
indexed into the dock's bucket-prioritized order, but the actual
`Cmd+N` action read App.tsx's pure-recency order — so the badge could
lie about which terminal a shortcut would target as soon as parked
terminals existed.

Extract `rankDockRows` into a new `canvas/dock/dockRowRanking.ts`
sibling (not `dockModel.ts` — that file owns the mega-level
four-bucket scheme that collapses parked into idle, while rows use
five buckets with `parked` distinct; co-locating would invite
label-collision bugs). Both `Dock.tsx`'s `ranked()` and `App.tsx`'s
`orderedIds` now read from `rankDockRows`, so the Cmd+N target and
the row that paints the hint can never diverge.
Lowy #1: the row classification + ranking loop (`parked` check →
`agentBucket` → `idle` upgrade → recency-sorted with bucket-priority
tiebreak) was byte-equivalent in `Dock.tsx` and `MobileDockDrawer.tsx`.
Now that commit 0e98f0f extracted `rankDockRows` into a shared
sibling module, MobileDockDrawer can drop its local `BUCKET_PRIORITY`
table, `MobileDockBucket` alias, and the duplicate loop — any change
to bucket vocabulary, parked threshold semantics, or tiebreak rules
lands in one place.
Lowy #3 + Hickey cross-validation B: the `pr.kind === "ok" ? pr.value
: null` extraction lived in three places (Dock.tsx's `PrLine`,
MobileDockDrawer.tsx's `PrLine`, and dockRowChrome.ts's `prSummary`).
Add `resolvedPr(pr)` to `dockRowChrome.ts` and have `prSummary` call
it internally, so a future kind added to the PR carrier union (e.g.
"loading" or "error") forces one edit, not three. The structured
`PrSummary` shape layers on top of the resolved-PR predicate rather
than re-implementing it.
srid added 3 commits May 15, 2026 11:51
`classifyDockRow` and `DOCK_ROW_BUCKET_PRIORITY` were exported from
`dockRowRanking.ts` but imported nowhere outside that file. Module-
private now — no speculative public surface.
The Alt-held hint chips were a modifier-mismatch: the chord they
preview is `Cmd+1..9` (or `Ctrl+1..9` on Linux/Windows), but the
hint surfaced under a *different* key. Holding the shortcut's own
modifier to reveal the mapping is the standard discoverability
pattern (System Settings, Linear, etc.) — same key for "what would
this do" and "do it".

Swap `altHeld` for `modHeld`, route the keydown/keyup listeners
through `isPlatformModifier` from `input/keyboard.ts`, and update the
matching e2e scenario from `press/release Alt` to `press/release Mod`
so it exercises the actual chord modifier on each platform.
The dock had `data-active` on each row but no visible treatment — the
active terminal looked indistinguishable from its neighbors. Add a 2px
accent strip pinned to the row's left edge, visible in both rail and
cards modes. The strip sits outside per-body theming so it reads as
"selected" against any tile background.

E2E: covers the indicator's presence after terminal creation.
@srid

srid commented May 15, 2026

Copy link
Copy Markdown
Member Author

Hickey/Lowy Analysis

Polish-pass review on the cumulative branch diff (git diff origin/master...HEAD). Sonnet sub-agents ran in parallel; both produced findings, so a second parallel cross-validation pass ran each lens over the other's recommendations. Net: 4 first-pass findings + 3 cross-validation findings, all fixed.

# Lens Finding Disposition
1 Hickey orderedIds (App.tsx) vs liveIds (Dock.tsx) — two derivations of dock-row order with divergent tiebreaks; Alt-held Cmd+N hint can lie about the activation target Fixed in this PR (0e98f0f9)
2 Lowy Row classification + ranking loop byte-equivalent in Dock.tsx and MobileDockDrawer.tsx Fixed in this PR (14e1226b)
3 Lowy Cmd+1..9 targets wrong terminal when parked terminals exist (same bug as Hickey #1, observed from the volatility lens) Fixed in this PR (subsumed by 0e98f0f9)
4 Lowy resolvedPr extraction (pr.kind === "ok" ? pr.value : null) repeated inline at three sites Fixed in this PR (8874440b)
A Hickey × Lowy #1 Lowy proposed placing the extracted classifier in dockModel.ts — would braid two classification schemes (5-bucket row vs 4-bucket mega) sharing four labels with divergent parked semantics Fixed in this PR (0e98f0f9 lands in new dockRowRanking.ts sibling, not dockModel.ts)
B Hickey × Lowy #3 prSummary and resolvedPr would be two definitions of "PR is resolved" unless layered Fixed in this PR (8874440b; prSummary delegates to resolvedPr)
C Lowy × Hickey #1 Hickey proposed exporting a module-scope signal from Dock.tsx (a UI component) as the ranking source — inverts the volatility gradient (UI rendering is high-volatility; ranking is lower-volatility, belongs below) Fixed in this PR (0e98f0f9; cross-validators converged on the sibling-module destination)

Hickey rationale

The Cmd+N shortcut and the dock's Alt-held hint chips both implicitly assert: "row N of the dock is the terminal that Cmd+N activates." But App.tsx's orderedIds went through buildDockModelsortDockEntriesByRecency (recency-desc, then canvas x,y tiebreak) while Dock.tsx's ranked() sorted by ts then BUCKET_PRIORITY. The coherence rule was asserted only by prose in actions.ts and broke as soon as multiple terminals shared lastActivityAt. Canonical fragmentation: one concept, two derivations.

The fix extracts rankDockRows into a new sibling module (not dockModel.ts, per cross-validation A) so both consumers read identical output.

Lowy rationale

Dock.tsx and MobileDockDrawer.tsx each carried a local DockBucket / MobileDockBucket alias, a local BUCKET_PRIORITY table, and a near-byte-equivalent ranked() loop. Same volatility axis (bucket vocabulary, parked threshold, idle-upgrade rule, tiebreak priority), two call sites — any change has to be made twice. The same fragmentation produced the Cmd+N correctness bug (finding 3): App.tsx independently called buildDockModel without idleClassifier, so its ordering pipeline didn't know about staleness while the dock's did.

Cross-validation C: Hickey's instinct to centralize at Dock.tsx was structurally inverted — UI rendering volatility (which row variants exist, what they look like) is independent from row-ordering policy (recency + bucket priority). Placing the source in Dock.tsx braids them. The right home is one layer below — a sibling module dedicated to ranking that both UI surfaces depend on.

Follow-on UX fixes during polish

Two UX issues surfaced during review and landed as follow-on commits:

  • 4459a2ba — hint chips were keyed off Alt; the shortcut they preview uses the platform modifier (Cmd on macOS, Ctrl elsewhere). Re-routed through isPlatformModifier. Same key for "what would this do" as for "do it" matches the standard discoverability pattern.
  • 98e21311data-active was set on dock rows but had no visible treatment. Added a 2px accent strip pinned to the row's left edge, visible in both rail and cards modes, outside per-body theming.

srid added 4 commits May 15, 2026 12:09
`Ctrl+B` was released back to the terminal in #821, then reclaimed
as the dock toggle shortcut when the dock shipped as canonical in
#903. The unit test still asserted "does not capture Ctrl+B" — flip
to assert it matches, since `toggleDock` carries `{key:"b", mod:true}`
and `mod` resolves to `ctrlKey` on Linux/Windows.
After #904 the canvas tiles split across two render branches: the
tiled section keeps non-active tiles mounted at \`visibility: hidden\`
so their PTY streams keep filling buffers, while the maximized tile
renders in its own \`Show\` branch. \`locator(TILE_SELECTOR).nth(0)\`
picks the first DOM-order tile — a hidden tiled-section tile — so
\`waitFor({state: "visible"})\` times out even though the maximized
tile is visible elsewhere.

Match on \`[data-maximized="true"]\` directly. The \`index\` parameter
was already informational only (the existing \`waitForFunction\` checked
"some tile has data-maximized=true" without verifying which).
The "Restored terminals appear in the predictable alphabetical pill
order" scenario tested an explicit property of the chrome-bar
workspace switcher (#830): pills were alphabetized by repo group.
The dock that replaced that surface (#903) uses recency-sorted
order with a bucket-priority tiebreak — alphabetical placement is no
longer a property the system preserves. Drop the scenario and its
companion step + helpers; the remaining session-restore scenarios
still exercise the meaningful behavior (count + theme).
Switching the active terminal triggers an xterm fit/resize on the
newly-focused tile. During that window two things can happen on the
peek-tick that paints the awaiting card's tail preview:

1. The terminal's refs are transiently absent (component still
   reconciling), so `getTerminalRefs(id)` returns undefined.
2. `buffer.active.length` is briefly small enough that every row
   reads as alt-screen chrome, so `tailBuffer` returns `[]`.

The old effect overwrote `tail` to `[]` in both cases. The card's
`<Show when={tail().length > 0}>` then unmounted the tail div, and
the user saw a flash to 0 lines before the next tick repainted N
lines. Treat both shapes as transient: hold the cached tail and let
the next tick replace it from a populated buffer.
@srid srid mentioned this pull request May 15, 2026
@srid

srid commented May 15, 2026

Copy link
Copy Markdown
Member Author

/do results

Step Status Duration Verification
hickey+lowy 16m 12s 4 findings, 3 commits (0e98f0f9, 14e1226b, 8874440b). Cross-validators converged on a new sibling-module destination.
police 9m 3s 1 violation (dead exports in dockRowRanking), fixed in 5a125c80. Re-run on subsequent feature commits clean.
test 45s dock.feature + mobile-dock-drawer.feature: 14/14 scenarios.
create-pr 1m 22s PR body refreshed (modifier-held hints, active indicator, dockRowRanking.ts); Hickey/Lowy analysis posted.
ci 33m 53s 14/14 contexts green on a4c1a90c. Linux e2e failed once on the prior SHA (Terminal survives browser refresh — flaky, logged to #320), passed on targeted retry with no source change.
evidence skipped User directive — "No need for evidence".
Total 64m 15s

Optimization suggestions

  • CI dominates wall-clock (53%). This run hit the flaky Terminal survives browser refresh scenario on e2e@x86_64-linux, which forced a single-step retry and then a full re-run against HEAD for SHA coverage. Tracking the flake in Flaky tests log #320 is the right place; landing a fix there shaves ~7 min off the average /do round. (The full re-run was also needed because an earlier background CI process was killed mid-flight — worth investigating the harness reaping behavior separately.)
  • hickey+lowy was the second-largest bucket (25%). Two-pass review (parallel reviewers + cross-validation) on a large cumulative diff. Cost was warranted — the cross-validation surfaced the third-destination compromise (dockRowRanking.ts sibling module) that neither reviewer reached alone. Worth keeping the two-pass shape on diffs of similar scope.
  • police ran 9 min for one finding. All three passes (rules / fact-check / elegance) ran on the cumulative branch diff; only the rules pass surfaced anything. On a polish-pass entry point where prior commits already cleared police, scoping subsequent invocations to just the new commits since the last green police pass would compress this substantially.
  • In-stream UX feedback landed two follow-on commits (4459a2ba, 98e21311, a4c1a90c). Captured as separate commits so the PR history reads as a sequence; this is the right shape for review but extends the loop. Consider pre-flighting visible-affordance changes (modifier semantics, active-state styling) before invoking /do --follow polish.

Workflow completed at 2026-05-15T16:36:46Z.

@srid
srid marked this pull request as ready for review May 15, 2026 16:53
@srid
srid merged commit 945edb9 into master May 15, 2026
16 checks passed
@srid
srid deleted the thin-sash branch May 15, 2026 16:53
srid added a commit that referenced this pull request Jul 2, 2026
The canvas first-mount centre-on-active effect guarded the bbox fallback only
against the COLD-LOAD race (`savedSessionSub.pending()`). W1.R6 moved restore
host-side, so a restore-from-card arrives with `session.get` already yielded
(`pending()` false) while the restored tiles land on the `terminals` collection
a tick before `useSessionRestore` assigns the active via `setActiveSilently`.
In that window the bbox fallback ran, panned off-origin, and `isDefaultViewport()`
then blocked the real centre-on-active — leaving a restored multi-tile session
centred on the bbox (0,0) instead of its active tile (session-restore.feature:46
'preserves active terminal and centers viewport', pre-existing since #909).

Widen the guard: wait for the active to hydrate whenever there's no active yet
AND one is expected — session still pending OR the saved session names an
`activeTerminalId`. Hydration always assigns some active when top-level tiles
exist, so it can't wait forever.
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.

Consolidate workspace switcher + Activity dock into a single 3-level surface

1 participant