Skip to content

refactor(ui): unify the workspace picker onto the composer's ghost-menu family - #2287

Merged
Astro-Han merged 4 commits into
mainfrom
refactor/composer-workspace-picker-ghost-menu
Aug 5, 2026
Merged

refactor(ui): unify the workspace picker onto the composer's ghost-menu family#2287
Astro-Han merged 4 commits into
mainfrom
refactor/composer-workspace-picker-ghost-menu

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Background

After #2230 moved the model and thinking pickers onto ghost-button DropdownMenus, the project picker became the last Astryx Selector (a form-field primitive) left in the composer footer — the one toolbar row where every other control (+, permission, model, thinking) is a ghost button. #2230's commit message explicitly left this as a follow-up: "The workspace picker keeps its quiet Selector overlay until it gets its own migration."

Every symptom reported maps to that family mismatch:

  • Different weight/color — Selector field typography/ink vs Button chrome (the field was disguised by ~60 lines of product CSS clearing field state by state).
  • No tooltipSelector has no tooltip slot (refactor(composer): drop the branch picker and rebuild the project picker on Selector #2217 recorded this loss), so the current git branch only rode in the accessible name.
  • No focus ring — the disguise CSS suppressed the field's focus (box-shadow: none), while ghost buttons get the accent focus-visible ring.

Change

WorkspacePicker migrates from Selector to the same chevronless ghost-button DropdownMenu as the model chip beside it, so resting, hover, focus, disabled and tooltip chrome all derive from one Astryx Button:

  • Trigger: FolderOpen icon + current project name (or 无项目); tooltip restored (选择项目 · <branch>, branch stays read-only context); accessible name unchanged.
  • Menu rows: project list with the current value's plain check (same convention as the model/thinking menus), the relink status row for unavailable projects, and 添加项目 / 无项目 after the catalogue in normal flow — outside the scroller, so they can never be carried along by the wheel.
  • Scroll confined to the catalogue: the project rows live in their own scroll region (.maka-workspace-picker-scroll, 224px budget, overscroll-behavior: contain); the menu panel lifts Astryx's 300px cap (max-height: none; overflow: visible) so it never scrolls itself. This replaces the sticky-pinned footer from the first iteration of this PR, which moved with the panel when overscroll chained into the page behind the popover.
  • Search dies with the Selector, following the model menu's precedent: the region keeps the scroll and Astryx first-character typeahead (documented: CJK labels are arrow-traversed in practice, since IME input does not produce the keydown events typeahead listens for).
  • Pending (project switch in flight): the trigger locks with a spinner and every row disables, like the model switcher.

Also removes the now-dead Selector overlay rules, the searchPlaceholder copy, a dead ::-webkit-scrollbar block (standard scrollbar-color/scrollbar-width win in Chromium 121+) and a dead class hook; restores the choose/chooseTitle copy; updates the palette-seam and header docs.

Storybook

The existing NewChatComposer story already renders the picker (draft state); this PR adds NewChatComposerProjectPending for the uncovered pending: true state.

Testing

Two external reviews (deepseek-v4-flash, design + test angles) found no P0/P1/P2 in the design, but flagged that the two behaviors that mattered most were verified manually, not by tests. Both gaps are now closed:

  • SSR structure tests (composer-workspace-picker.test.tsx, +5 cases): the catalogue renders exactly once in its own region with the actions after it (the mid-PR duplicate-catalogue regression that broke keyboard roving would now go red); relink row; current-value check placement (selected project / no-project row); first run renders no scroll region; pending locks the trigger (aria-disabled + aria-busy) and every row, with an at-rest reverse assertion — mirroring chat-model-switcher's lock pin.

  • CSS contract extensions (workspace-picker-menu-contract.test.ts, +2 cases): the panel must lift the cap (max-height: none + overflow: visible — Astryx's cap comes with overflow-y: auto), the scroller must declare overscroll-behavior: contain, and the :not(:first-child) divider rule is pinned. Mutation-checked: dropping overscroll-behavior: contain now fails the contract (was green before).

  • @maka/ui: 419 tests pass; contract test 5/5; check-dead-css 13/13; biome format/lint clean.

  • Live Storybook checks (Playwright + computed styles): trigger and model chip report identical computed styles (color oklch(0.17 0.005 286), weight 500, 14px, radius 16px); keyboard focus shows the accent ring; the actions' rect is constant while the catalogue scrolls and after a wheel over it; keyboard roving reaches every row and stops (no wrap); tooltip renders "选择项目 · opencode/storybook-surface-coverage"; pending story shows aria-disabled + aria-busy + spinner.

…u family

The composer footer mixed two component families for one toolbar role
after #2230 moved the model and thinking pickers onto ghost-button
DropdownMenus: +/permission/model/thinking were ghost buttons, while the
project picker stayed an Astryx Selector field disguised by ~60 lines of
product CSS that cleared field chrome state by state. That is why the
chip never matched its neighbours — Selector's own label weight and ink
against the Button's, no tooltip (Selector has no such slot, so the
current git branch rode in the accessible name alone), and a focus fill
with no accent ring (#2230 kept the quiet Selector overlay until it got
its own migration; this is that migration).

The picker is now a chevronless ghost-button DropdownMenu like the model
chip beside it, so resting, hover, focus, disabled and tooltip chrome all
derive from one Button:

- Trigger: FolderOpen icon + current project (or 无项目), tooltip with
  the current branch (`选择项目 · <branch>`), accessible name unchanged.
- Rows: project list with the current value's plain check and the
  relink status on unavailable projects, then 添加项目 / 无项目 pinned to
  the menu's bottom edge as one role=group box (the CSS pinning contract
  moves from [role=listbox] to [role=menu]).
