Skip to content

fix(ui): keep composer model/thinking pickers mounted mid-turn and unify them as ghost menus - #2230

Merged
Astro-Han merged 2 commits into
mainfrom
fix/composer-model-thinking-toolbar
Aug 5, 2026
Merged

fix(ui): keep composer model/thinking pickers mounted mid-turn and unify them as ghost menus#2230
Astro-Han merged 2 commits into
mainfrom
fix/composer-model-thinking-toolbar

Conversation

@Astro-Han

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

Copy link
Copy Markdown
Contributor

What

Fix — the composer unmounted the model + thinking pair while a turn was in flight (!props.streaming gate, #1835), so the footer row reflowed on every turn start/end, and the disabled-reason path built for exactly that state sat unreachable. The pair now stays mounted mid-turn, locked with an explanatory tooltip — the same treatment the + menu and permission control already had by design (#1444).

Refactor — the pair moves from Astryx Selector form fields (disguised by ~60 lines of product CSS clearing field chrome state by state) onto ghost-button DropdownMenus, the same toolbar primitive as +/permission, so resting/hover/focus/disabled chrome derives from one Button. This is also why the two controls never matched their neighbours when tabbing through the footer.

  • Model menu keeps one role=group section per connection + provider marks; the current value wears a plain check — no radio rows, keeping the quiet footer's sm density.
  • Menu search is dropped; the panel keeps its built-in 300px scroll and Astryx typeahead.
  • The Selector-based ModelPicker with search stays for Settings forms; the workspace picker keeps its quiet-Selector overlay until its own migration.

External review round (deepseek-v4-flash × 3 lenses, adjudicated)

Three independent review passes (architecture/minimality, defect hunting, test quality) — no P0; findings and their resolution:

  • P1 — mid-turn lock bypassable by keyboard (an aria-disabled DropdownMenu trigger still opens on ArrowDown; items had no guard). Fixed: the lock now rides on every menu row via item-level isDisabled, not just the trigger. Upstream note: Astryx DropdownMenu's handleButtonKeyDown should consult button.isDisabled.
  • P2 — thinking lock reason unreachable (tooltip showed the action copy while locked). Fixed: new thinkingDisabled* copy; each locked control explains the lock in its own words.
  • P2 — menu popover lost its width ceiling (the Selector's 260px option cap died with it). Fixed: .maka-composer-quiet-menu gets max-width: min(320px, 92vw).
  • P1/P2 — the new streaming test was vacuous (fixture stacked three independent lock sources, so it passed with the streaming branch deleted) and pinned the derived attribute, not the contract. Fixed: streaming is now the only lock source in the fixture, the test asserts the reason copy plus an at-rest reverse assertion, and the fix is verified by mutation (deleting the streaming branch turns it red).
  • P2 — group markup had zero coverage. Fixed: new chat-model-switcher SSR test pins role=group headings, the current-check, the unknown-current leading row, and the row-level lock.
  • P3 batch: deleted the orphaned 260px trigger rule (the scoped 220px rule always won), dead regex alternatives, stale chevron/Selector comments, and the never-read data-streaming attributes.
  • Recorded, not code-changed: menu accessible name derives from the trigger label (Astryx design); transient aria-label swap during isLoading (Astryx behavior); the new-chat empty-state no longer renders the no-op "选择模型" leading row (intentional cleanup).

Validation

  • @maka/ui: 348 tests green (mid-turn regression + new chat-model-switcher suite)
  • desktop typecheck, biome format/lint, dead-css check green
  • E2E floating-layers + send-message green on real Electron
  • Live-app evidence captured: footer resting, model menu open (groups + check + scroll), mid-turn with the pair still mounted (dimmed)

The composer unmounted the model + thinking pair while a turn was in
flight (`!props.streaming` gate, #1835), so the footer row reflowed on
every turn start/end and the disabled-reason path built for exactly
this state (switchDisabledStreaming copy + Selector isDisabled) sat
unreachable. The + menu and permission control stay reachable mid-turn
by design (#1444); the pair now follows the same rule — mounted, locked
with an explanatory tooltip.
…u toolbar family

The composer footer mixed two component families for one toolbar role:
+/permission/modes were ghost icon buttons, while model/thinking were
Astryx Selector form fields disguised by ~60 lines of product CSS that
cleared field chrome state by state — which is why the pair never
matched its neighbours (chevron, pill focus fill vs accent outline
ring) and why mid-turn it got a cruder treatment (unmount) than the
disabled-with-reason every toolbar member deserved.

Model and thinking pickers are now ghost-button DropdownMenus — the
same primitive as the + and permission controls beside them, so
resting/hover/focus/disabled chrome derives from one Button:

- Model menu: one role=group section per connection (headings kept),
  provider marks on rows, the current value wears a plain check — no
  radio rows, keeping the quiet footer's sm density. Menu search is
  dropped; the panel keeps its 300px scroll and Astryx typeahead.
- Thinking menu: same ghost trigger + checked current item.
- Locked mid-turn via aria-disabled + a reason tooltip in each
  control's own words (new thinkingDisabled* copy; the trigger stays
  focusable so the reason is keyboard/SR-discoverable). The lock also
  rides on every menu row, not just the trigger: an aria-disabled
  Astryx DropdownMenu trigger still opens on ArrowDown, and only
  item-level isDisabled closes that keyboard path.
- The menu panel gains a max-width cap (the Selector's 260px option
  cap died with it); the Selector-based ModelPicker with search stays
  for Settings forms.

Dead with the disguise: the four-state transparent-chrome overrides
for the migrated triggers, the data-pending wrapper rules (Button
isLoading owns pending now), the orphaned 260px trigger rule (the
scoped 220px rule in composer.css always won), and the never-read
data-streaming attributes. The workspace picker keeps its quiet
Selector overlay until it gets its own migration.

Tests: the mid-turn regression test now isolates streaming as the
only lock source (resting session + in-catalog current model) and
asserts the reason copy, with a reverse at-rest assertion — verified
to go red when the streaming branch is removed. A new
chat-model-switcher SSR test pins the group markup, the current-check,
the unknown-current leading row, and the row-level lock.
@Astro-Han
Astro-Han force-pushed the fix/composer-model-thinking-toolbar branch from 11e4c80 to 98b1187 Compare August 5, 2026 12:20
@Astro-Han
Astro-Han marked this pull request as ready for review August 5, 2026 12:24
@Astro-Han
Astro-Han merged commit 0cc00dd into main Aug 5, 2026
12 checks passed
cat0825 added a commit to cat0825/maka-agent that referenced this pull request Aug 5, 2026
Keep both sides of the composer.tsx import block: upstream's
WorkspacePicker/ComposerDraftPersistence additions and our
stripQuoteHeadingMarkers import.
Astro-Han added a commit that referenced this pull request Aug 5, 2026
…nu family (#2287)

* refactor(ui): move the workspace picker onto the composer's ghost-menu 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.

* test(storybook): cover the project picker's pending state

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`.

* fix(ui): confine the project menu's scroll to the catalogue rows

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).

* test(ui): pin the picker's menu structure, lock, and load-bearing CSS

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+.
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