Add a hover workspace switcher - #829
Merged
Merged
Conversation
Closed
Member
Author
EvidenceChrome DevTools MCP pass after
|
Member
Author
[/do] results
Slowest step: Workflow notes No step crossed the 30% dominance threshold, and there were no skipped or failed workflow steps. The only actionable follow-up is the unrelated parallel e2e flake pattern, already logged on #320; keeping those flakes out of the main PR avoids hiding the workspace-switcher verification signal. |
Replace pure-CSS hover/focus-within with a `hover && !dismissed` signal so explicit dismissals (click a pill, Escape, cursor leaving) actually close the panel — `:focus-within` was pinning it open after a click.
Move the per-bucket text-class and accent CSS variable into the bucket descriptors in `model.ts`. SearchPanel reads `column.textClass` / `column.accentVar` directly off the column instead of maintaining parallel maps; the card status glyph reads via `bucketDescriptor()`. One source of truth — adding a bucket is a single edit.
The pill-border class set and the status glyph were both state→bucket→ visual mappings duplicated in chrome.ts. Move them onto the bucket descriptors in model.ts and let callers compute the bucket directly (`bucketDescriptor(agentBucket(agent))`). chrome.ts shrinks to the helpers that aren't bucket-keyed (agentLabel, metaLine, prSummary, tokenLine).
The panel's bridge wrapper sat at top-9 (36px) with z-50, overlapping the bottom 6px of every mini-card (which lives at y=18-42 in the collapsed strip). Pointer events for the wrapper consumed clicks that landed in the bottom quarter of any pill — the user could only click the top portion of each pill, ~90% of clicks went to dead air. Move the wrapper to top-11 (44px = strip bottom edge) with pt-2 (8px) so it sits cleanly below the strip and never overlaps a clickable descendant.
The repo facets count pre-filter matches; visibleEntries count post- filter. The dependency was implicit — comments noted the invariant but nothing structural enforced it, so a future reordering of the locals would silently corrupt facet counts. Wrap the three derivations in one function so the shared input (queryMatches) and the order of operations are visible in one place.
The three "ordering" functions weren't real volatilities. Desktop sort is a five-line spatial comparator that lives next to the layout read in App.tsx; mobile order was identity (just `[...entries]`); flat order was `entries.map(e => e.id)`. Inline them at the call sites. Move WorkspaceSwitcherSourceEntry + buildWorkspaceEntries to model.ts where the model lives — they shape the model's input. Delete order.ts.
- Toggle chevron next to the strip's `+` button: clicking opens the panel and latches it open until an explicit dismissal. - Close button (✕) in the panel's search header. - Click-outside (mousedown) on a latched panel closes it. - Esc / select / close button all clear both the latch and the hover-dismiss flag. The state shape is now `latched || (hover && !dismissed)`. Hover-only opens are still ephemeral (close on mouse leave); latched opens stay until the user dismisses them — better for searching, faceting, and keyboard interaction inside the panel.
The pill-border channel was multiplexing two concerns onto one CSS variable. `--card-color` (repo color) drove both the active-terminal ring AND the agent-state animations (spin and breathe). State was encoded only in motion type — both buckets painted in repo color, so "awaiting" and "working" were chromatically identical. Split into two independent channels: - `--card-color` keeps driving terminal-active (`pill-border-active` and `pill-glow-inner`) — repo identity. - `--pill-state-color` drives agent-state. JSX call sites set it from the bucket descriptor's `accentVar` (alert / accent / fg-3), so the bucket descriptor is the single source of "alert = awaiting". Reduce motion to the actionable bucket: only awaiting breathes (in alert), working is a static accent ring, idle stays empty. The rotating conic gradient was three pixels of arc travel on a 24px pill — more flicker than communication. `@property --spin-angle`, `@keyframes border-spin`, and `pill-border-spin` are deleted. Honour `prefers-reduced-motion`: the breathe collapses to a static 80%-opacity alert ring so the bucket still reads but doesn't pulse.
The default browser scrollbar reads as chunky-gray against the panel's frosted surface. Adds a `.scrollbar-subtle` utility (thin, edge-bright thumb, transparent track) and applies it to the panel's sidebar and column-grid scrollers.
- Toggle button latches the panel open and closes it on second click. - Close button (✕) inside the panel header dismisses. - Escape dismisses. - Clicking outside (on the canvas) dismisses a latched panel. - Selecting a card closes the panel after switching active terminal. Step definitions: - I click the workspace switcher toggle / close button - I press Escape - I click outside the workspace switcher - the workspace switcher panel should not be visible Also fix the index.css formatting biome wanted (multiline color-mix).
Two issues caught by e2e:
1. `toggleLatch` checked `isOpen()`, so a hover-opened panel + click on
the chevron would close instead of latch. Playwright always hovers
before clicking, so the very first toggle click closed the panel.
Real users hit the same trap: peek via hover, click chevron to
latch, get an unexpected close. Gate on `latched()` instead.
2. Chevron rotation, aria-label, and title now reflect `latched()`
(the toggle's own state) rather than `isOpen()`. Hover-only opens
leave the chevron pointing down — the panel itself is the
indication. `aria-expanded` still tracks `isOpen()` for AT.
3. Drop my duplicated `When("I press Escape")` step — collided with
the existing generic `When("I press {word}")` in
`command_palette_steps.ts`. The generic one already matches.
The chevron's rotation was wired via a template-literal class string —
works, but Solid prefers `classList={{...}}` for conditional classes.
`ChevronDownIcon` only accepts `class`, so wrap it in a span that
carries the rotation transition + classList.
Also add a why-comment to `.scrollbar-subtle` explaining the
`styling-tailwind-only` rule exception (stock Tailwind v4 has no
scrollbar pseudo-element utilities; adding `tailwind-scrollbar` would
be a new dep for what the ~25 lines already encode).
srid
marked this pull request as ready for review
May 6, 2026 01:39
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.



The canvas now has a hover workspace switcher for live terminals: a compact repo/branch strip at rest, with a searchable panel on hover grouped by agent state. Selecting an entry focuses the terminal and centers its canvas tile, making parallel work easier to scan and jump between.
First pass at #828.
How it fits together
The switcher lives behind
canvas/workspace-switcher/as its own boundary. Bucket descriptors inmodel.tsown the per-bucket label, empty copy, accent var, text class, border class, and glyph in one place — adding or renaming a bucket is a single edit.What changed for users
surface-1when the panel is engaged, so the strip and dropdown read as one floating piece.Visual language for state
The agent-state ring (
--pill-state-color) and the active-terminal indicator (--card-color) read from independent CSS variables, so an active terminal that's also awaiting renders both signals without channel collision. The breathe animation collapses to a static high-opacity background underprefers-reduced-motion.Bugs flushed during iteration
top-9withz-50, overlapping the bottom 6px of every mini-card. Fixed by moving the bridge below the strip's bottom edge.backdrop-filter-induced stacking context trapped the panel below the maximized tile (z-40).z-50on the docked bar now lifts the whole stack.:focus-withinpinned the panel open after clicking a pill — replaced with a JS(hover, dismissed, latched)state machine so explicit dismissals win over CSS hover.bg-surface-0/60(transparent); now opaquesurface-0.toggleLatchcheckedisOpen()and Playwright (and real users) hover before clicking — gated onlatched()instead.Verification
just fmt/just check/ unit tests across all packagesjust cionb1c37f72: all 14 expectedci/*contexts green. One unrelated Codex token-counting flake (features/codex.feature:39) on x86_64-linux passed on retry; logged to Flaky tests log #320.Try it locally
Generated by
/doon Codex (modelgpt-5); updated by/forge-pron Claude Code (modelclaude-opus-4-7).