- Search dies with the Selector, matching the model menu's precedent:
  the panel keeps its 300px scroll and Astryx first-character typeahead.
- Pending (switch in flight) locks the trigger with a spinner and every
  row, like the model switcher.

Also removes the now-dead Selector overlay rules and the
searchPlaceholder copy; the palette seam comment now covers popovers,
not just value controls.
The NewChatComposer story covers the picker's resting state and its menu;
nothing covered `pending: true` — the trigger locks with a spinner and
every row disables, matching the model switcher's mid-switch treatment.
Add a variant that passes `workspacePicker.pending`.
The pinned actions were sticky inside the menu panel, which is itself the
scroller: when the wheel reached the catalogue's end, overscroll chained
into the page behind the popover and dragged the whole inline-rendered
panel — pinned actions included — with it.

Restructure instead of re-pinning: the catalogue renders in its own
scroll region (`.maka-workspace-picker-scroll`, the pre-Selector design
from before #2217) and the two actions sit after it in normal flow,
physically outside the scroller. The panel lifts Astryx's 300px cap
(max-height: none, overflow: visible) so it can never scroll on its own;
the region owns the height budget (224px of catalogue + the actions ≈
the old 300px ceiling) and `overscroll-behavior: contain` stops the
wheel from chaining past its ends. The sticky rules, the group backdrop,
and the padding restoration all die with the construction they propped
up; the divider between catalogue and actions stays on the group's top
border, dropped on first run when the group is the menu's first child.

The CSS pinning contract test is rewritten to pin the new construction:
exactly one scroller under the menu (the catalogue region, with the
height budget), no sticky anywhere, no backdrop painted on the group or
rows, and the window cap intact.

Verified in Storybook: the actions' rect is constant while the region
scrolls and after a wheel over it; keyboard roving reaches the actions
(they were unreachable while the menu accidentally rendered two copies
of the catalogue during this refactor — the single-copy tree roves
maka-agent → … → 添加项目 → 无项目 and stops).
Follow-up from two external reviews of #2287: the behaviors that mattered
most were verified manually, not by tests. Pin them.

- SSR structure tests: the catalogue renders exactly once, inside its own
  scroll region with the actions after it (the mid-PR duplicate-catalogue
  regression that made keyboard roving unreachable would now go red); the
  unavailable project's relink row; the current-value check on the
  selected project and on the no-project row; first run renders no scroll
  region at all.
- SSR lock test mirroring chat-model-switcher: pending locks the trigger
  (aria-disabled + aria-busy) and every menu row — an aria-disabled
  trigger still opens on ArrowDown, so the lock must ride on the items.
  Reverse at-rest assertion included.
- CSS contract: the panel "may lift" the 300px cap is now "must lift"
  (max-height: none + overflow: visible are required — Astryx's cap comes
  with overflow-y: auto, which scrolls the actions with the panel again),
  the scroller must declare overscroll-behavior: contain (the chaining
  fix), and the :not(:first-child) divider rule is pinned. Mutation
  check: dropping overscroll-behavior: contain now fails the contract
  (was green before).

Also trims what the design review found: the stale header doc (panel
"keeps its 300px scroll" — it no longer scrolls at all; typeahead is
now described accurately for CJK labels), Check aligned to the family's
size 14, the dead .maka-workspace-picker-actions class, and the
::-webkit-scrollbar block that standard scrollbar-color/width override
in Chromium 121+.
@Astro-Han

Copy link
Copy Markdown
Contributor Author

External review follow-up (2× deepseek-v4-flash): design review found no P0/P1/P2 — agreed no shared QuietFooterMenu abstraction, search→typeahead tradeoff defensible; test review found the load-bearing CSS and the pending lock unpinned (P2-1/P2-2/P2-3, mutation-verified). All accepted findings fixed in c658415 (structure/lock SSR tests + contract must-lift/overscroll/divider assertions + doc/trim cleanups), mutation-checked: the contract now goes red when overscroll-behavior: contain is dropped.

@Astro-Han
Astro-Han marked this pull request as ready for review August 5, 2026 19:40
@Astro-Han
Astro-Han merged commit 120769f into main Aug 5, 2026
12 checks passed
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