Skip to content

Add a hover workspace switcher - #829

Merged
srid merged 27 commits into
masterfrom
feat/828-workspace-switcher
May 6, 2026
Merged

Add a hover workspace switcher#829
srid merged 27 commits into
masterfrom
feat/828-workspace-switcher

Conversation

@srid

@srid srid commented May 5, 2026

Copy link
Copy Markdown
Member

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.

image

How it fits together

live terminals
  -> display info + optional canvas layout
  -> desktop/mobile order policy
  -> workspace switcher model
       -> compact repo groups
       -> repo facets + search results
       -> agent-state columns

The switcher lives behind canvas/workspace-switcher/ as its own boundary. Bucket descriptors in model.ts own 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

  • Two ways to open the panel: hover for a peek (closes when the cursor leaves), or click the chevron to pin it open.
  • Four dismissal paths: cursor leaves, click a pill/card, press Esc, click outside (when pinned), or hit the explicit ✕ button in the panel header.
  • Keyboard shortcut: Cmd/Ctrl + Shift + K opens the panel and focuses the search input.
  • Search spans live metadata: repo, branch, PR, agent state/model/summary, foreground title, cwd, recent agent command.
  • Whole-header frosted surface: the chrome bar fades to a frosted surface-1 when the panel is engaged, so the strip and dropdown read as one floating piece.
  • Repo facet clicks stay interactive: clicking a side repo keeps the panel open and filters the cards.
  • Mobile keeps live terminal order; desktop uses canvas layout order. Future desktop grouping changes won't rewrite swipe behavior.

Visual language for state

State Strip pill Panel card
Idle thin edge border, opaque surface thin edge border, opaque surface
Awaiting input breathing alert ring breathing alert ring + bucket glyph
Working (thinking / tool use) static accent ring static accent ring + bucket glyph
Active terminal 2px accent border, surface-2 fill solid accent fill, inverted text

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 under prefers-reduced-motion.

Bugs flushed during iteration

  • Click deadzones on strip pills: the panel's hover bridge sat at top-9 with z-50, overlapping the bottom 6px of every mini-card. Fixed by moving the bridge below the strip's bottom edge.
  • Panel hidden behind maximized terminal: the docked chrome bar lacked a z-index, so the backdrop-filter-induced stacking context trapped the panel below the maximized tile (z-40). z-50 on the docked bar now lifts the whole stack.
  • :focus-within pinned the panel open after clicking a pill — replaced with a JS (hover, dismissed, latched) state machine so explicit dismissals win over CSS hover.
  • Terminal text bleeding through pills: pills used bg-surface-0/60 (transparent); now opaque surface-0.
  • Toggle click closed a hover-opened panel because toggleLatch checked isOpen() and Playwright (and real users) hover before clicking — gated on latched() instead.

Verification

  • just fmt / just check / unit tests across all packages
  • New e2e coverage for every dismissal path: toggle latch/un-latch, close button, Esc, click-outside, select-and-close
  • just ci on b1c37f72: all 14 expected ci/* 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

nix run github:juspay/kolu/feat/828-workspace-switcher

Generated by /do on Codex (model gpt-5); updated by /forge-pr on Claude Code (model claude-opus-4-7).

@srid srid mentioned this pull request May 5, 2026
@srid srid mentioned this pull request May 5, 2026
@srid

srid commented May 5, 2026

Copy link
Copy Markdown
Member Author

Evidence

Chrome DevTools MCP pass after 4a9c7160 verified the workspace switcher hover panel stays inside the available chrome slot with the inspector/right panel open.

  • 1900x1120 with right panel open: panel rect left=60 right=1222 width=1162, horizontallyClipped=false, verticallyClipped=false.
  • 1280x900 with right panel open: panel rect left=60 right=757 width=697, horizontallyClipped=false, verticallyClipped=false.
  • 390x844 mobile emulation: desktop switcher hidden, mobile workspace tile visible.

Workspace switcher DevTools evidence, 1900px

Workspace switcher DevTools evidence, 1280px

Workspace switcher DevTools evidence, mobile

@srid

srid commented May 5, 2026

Copy link
Copy Markdown
Member Author

[/do] results

Step Status Duration Verification
sync 1s git fetch ok; forge=github; noGit=false
research 4m 29s workspace switcher scope mapped: App/PillTree/pillTreeOrder plus README/tips/tests; no server/router/persistence changes
branch 6s created feat/828-workspace-switcher from origin/master
implement 14m 14s WorkspaceSwitcher component/model/order implemented; compact selection, hover search, repo facets, agent-state grouping, and card selection covered by unit/e2e tests
check 23s just check exited 0; pnpm typecheck passed; biome lint reported warnings only
docs 20s README and packages/surface README updated; client/common/server docs no longer describe PillTree as the navigation surface
fmt 17s just fmt completed; biome formatted 5 files and nixpkgs-fmt had no nix changes
commit 29s commit f5c2ee3 created and pushed to origin/feat/828-workspace-switcher; working tree clean
hickey+lowy 14m 23s hickey and lowy reviews completed; addressed boundary/model/order/color findings in b707d49; pushed to origin
police 0s code-police rules/fact-check/elegance reviewed branch diff; fixed exported-symbol/boundary documentation in c9c6993; just fmt, just check, and focused client unit tests passed
test 59s selected changed e2e feature set passed: 90 scenarios / 668 steps
create-pr 27s draft GitHub PR created: #829
ci 16m 23s CI passed for 4a9c716 after paginated status cross-check; Darwin and Linux e2e passed on serial retries after unrelated parallel flakes were logged to #320
evidence 25s Chrome DevTools MCP screenshots uploaded to evidence-assets and posted on PR #829
done 0s PR #829 has evidence, CI statuses are green on 4a9c716, and the workflow summary is posted
Total 58m 21s

Slowest step: ci (16m 23s).

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.

srid added 14 commits May 5, 2026 13:19
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.
srid added 4 commits May 5, 2026 21:10
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.
@srid srid mentioned this pull request May 6, 2026
3 tasks
srid added 2 commits May 5, 2026 21:27
- 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
srid marked this pull request as ready for review May 6, 2026 01:39
@srid
srid merged commit 31d04c5 into master May 6, 2026
17 checks passed
@srid
srid deleted the feat/828-workspace-switcher branch May 6, 2026 14:02
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.

1 participant