fix(ui): keep composer model/thinking pickers mounted mid-turn and unify them as ghost menus - #2230
Merged
Merged
Conversation
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
force-pushed
the
fix/composer-model-thinking-toolbar
branch
from
August 5, 2026 12:20
11e4c80 to
98b1187
Compare
Astro-Han
marked this pull request as ready for review
August 5, 2026 12:24
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+.
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.
What
Fix — the composer unmounted the model + thinking pair while a turn was in flight (
!props.streaminggate, #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.
role=groupsection per connection + provider marks; the current value wears a plain check — no radio rows, keeping the quiet footer's sm density.ModelPickerwith 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:
isDisabled, not just the trigger. Upstream note: Astryx DropdownMenu'shandleButtonKeyDownshould consultbutton.isDisabled.thinkingDisabled*copy; each locked control explains the lock in its own words..maka-composer-quiet-menugetsmax-width: min(320px, 92vw).chat-model-switcherSSR test pinsrole=groupheadings, the current-check, the unknown-current leading row, and the row-level lock.data-streamingattributes.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)floating-layers+send-messagegreen on real Electron