You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/17 14:14:49 UTC
(maka) branch main updated: feat(ui): restore list pickers for model and thinking selection (#5444)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new cd93f13da7 feat(ui): restore list pickers for model and thinking selection (#5444)
cd93f13da7 is described below
commit cd93f13da78d3f8aa7f480f509bf80b8bd024cec
Author: AstroHan <le...@outlook.com>
AuthorDate: Thu Sep 17 22:14:43 2026 +0800
feat(ui): restore list pickers for model and thinking selection (#5444)
Model catalogs grew past what the magnetic wheel could scan. Full-size surfaces (composer, new chat, settings, thinking levels) now share a searchable grouped Selector list with tail-visible truncation; the collapsed WorkHub keeps the wheel for models and gets a bottom sheet for thinking levels.
---
apps/desktop/e2e-budget.json | 2 +-
apps/desktop/e2e/workhub-layout.spec.ts | 10 +-
.../__tests__/session-health-recovery-flow.test.ts | 21 +-
.../renderer/features/workhub/ui/workhub-root.tsx | 2 +
.../settings/daily-review-settings-page.tsx | 16 +-
.../src/renderer/settings/provider-add-form.tsx | 25 +-
.../renderer/settings/subagent-settings-page.tsx | 36 +-
apps/desktop/src/renderer/styles/composer.css | 1 -
.../desktop/src/renderer/styles/model-switcher.css | 44 ++-
apps/desktop/src/renderer/styles/workhub.css | 15 +-
.../stories/settings/settings-pages.stories.tsx | 37 +-
apps/desktop/stories/workhub.stories.tsx | 58 +---
docs/astryx-surface-file-inventory.md | 11 +-
docs/astryx-surface-file-inventory.paths | 1 +
.../composer-model-picker-recovery.test.tsx | 89 +++--
.../src/__tests__/use-pending-selection.test.tsx | 159 +++++++++
packages/ui/src/chat-model-switcher.tsx | 384 ++++++++++++++-------
packages/ui/src/composer.tsx | 31 +-
packages/ui/src/model-picker-internals.tsx | 132 +++++++
packages/ui/src/model-picker.tsx | 96 ++++--
packages/ui/src/use-pending-selection.ts | 89 +++++
packages/ui/stories/model-picker.stories.tsx | 158 +++++----
22 files changed, 1028 insertions(+), 389 deletions(-)
diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json
index 57a96d024c..deabb281c5 100644
--- a/apps/desktop/e2e-budget.json
+++ b/apps/desktop/e2e-budget.json
@@ -71,7 +71,7 @@
},
"workhub-layout.spec.ts": {
"tests": 3,
- "electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the native Workbar menu must coexist with the live sibling WebContentsView without hiding its compositor; the compact native window grows only with the inline composer wheel and keeps its bottom anchor; opening context details routes to Main’s Workbar without resizing or docking the floating conversati [...]
+ "electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the native Workbar menu must coexist with the live sibling WebContentsView without hiding its compositor; the compact native window grows only with the inline composer wheel and keeps its bottom anchor; opening context details routes to Main’s Workbar without resizing or docking the floating conversati [...]
},
"workhub-reconstruction.spec.ts": {
"tests": 1,
diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index 31b0669ae4..b8264fd3e9 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -233,17 +233,17 @@ test('WorkHub uses its coordination model and shared attachment composer', async
await expect(editor).toHaveText('Keep this draft while folding the conversation.');
await workhub.getByRole('button', { name: /打开用量追踪|Open usage trace/ }).click();
await expect(page.getByRole('button', { name: /展开任务工作栏|Expand task workbar/ })).toBeVisible();
- const thinking = workhub.getByRole('button', { name: /思考级别|Thinking level/ });
+ const thinking = workhub.getByRole('combobox', { name: /思考级别|Thinking level/ });
await expect(thinking).toBeEnabled();
await thinking.click();
- const thinkingMenu = workhub.getByRole('menu');
- await expect(thinkingMenu).toBeVisible();
+ const thinkingSheet = workhub.getByRole('dialog');
+ await expect(thinkingSheet).toBeVisible();
await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-thinking-levels.png') });
- await expect.poll(() => thinkingMenu.evaluate((element) => {
+ await expect.poll(() => thinkingSheet.evaluate((element) => {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= innerHeight;
})).toBe(true);
- await workhub.getByRole('menuitemradio', { name: /^(高|High)$/ }).click();
+ await thinkingSheet.getByRole('option', { name: /^(高|High)$/ }).click();
await expect(thinking).toContainText(/高|High/);
await expect.poll(() => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).thinkingLevel, sessionId)).toBe('high');
await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-composer-controls.png') });
diff --git a/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts b/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts
index 874cfcbb01..9921ebac59 100644
--- a/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts
+++ b/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts
@@ -156,6 +156,14 @@ async function renderFlow(props: Parameters<typeof RecoveryFlow>[0]) {
writingMode: 'horizontal-tb',
getPropertyValue: () => '',
}) as unknown as CSSStyleDeclaration;
+ // linkedom lacks the popover API; record the calls so tests can tell an open
+ // panel from one that is mounted but hidden.
+ window.HTMLElement.prototype.showPopover = function () {
+ this.setAttribute('data-popover-open', '');
+ };
+ window.HTMLElement.prototype.hidePopover = function () {
+ this.removeAttribute('data-popover-open');
+ };
Object.assign(globalThis, {
document,
window,
@@ -190,11 +198,12 @@ test('recovery CTA opens the production Composer model picker', async () => {
onOpenSettings: assert.fail,
});
await act(() => flow.action.dispatchEvent(new flow.window.Event('click', { bubbles: true })));
- const wheel = flow.document.querySelector('.maka-model-wheel-viewport[role="listbox"]');
- assert.ok(wheel, 'the recovery action opens the shared model wheel');
- const options = wheel.querySelectorAll('[role="option"]');
+ const popup = [...flow.document.querySelectorAll<HTMLElement>('[data-popover-open]')]
+ .find((panel) => panel.querySelector('[role="option"]'));
+ assert.ok(popup, 'the recovery action opens the shared model picker');
+ const options = popup.querySelectorAll('[role="option"]');
assert.equal(options.length, 1, 'only the available exact account-and-model choice is offered');
- assert.equal(options[0]?.textContent, `${CHOICE.label}${CHOICE.connectionName}`);
+ assert.equal(options[0]?.textContent?.includes(CHOICE.label), true);
});
test('unsettled recovery reloads the catalog without opening the picker', async () => {
@@ -207,7 +216,7 @@ test('unsettled recovery reloads the catalog without opening the picker', async
});
await act(() => flow.action.dispatchEvent(new flow.window.Event('click', { bubbles: true })));
assert.equal(refreshCount, 1);
- assert.equal(Boolean(flow.document.querySelector('.maka-model-wheel-viewport')), false);
+ assert.equal(flow.document.querySelector('[data-popover-open] [role="option"]'), null);
});
test('empty recovery opens Models settings through the production route', async () => {
@@ -232,7 +241,7 @@ test('a live-turn lock disables the recovery CTA and keeps the picker closed', a
});
assert.equal(flow.action.disabled, true);
await act(() => flow.action.dispatchEvent(new flow.window.Event('click', { bubbles: true })));
- assert.equal(Boolean(flow.document.querySelector('.maka-model-wheel-viewport')), false);
+ assert.equal(flow.document.querySelector('[data-popover-open] [role="option"]'), null);
});
afterEach(async () => {
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
index f5f1d44cad..f1e1b9bd6a 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
@@ -341,6 +341,8 @@ export function WorkHubRoot() {
activeModelConnectionId={session?.llmConnectionId}
activeModelConnectionSlug={session?.llmConnectionSlug}
modelChoices={controller.choices}
+ pickerPresentation={showConversation ? 'popover' : 'wheel'}
+ pickersReadOnly={Boolean(controller.activeQuestion || controller.activeForm)}
maxInputRows={progress && !editingProgress ? 1 : showConversation ? undefined : 6}
onModelChange={controller.changeModel}
modelSwitchAvailability={controller.configuringModel ? { available: false, pending: true, reason: 'pending' } : undefined}
diff --git a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx
index 0784fca6f6..bf14388efe 100644
--- a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx
@@ -21,7 +21,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Banner } from '@astryxdesign/core';
import type { DailyReviewConfig } from '@maka/core/daily-review';
import type { LlmConnection, ProjectedLlmConnection } from '@maka/core/llm-connections';
-import { ModelWheelPicker, Switch, TextInput, useMountedRef, useUiLocale } from '@maka/ui';
+import { Selector, Switch, TextInput, useMountedRef, useUiLocale } from '@maka/ui';
import { buildCatalogDailyReviewModelOptions } from '../model-catalog-choices';
import { getDailyReviewSettingsCopy, type DailyReviewSettingsCopy } from '../locales/settings-daily-review-copy';
import { settingsActionErrorMessage } from './settings-error-copy';
@@ -176,15 +176,15 @@ export function DailyReviewSettingsPage(props: { connections: readonly Projected
<SettingsRow
label={copy.model}
description={copy.modelHelp}
- end={<ModelWheelPicker
+ end={<Selector
value={selectedModelValue}
- label={modelOptions.find((option) => option.value === selectedModelValue)?.label ?? selectedModelValue}
- ariaLabel={copy.model}
+ label={copy.model}
+ isLabelHidden
options={modelOptions}
- size="md"
- triggerClassName="settingsModelPickerTrigger"
- disabled={formDisabled || modelOptions.length === 0}
- onValueChange={(value) => patchConfig('modelKey', {
+ hasSearch
+ placement="below"
+ isDisabled={formDisabled || modelOptions.length === 0}
+ onChange={(value) => void patchConfig('modelKey', {
modelKey: value === DAILY_REVIEW_DEFAULT_MODEL_VALUE ? '' : value,
})}
/>}
diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx
index 820e8058c5..dfbfc556ae 100644
--- a/apps/desktop/src/renderer/settings/provider-add-form.tsx
+++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx
@@ -39,7 +39,7 @@ import { Collapsible } from '@astryxdesign/core/Collapsible';
import {
Button,
FormLayout,
- ModelWheelPicker,
+ Selector,
TextInput,
useMountedRef,
useUiLocale,
@@ -649,19 +649,16 @@ export function AddProviderForm(props: {
))}
</CheckboxList>
)}
- <VStack gap={1}>
- <Text weight="semibold">{copy.onboardingDefaultModel}</Text>
- <Text type="supporting" color="secondary">{copy.onboardingDefaultModelHelp}</Text>
- <ModelWheelPicker
- ariaLabel={copy.onboardingDefaultModel}
- label={selectedOptions.find((option) => option.value === managedPhase.defaultId)?.label ?? copy.onboardingSelectModel}
- options={selectedOptions}
- value={managedPhase.defaultId}
- onValueChange={(defaultId) => setManagedPhase({ ...managedPhase, defaultId })}
- disabled={busy || selectedOptions.length === 0}
- size="md"
- />
- </VStack>
+ <Selector
+ label={copy.onboardingDefaultModel}
+ description={copy.onboardingDefaultModelHelp}
+ options={selectedOptions}
+ value={managedPhase.defaultId}
+ onChange={(defaultId: string) => setManagedPhase({ ...managedPhase, defaultId })}
+ isDisabled={busy || selectedOptions.length === 0}
+ placeholder={copy.onboardingSelectModel}
+ width="100%"
+ />
<div role="status" aria-live="polite">
{busy ? <Text type="supporting">{copy.saving}</Text> : null}
</div>
diff --git a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx
index 5ee7fc695f..a2c7866ce9 100644
--- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx
@@ -53,12 +53,11 @@ import {
Button,
EmptyState,
IconButton,
- ModelWheelPicker,
Selector,
Switch,
TextArea,
TextInput,
- type ModelWheelOption,
+ type SelectorOptionData,
useToast,
useUiLocale,
} from '@maka/ui';
@@ -414,7 +413,7 @@ function SubagentPresetEditor(props: {
disabled: true,
});
}
- const modelOptions: ModelWheelOption[] = offerableModels.map((entry) => ({
+ const modelOptions: SelectorOptionData[] = offerableModels.map((entry) => ({
value: entry.id,
label: entry.displayName?.trim() || entry.id,
}));
@@ -598,21 +597,22 @@ function SubagentPresetEditor(props: {
<SettingsRow
label={copy.editor.model}
end={(
- <VStack gap={1}>
- <ModelWheelPicker
- ariaLabel={copy.editor.model}
- label={modelOptions.find((option) => option.value === draft.model)?.label ?? copy.editor.noModel}
- value={draft.model}
- options={modelOptions}
- size="md"
- triggerClassName="settingsModelPickerTrigger"
- disabled={props.isSaving || offerableModels.length === 0}
- tooltip={offerableModels.length === 0 ? copy.editor.noModel : undefined}
- onValueChange={(model) => setDraft((current) => ({ ...current, model, thinkingLevel: '' }))}
- />
- {submitted && validConnection && !validModel
- ? <Banner status="error" title={copy.editor.invalidModel} /> : null}
- </VStack>
+ <Selector
+ label={copy.editor.model}
+ isLabelHidden
+ value={draft.model}
+ options={modelOptions}
+ hasSearch
+ width="100%"
+ isDisabled={props.isSaving || offerableModels.length === 0}
+ disabledMessage={offerableModels.length === 0 ? copy.editor.noModel : undefined}
+ // The route is two choices, so it gets two errors: an enabled
+ // connection with no model selected is the model's problem.
+ status={submitted && validConnection && !validModel
+ ? { type: 'error', message: copy.editor.invalidModel }
+ : undefined}
+ onChange={(model) => setDraft((current) => ({ ...current, model, thinkingLevel: '' }))}
+ />
)}
/>
{thinkingLevels.length > 0 ? (
diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css
index 6eb49c157b..55941cac4c 100644
--- a/apps/desktop/src/renderer/styles/composer.css
+++ b/apps/desktop/src/renderer/styles/composer.css
@@ -373,7 +373,6 @@
min-width: 100px;
max-width: min(220px, 28vw);
}
-
.maka-composer-model-chip-text {
min-width: 0;
overflow: hidden;
diff --git a/apps/desktop/src/renderer/styles/model-switcher.css b/apps/desktop/src/renderer/styles/model-switcher.css
index bb9fdc026e..f7ddf7c830 100644
--- a/apps/desktop/src/renderer/styles/model-switcher.css
+++ b/apps/desktop/src/renderer/styles/model-switcher.css
@@ -26,8 +26,13 @@
max-width: 100%;
}
-/* Model and thinking triggers share the footer's ghost-button chrome.
- The model control expands into the shared inline magnetic wheel. */
+/* The composer footer's model picker is a ghost-trigger Selector — the same
+ searchable list the settings pages use — and the thinking picker beside it
+ is the same control minus search; resting, hover, focus, and disabled
+ chrome all derive from Astryx, so no product overlay is needed here.
+ Trigger geometry (min/max width) is owned by composer.css, the one real
+ context these triggers render in. The collapsed WorkHub window is the one
+ exception: its model picker keeps the inline wheel. */
.maka-model-picker-root {
display: inline-flex;
@@ -77,3 +82,38 @@
width: 100%;
height: 100%;
}
+.modelPickerOption {
+ min-width: 0;
+}
+
+/* Selector's popover stretches to its positioning context, not its content —
+ left alone it renders far wider than the option labels. Hug the widest row
+ instead, and cap it so one long id cannot stretch the popup past the
+ viewport: at ~44 chars per line this covers p99 of models.dev ids (p90=32,
+ p99=44), and the rare path-style ids ellipsize at the start. Scoped by the
+ option class renderModelPickerOption emits. */
+.astryx-selector-popup:has(.modelPickerOption) {
+ /* min-width keeps the painted card covering the trigger-sized layer, whose
+ leftover strip would otherwise swallow clicks without dismissing. */
+ min-width: 100%;
+ width: fit-content;
+ max-width: min(340px, 92vw);
+}
+
+.modelPickerOptionLabel {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ direction: rtl;
+ text-align: left;
+}
+
+/* User-set connection names are unbounded too, but a heading is wayfinding —
+ one ellipsized line, not a tall row. */
+.astryx-selector-popup:has(.modelPickerOption) .astryx-selector-section-heading {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index 03c1d02494..3bd0cd057b 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -163,20 +163,11 @@
.workHubLive[data-placement='floating'][data-conversation-expanded='false'] .maka-model-wheel-anchor[data-open='true'] {
width: min(264px, 55vw); height: var(--maka-model-wheel-height);
}
- .workHubLive .maka-thinking-level-menu {
+ /* Selector popups stay translucent over the transparent WorkHub surface. */
+ .workHubLive .astryx-selector-popup {
background: color-mix(in oklch, var(--background-elevated) 94%, transparent);
backdrop-filter: blur(12px);
}
- /* A compact native window cannot paint the thinking menu outside its bounds.
- Keep the menu in the window and scroll its options without resizing the composer. */
- .workHubLive[data-placement='floating'][data-conversation-expanded='false'] [popover]:has(> .maka-thinking-level-menu) {
- position-area: none !important;
- inset: 4px max(4px, calc(100vw - anchor(right))) auto auto;
- margin: 0;
- }
- .workHubLive[data-placement='floating'][data-conversation-expanded='false'] .maka-thinking-level-menu {
- max-height: min(300px, calc(100dvh - 8px));
- }
.workHubComposerActions { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.workHubRevealMark { position: absolute; inset: 32px 0 calc(var(--workhub-composer-height, 96px) + 12px); display: grid; place-items: center; z-index: 3; color: var(--foreground); opacity: 0; pointer-events: none; }
.workHubRevealMark svg { filter: drop-shadow(0 2px 12px var(--background)); }
@@ -233,7 +224,7 @@
.workHubLive[data-conversation-expanded='false'] .maka-composer-editor { padding-right: 24px; }
.workHubLive[data-conversation-expanded='false'] .maka-composer-editor [contenteditable='true'] { padding-block: 6px; overscroll-behavior-y: contain; scrollbar-gutter: stable; scrollbar-width: thin; scroll-padding-block: 6px; }
.workHubLive[data-conversation-expanded='false'] .workHubComposerSurface { position: relative; -webkit-app-region: drag; }
- .workHubLive[data-conversation-expanded='false'] .workHubComposerSurface :is(button, input, textarea, [contenteditable='true'], [role='listbox'], [role='option'], a) { -webkit-app-region: no-drag; }
+ .workHubLive[data-conversation-expanded='false'] .workHubComposerSurface :is(button, input, textarea, [contenteditable='true'], [role='listbox'], [role='option'], dialog, a) { -webkit-app-region: no-drag; }
.workHubExpandButton { position: absolute; top: 6px; right: 8px; z-index: 2; color: var(--muted-foreground); -webkit-app-region: no-drag; }
html:has(.workHubLive), body:has(.workHubLive), #root:has(.workHubLive) { background: transparent; }
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index 289d46bd36..034bf29df8 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -1939,6 +1939,13 @@ function focusedRowOutline() {
return { outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth };
}
+function fieldChrome(element: HTMLElement) {
+ const field = element.parentElement;
+ if (!field) throw new Error('Settings field chrome is missing');
+ const style = getComputedStyle(field);
+ return `${style.borderColor} | ${style.boxShadow}`;
+}
+
/**
* The provider has to sit above the body: 已归档任务's story bridge confirms
* through the same toast surface the shell's row action uses, and a hook cannot
@@ -2059,7 +2066,7 @@ async function openDailyReviewModelSelector(canvasElement: HTMLElement): Promise
);
await userEvent.click(selector);
await waitForStoryCondition(
- () => canvasElement.querySelector('.maka-model-wheel-viewport') !== null,
+ () => document.querySelector('[popover]:popover-open [role="listbox"]') !== null,
'Daily Review model selector did not open',
);
return selector;
@@ -2125,7 +2132,7 @@ export const General: Story = {
decorators: [withSettingsBridge],
render: () => <SettingsStory section="general" />,
};
-// Real path: 设置 → 通用 → 默认模型. Focus stays on the floating magnetic wheel;
+// Real path: 设置 → 通用 → 默认模型. Focus moves into the open Selector popup;
// the containing settings row must not add a second focus ring.
export const GeneralPickerOpenFocusRing: Story = {
decorators: [withSettingsBridge],
@@ -2134,29 +2141,22 @@ export const GeneralPickerOpenFocusRing: Story = {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole('button', { name: '默认模型' });
trigger.scrollIntoView({ block: 'center' });
- const rows = () => Array.from(canvasElement.querySelectorAll('.astryx-item')).map((element) => {
- const { x, y, width, height } = element.getBoundingClientRect();
- return { x, y, width, height };
- });
- const before = rows();
await userEvent.click(trigger);
await waitFor(() => {
const active = document.activeElement as HTMLElement | null;
- expect(active?.matches('.maka-model-wheel-viewport')).toBe(true);
+ expect(document.querySelector('[popover]:popover-open')).not.toBeNull();
+ expect(active?.closest('[popover]:popover-open')).not.toBeNull();
});
const active = document.activeElement as HTMLElement;
const row = active.closest<HTMLElement>('.astryx-item');
expect(row).not.toBeNull();
expect(row ? getComputedStyle(row).outlineStyle : null).toBe('none');
- expect(rows()).toEqual(before);
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(trigger).toHaveFocus());
- expect(rows()).toEqual(before);
- await userEvent.click(trigger);
},
};
-// Real path: keyboard navigation through 设置 → 通用. The model button carries the
+// Real path: keyboard navigation through 设置 → 通用. The field carries the
// visible focus treatment; its containing Item does not add a second ring.
export const GeneralKeyboardFocusRing: Story = {
decorators: [withSettingsBridge],
@@ -2165,19 +2165,16 @@ export const GeneralKeyboardFocusRing: Story = {
const canvas = within(canvasElement);
const tone = await canvas.findByRole('textbox', { name: '助手语气偏好' });
const trigger = canvas.getByRole('button', { name: '默认模型' });
+ const resting = fieldChrome(trigger);
tone.focus();
await tabTo(trigger);
expect(focusedRowOutline()?.outlineStyle).toBe('none');
- await waitFor(() => {
- const style = getComputedStyle(trigger);
- expect(style.outlineStyle).toBe('solid');
- expect(Number.parseFloat(style.outlineWidth)).toBeGreaterThan(0);
- });
+ await waitFor(() => expect(fieldChrome(trigger)).not.toBe(resting));
},
};
// Real path: Windows High Contrast keyboard navigation through 设置 → 通用.
-// The model button's outline survives, and the Item retains its shared fallback.
+// The field loses its own paint there, so the Item retains the focus ring.
export const GeneralForcedColorsFocusRing: Story = {
decorators: [withSettingsBridge],
render: () => <SettingsStory section="general" />,
@@ -2185,10 +2182,10 @@ export const GeneralForcedColorsFocusRing: Story = {
const canvas = within(canvasElement);
const tone = await canvas.findByRole('textbox', { name: '助手语气偏好' });
const trigger = canvas.getByRole('button', { name: '默认模型' });
+ const resting = fieldChrome(trigger);
tone.focus();
await tabTo(trigger);
- expect(getComputedStyle(trigger).outlineStyle).toBe('solid');
- expect(Number.parseFloat(getComputedStyle(trigger).outlineWidth)).toBeGreaterThan(0);
+ expect(fieldChrome(trigger)).toBe(resting);
expect(focusedRowOutline()?.outlineStyle).toBe('solid');
},
};
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx
index 5bf27eaf0a..3ea802fd81 100644
--- a/apps/desktop/stories/workhub.stories.tsx
+++ b/apps/desktop/stories/workhub.stories.tsx
@@ -211,7 +211,7 @@ export const UsageInspector: Story = {
expect(editor).toHaveTextContent('再检查一下并发回调。');
},
};
-// Real path: the docked WorkHub composer opens its model wheel before sending.
+// Real path: the docked WorkHub composer opens its model picker before sending.
export const StandardComposer: Story = {
render: () => <Surface />,
play: async ({ canvasElement }) => {
@@ -219,43 +219,9 @@ export const StandardComposer: Story = {
const canvas = within(canvasElement); const page = within(canvasElement.ownerDocument.body);
await waitFor(() => expect(canvas.getByRole('button', { name: /切换当前任务模型/ })).toBeEnabled());
await waitFor(() => expect(canvas.getByRole('button', { name: '打开用量追踪' }).textContent).toContain('1%'));
- const trigger = canvas.getByRole('button', { name: /切换当前任务模型/ });
- const layout = () => Array.from(canvasElement.querySelectorAll('.maka-composer-editor, .maka-composer button')).map((element) => {
- const { x, y, width, height } = element.getBoundingClientRect();
- return { x, y, width, height };
- });
- const before = layout();
- const anchor = trigger.getBoundingClientRect();
- await userEvent.click(trigger);
- const wheel = canvas.getByRole('listbox', { name: /切换当前任务模型/ });
- await waitFor(() => expect(wheel).toHaveFocus());
- expect(layout()).toEqual(before);
- expect(trigger).not.toBeVisible();
- const expanded = wheel.getBoundingClientRect();
- const center = Math.max(expanded.height / 2, Math.min(innerHeight - expanded.height / 2, (anchor.top + anchor.bottom) / 2));
- expect(Math.abs((expanded.top + expanded.bottom) / 2 - center)).toBeLessThanOrEqual(1);
- await expect(within(wheel).getByRole('option', { name: /model-a/, selected: true })).toBeInTheDocument();
- await userEvent.keyboard('{End}');
+ await userEvent.click(canvas.getByRole('button', { name: /切换当前任务模型/ }));
+ await userEvent.click(page.getByRole('option', { name: /model-b/ }));
await waitFor(() => expect(writes.model).toHaveBeenCalledWith(sessionId, expect.objectContaining({ expectedRevision: 1, modelTarget: expect.objectContaining({ model: 'model-b' }) })));
- await waitFor(() => expect(within(wheel).getByRole('option', { name: /model-b/, selected: true })).toBeInTheDocument());
- expect(layout()).toEqual(before);
- await waitFor(() => {
- const selected = wheel.querySelector('[aria-selected="true"]')!.getBoundingClientRect();
- const viewport = wheel.getBoundingClientRect();
- expect(Math.abs((selected.top + selected.bottom - viewport.top - viewport.bottom) / 2)).toBeLessThanOrEqual(1);
- });
- const activeLabel = wheel.querySelector('[data-active="true"] .maka-model-wheel-label')!;
- const otherLabel = wheel.querySelector('[data-active="false"] .maka-model-wheel-label')!;
- await waitFor(() => expect(new DOMMatrix(getComputedStyle(activeLabel).transform).a).toBeGreaterThan(new DOMMatrix(getComputedStyle(otherLabel).transform).a));
- expect(getComputedStyle(wheel).outlineStyle).toBe('solid');
- await userEvent.keyboard('{Escape}');
- await waitFor(() => expect(trigger).toHaveFocus());
- expect(layout()).toEqual(before);
- await userEvent.click(trigger);
- await waitFor(() => expect(canvas.getByRole('listbox')).toHaveFocus());
- await userEvent.tab();
- await waitFor(() => expect(canvas.queryByRole('listbox')).not.toBeInTheDocument());
- expect(canvasElement.ownerDocument.activeElement).not.toBe(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole('button', { name: '添加上下文' }));
await userEvent.click(page.getByRole('menuitem', { name: /添加文件/ }));
const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement;
@@ -270,19 +236,19 @@ export const ThinkingLevelPicker: Story = {
play: async ({ canvasElement }) => {
Object.values(writes).forEach((spy) => spy.mockClear());
const canvas = within(canvasElement); const page = within(canvasElement.ownerDocument.body);
- await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 默认' })).toBeEnabled());
+ await waitFor(() => expect(canvas.getByRole('combobox', { name: '思考级别: 默认' })).toBeEnabled());
const usage = canvas.getByRole('button', { name: '打开用量追踪' });
await waitFor(() => expect(usage.textContent).toContain('1%'));
- await userEvent.click(canvas.getByRole('button', { name: '思考级别: 默认' }));
- await userEvent.click(page.getByRole('menuitemradio', { name: /^高$/ }));
- await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 高' })).toBeEnabled());
+ await userEvent.click(canvas.getByRole('combobox', { name: '思考级别: 默认' }));
+ await userEvent.click(page.getByRole('option', { name: /^高$/ }));
+ await waitFor(() => expect(canvas.getByRole('combobox', { name: '思考级别: 高' })).toBeEnabled());
await expect(writes.model).toHaveBeenCalledWith(sessionId, expect.objectContaining({ thinkingLevel: 'high' }));
- await userEvent.click(canvas.getByRole('button', { name: '思考级别: 高' }));
- await userEvent.click(page.getByRole('menuitemradio', { name: /^默认$/ }));
- await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 默认' })).toBeEnabled());
+ await userEvent.click(canvas.getByRole('combobox', { name: '思考级别: 高' }));
+ await userEvent.click(page.getByRole('option', { name: /^默认$/ }));
+ await waitFor(() => expect(canvas.getByRole('combobox', { name: '思考级别: 默认' })).toBeEnabled());
await expect(writes.model).toHaveBeenLastCalledWith(sessionId, expect.objectContaining({ expectedRevision: 2, thinkingLevel: null }));
- await userEvent.click(canvas.getByRole('button', { name: '思考级别: 默认' }));
- await expect(page.getByRole('menuitemradio', { name: /^默认$/ })).toHaveAttribute('aria-checked', 'true');
+ await userEvent.click(canvas.getByRole('combobox', { name: '思考级别: 默认' }));
+ await expect(page.getByRole('option', { name: /^默认$/ })).toHaveAttribute('aria-selected', 'true');
},
};
// Real path: a floating WorkHub progress card → edit its composer → open the model picker.
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index f920543174..58e2832107 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.1` (195 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 282 files — blocker 0, reimplementation 0, polish 4, aligned 278.
+**Totals:** 283 files — blocker 0, reimplementation 0, polish 4, aligned 279.
## Exclusions (explicit)
@@ -144,7 +144,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx` | settings-page | Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner) | aligned |
| `apps/desktop/src/renderer/settings/bot-wechat-login.tsx` | settings-module | Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, FormLayout, Layout, LayoutContent, Spinner, TextInput | aligned — uses Astryx (Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, FormLayout, Layout) | aligned |
| `apps/desktop/src/renderer/settings/custom-pet-settings-section.tsx` | settings-module | Badge, Button, EmptyState | aligned — uses Astryx (Badge, Button, EmptyState) | aligned |
-| `apps/desktop/src/renderer/settings/daily-review-settings-page.tsx` | settings-page | Banner, Switch, TextInput | aligned — uses Astryx (Banner, Switch, TextInput) | aligned |
+| `apps/desktop/src/renderer/settings/daily-review-settings-page.tsx` | settings-page | Banner, Selector, Switch, TextInput | aligned — uses Astryx (Banner, Selector, Switch, TextInput) | aligned |
| `apps/desktop/src/renderer/settings/data-settings-page.tsx` | settings-page | Banner, Button, Selector, Switch | aligned — uses Astryx (Banner, Button, Selector, Switch) | aligned |
| `apps/desktop/src/renderer/settings/general-settings-page.tsx` | settings-page | Banner, Button, FormLayout, NumberInput, Selector, Switch, TextInput | aligned — uses Astryx (Banner, Button, FormLayout, NumberInput, Selector, Switch, TextInput) | aligned |
| `apps/desktop/src/renderer/settings/health-center-page.tsx` | settings-page | Banner, Button, StatusDot, Text, VStack | aligned — uses Astryx (Banner, Button, StatusDot, Text, VStack) | aligned |
@@ -156,7 +156,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/settings/permission-center-page.tsx` | settings-page | Banner, Button, Collapsible, CollapsibleGroup, HStack, List, ListItem, MetadataList, MetadataListItem, StatusDot, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, HStack, List, ListItem, MetadataList) | aligned |
| `apps/desktop/src/renderer/settings/personalization-settings-section.tsx` | settings-module | Selector, TextArea, TextInput | aligned — uses Astryx (Selector, TextArea, TextInput) | aligned |
| `apps/desktop/src/renderer/settings/projects-settings-page.tsx` | settings-page | Badge, Banner, Button, EmptyState, HStack, List, ListItem, MoreMenu, TextInput | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, List, ListItem, MoreMenu) | aligned |
-| `apps/desktop/src/renderer/settings/provider-add-form.tsx` | settings-module | Banner, Button, CheckboxList, CheckboxListItem, Collapsible, EmptyState, FormLayout, HStack, Step, Stepper, Text, TextInput, VStack | aligned — uses Astryx (Banner, Button, CheckboxList, CheckboxListItem, Collapsible, EmptyState, FormLayout, HStack) | aligned |
+| `apps/desktop/src/renderer/settings/provider-add-form.tsx` | settings-module | Banner, Button, CheckboxList, CheckboxListItem, Collapsible, EmptyState, FormLayout, HStack, Selector, Step, Stepper, Text, TextInput, VStack | aligned — uses Astryx (Banner, Button, CheckboxList, CheckboxListItem, Collapsible, EmptyState, FormLayout, HStack) | aligned |
| `apps/desktop/src/renderer/settings/provider-brand-marks.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/settings/provider-catalog-page.tsx` | settings-page | Banner, Button, EmptyState, HStack, Heading, List, ListItem, TextInput, VStack | aligned — uses Astryx (Banner, Button, EmptyState, HStack, Heading, List, ListItem, TextInput) | aligned |
| `apps/desktop/src/renderer/settings/provider-connection-detail.tsx` | settings-module | Badge, Banner, Button, HStack, Icon, IconButton, Link, Switch, Text, TextInput, Token, VStack | aligned — uses Astryx (Badge, Banner, Button, HStack, Icon, IconButton, Link, Switch) | aligned |
@@ -252,7 +252,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/bot-brand-logo.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/capability-audit-strip.tsx` | ui-composition | Banner | aligned — uses Astryx (Banner) | aligned |
| `packages/ui/src/chat-empty-hero.tsx` | shell-chrome-or-panel | Item | aligned — uses Astryx (Item) | aligned |
-| `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem) | aligned |
+| `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, Selector | aligned — uses Astryx (Button, Selector) | aligned |
| `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned |
| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned |
| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner) | aligned |
@@ -273,7 +273,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/markdown-math.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/markdown.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/mermaid-diagram.tsx` | ui-composition | Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar | aligned — uses Astryx (Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar) | aligned |
-| `packages/ui/src/model-picker.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `packages/ui/src/model-picker-internals.tsx` | ui-composition | SelectorOption | aligned — uses Astryx (SelectorOption) | aligned |
+| `packages/ui/src/model-picker.tsx` | ui-composition | Selector | aligned — uses Astryx (Selector) | aligned |
| `packages/ui/src/model-wheel-picker.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned |
| `packages/ui/src/module-hub-selector.tsx` | ui-composition | Tab, TabList | aligned — uses Astryx (Tab, TabList) | aligned |
| `packages/ui/src/module-pages.tsx` | ui-composition | EmptyState, Spinner | aligned — uses Astryx (EmptyState, Spinner) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index b27218c2ff..a31dc3e89b 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -244,6 +244,7 @@ packages/ui/src/markdown-body.tsx
packages/ui/src/markdown-math.tsx
packages/ui/src/markdown.tsx
packages/ui/src/mermaid-diagram.tsx
+packages/ui/src/model-picker-internals.tsx
packages/ui/src/model-picker.tsx
packages/ui/src/model-wheel-picker.tsx
packages/ui/src/module-hub-selector.tsx
diff --git a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
index 16d929f2ae..05bd362f04 100644
--- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
+++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
@@ -25,6 +25,7 @@ import { parseHTML } from 'linkedom';
import type { ChatModelChoice } from '@maka/core/chat-model-choice';
import type { SessionSummary } from '@maka/core/session';
import { Composer, type ComposerHandle } from '../composer.js';
+import { ThinkingLevelSelector } from '../chat-model-switcher.js';
import { deriveComposerModelSwitchAvailability } from '../composer-helpers.js';
import { LocaleProvider } from '../locale-context.js';
@@ -67,13 +68,15 @@ test('the recovery handle opens the existing exact account-and-model picker', as
}).IS_REACT_ACT_ENVIRONMENT,
};
const { document, window } = parseHTML('<div id="root"></div>');
- window.getComputedStyle = () => ({
- direction: 'ltr',
- writingMode: 'horizontal-tb',
- getPropertyValue: () => '',
- }) as unknown as CSSStyleDeclaration;
+ window.getComputedStyle = () =>
+ new Proxy(
+ { direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' },
+ { get: (target, key) => (key in target ? target[key as keyof typeof target] : '') },
+ ) as unknown as CSSStyleDeclaration;
window.matchMedia = () =>
({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList;
+ window.scrollTo = () => {};
+ window.scrollBy = () => {};
window.getSelection = () =>
({
rangeCount: 0,
@@ -94,6 +97,13 @@ test('the recovery handle opens the existing exact account-and-model picker', as
return this;
},
}) as unknown as Range;
+ // linkedom has no <dialog> behavior: the bottom sheet needs showModal/close
+ // to mount its panel, so the stubs only toggle the `open` attribute.
+ Object.assign(window.HTMLElement.prototype, {
+ showModal(this: HTMLElement) { this.setAttribute('open', ''); },
+ show(this: HTMLElement) { this.setAttribute('open', ''); },
+ close(this: HTMLElement) { this.removeAttribute('open'); },
+ });
Object.assign(globalThis, {
document,
window,
@@ -147,13 +157,13 @@ test('the recovery handle opens the existing exact account-and-model picker', as
/>
</LocaleProvider>,
));
- assert.match(document.documentElement.innerHTML, /aria-expanded="false"/);
+ assert.match(document.documentElement.innerHTML, /aria-expanded="false"[^>]*aria-haspopup="listbox"/);
await act(() => composer.current?.openModelPicker());
- assert.ok(document.querySelector('.maka-model-wheel-viewport'));
+ assert.match(document.documentElement.innerHTML, /aria-expanded="true"[^>]*aria-haspopup="listbox"/);
assert.match(document.documentElement.innerHTML, /GPT-5/);
- const items = [...document.querySelectorAll<HTMLElement>('.maka-model-wheel-viewport [role="option"]')];
+ const items = [...document.querySelectorAll<HTMLElement>('[role="option"]')];
assert.equal(items.length, 1, 'the stale legacy target is not a selectable current row');
await act(() => items[0]?.dispatchEvent(new window.Event('click', { bubbles: true })));
@@ -163,8 +173,7 @@ test('the recovery handle opens the existing exact account-and-model picker', as
llmConnectionSlug: 'openrouter',
model: 'openai/gpt-5',
});
- await act(() => document.querySelector('.maka-model-wheel-viewport')?.dispatchEvent(Object.assign(new window.Event('keydown', { bubbles: true }), { key: 'Escape' })));
- assert.equal(Boolean(document.querySelector('.maka-model-wheel-viewport')), false, 'Escape closes the wheel');
+ assert.match(document.documentElement.innerHTML, /aria-expanded="false"[^>]*aria-haspopup="listbox"/);
await act(() => root.render(
<LocaleProvider locale="en">
@@ -188,12 +197,12 @@ test('the recovery handle opens the existing exact account-and-model picker', as
));
await act(() => composer.current?.openModelPicker());
- const selectedRadio = document.querySelector<HTMLElement>(
- '.maka-model-wheel-viewport [role="option"][aria-selected="true"]',
+ const selectedOption = document.querySelector<HTMLElement>(
+ '[role="option"][aria-selected="true"]',
);
- assert.equal(selectedRadio?.textContent?.includes('GPT-5'), true);
+ assert.equal(selectedOption?.textContent?.includes('GPT-5'), true);
assert.match(
- document.querySelector<HTMLElement>('.maka-model-wheel-viewport')?.getAttribute('aria-label') ?? '',
+ document.querySelector<HTMLElement>('.maka-model-switcher-trigger')?.textContent ?? '',
/GPT-5/,
);
@@ -203,26 +212,52 @@ test('the recovery handle opens the existing exact account-and-model picker', as
await act(() => root.render(
<LocaleProvider locale="en"><Composer ref={composer}
activeSession={{ id: 'wheel-session', llmConnectionId: choice.connectionId, llmConnectionSlug: choice.connectionSlug, model: choice.model } as SessionSummary}
- modelChoices={[choice, second]}
+ pickerPresentation="bottom-sheet" modelChoices={[choice, second]}
onModelChange={(input) => { selected = input; }} onSend={() => { sends++; }} onStop={() => undefined} />
</LocaleProvider>,
));
await act(() => composer.current?.openModelPicker());
- const browseNext = async () => {
- const wheel = document.querySelector<HTMLElement>('.maka-model-wheel-viewport');
- assert.ok(wheel);
- await act(() => wheel.dispatchEvent(Object.assign(new window.Event('keydown', { bubbles: true, cancelable: true }), { key: 'ArrowDown' })));
- await act(() => wheel.dispatchEvent(new window.Event('scroll')));
- return wheel;
- };
- const wheel = await browseNext();
- assert.deepEqual(selected, { llmConnectionId: second.connectionId, llmConnectionSlug: second.connectionSlug, model: second.model }, 'keyboard navigation applies the model without confirmation');
- assert.ok(document.querySelector('.maka-model-wheel-viewport'), 'selection keeps the wheel open');
- await act(() => wheel.dispatchEvent(Object.assign(new window.Event('keydown', { bubbles: true, cancelable: true }), { key: 'Enter' })));
+ const sheetOptions = [...document.querySelectorAll<HTMLElement>('[role="option"]')];
+ assert.equal(sheetOptions.length, 2, 'the sheet lists both accounts');
+ const secondOption = sheetOptions[1];
+ await act(() => secondOption.dispatchEvent(new window.Event('click', { bubbles: true })));
+ assert.deepEqual(selected, { llmConnectionId: second.connectionId, llmConnectionSlug: second.connectionSlug, model: second.model });
assert.equal(sends, 0, 'closing the picker must not send the composer draft');
- assert.equal(Boolean(document.querySelector('.maka-model-wheel-viewport')), false);
} finally {
await act(() => root.unmount());
Object.assign(globalThis, original);
}
});
+
+test('the thinking picker survives levels arriving after mount', async () => {
+ // Thinking levels resolve asynchronously; a picker that mounts variantless
+ // must not change its hook count when they land.
+ const { document, window } = parseHTML('<div id="root"></div>');
+ window.matchMedia = () =>
+ ({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList;
+ Object.assign(globalThis, {
+ document,
+ window,
+ Element: window.Element,
+ HTMLElement: window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+ try {
+ const render = (levels: ('low' | 'high')[]) =>
+ act(() => root.render(
+ <LocaleProvider locale="en"><ThinkingLevelSelector levels={levels} onChange={() => undefined} /></LocaleProvider>,
+ ));
+ await render([]);
+ await render(['low', 'high']);
+ assert.equal(
+ document.querySelector('.maka-thinking-level-selector') !== null,
+ true,
+ 'the selector mounts once variants exist',
+ );
+ } finally {
+ await act(() => root.unmount());
+ }
+});
diff --git a/packages/ui/src/__tests__/use-pending-selection.test.tsx b/packages/ui/src/__tests__/use-pending-selection.test.tsx
new file mode 100644
index 0000000000..a911685e57
--- /dev/null
+++ b/packages/ui/src/__tests__/use-pending-selection.test.tsx
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { act, createElement } from 'react';
+import { createRoot } from 'react-dom/client';
+import { parseHTML } from 'linkedom';
+import { usePendingSelection } from '../use-pending-selection.js';
+
+interface Deferred {
+ resolve(): void;
+ reject(): void;
+}
+
+interface Harness {
+ value(): string;
+ writeCount(): number;
+ pick(next: string): Promise<void>;
+ render(authoritative: string): Promise<void>;
+ settle(index?: number): Promise<void>;
+ reject(index?: number): Promise<void>;
+}
+
+const flush = () => new Promise((r) => setTimeout(r, 0));
+
+async function mount(initial: string): Promise<Harness> {
+ const { document, window } = parseHTML('<div id="root"></div>');
+ Object.assign(globalThis, {
+ document,
+ window,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const container = document.querySelector('#root');
+ assert.ok(container);
+
+ const writes: Deferred[] = [];
+ let handle: { value: string; onChange: (n: string) => void } | null = null;
+ const onValueChange = (_next: string) =>
+ new Promise<void>((resolve, reject) => {
+ writes.push({ resolve, reject });
+ });
+
+ function Host({ authoritative }: { authoritative: string }) {
+ handle = usePendingSelection(authoritative, onValueChange);
+ return null;
+ }
+
+ const root = createRoot(container as unknown as Element);
+ await act(() => {
+ root.render(createElement(Host, { authoritative: initial }));
+ });
+
+ return {
+ value: () => handle!.value,
+ writeCount: () => writes.length,
+ pick: async (next) => {
+ await act(async () => {
+ handle!.onChange(next);
+ await flush();
+ });
+ },
+ render: async (authoritative) => {
+ await act(() => {
+ root.render(createElement(Host, { authoritative }));
+ });
+ },
+ settle: async (index = writes.length - 1) => {
+ await act(async () => {
+ writes[index]!.resolve();
+ await flush();
+ });
+ },
+ reject: async (index = writes.length - 1) => {
+ await act(async () => {
+ writes[index]!.reject();
+ await flush();
+ });
+ },
+ };
+}
+
+test('a pick shows immediately, before the write settles', async () => {
+ const h = await mount('A');
+ assert.equal(h.value(), 'A');
+ await h.pick('B');
+ assert.equal(h.value(), 'B');
+});
+
+test('the pick clears to authority once the write resolves and value catches up', async () => {
+ const h = await mount('A');
+ await h.pick('B');
+ await h.render('B'); // caller's refresh lands the new authority
+ await h.settle();
+ assert.equal(h.value(), 'B');
+});
+
+test('a rejected write rolls back to the authoritative value', async () => {
+ const h = await mount('A');
+ await h.pick('B');
+ assert.equal(h.value(), 'B');
+ await h.reject();
+ assert.equal(h.value(), 'A');
+});
+
+test('the pick holds across an unrelated authority change while the write is in flight', async () => {
+ const h = await mount('A');
+ await h.pick('B');
+ // An unrelated event pushes a different authoritative value mid-write; the
+ // user's pick still shows until their own write settles.
+ await h.render('C');
+ assert.equal(h.value(), 'B');
+ await h.settle();
+ assert.equal(h.value(), 'C');
+});
+
+test('picking the displayed value again never fires a write', async () => {
+ // The wheel guarded this at the option; downstream "set model" writes carry
+ // required sibling fields (thinkingLevel) that a spurious re-write resets.
+ const h = await mount('A');
+ await h.pick('A');
+ assert.equal(h.writeCount(), 0);
+ await h.pick('B');
+ assert.equal(h.writeCount(), 1);
+ // While B's write is in flight the displayed value is B — re-picking it is a
+ // no-op, but re-picking the stale authority A is a real second write.
+ await h.pick('B');
+ assert.equal(h.writeCount(), 1);
+ await h.pick('A');
+ assert.equal(h.writeCount(), 2);
+});
+
+test('latest pick wins: a slower earlier write settling does not wipe a newer pick', async () => {
+ const h = await mount('A');
+ await h.pick('B'); // write #0
+ await h.pick('C'); // write #1 (newer)
+ assert.equal(h.value(), 'C');
+ await h.settle(0); // the older B write resolves late
+ assert.equal(h.value(), 'C'); // still C, not cleared
+ await h.render('C');
+ await h.settle(1);
+ assert.equal(h.value(), 'C');
+});
diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx
index c654985b5d..e581f8b16a 100644
--- a/packages/ui/src/chat-model-switcher.tsx
+++ b/packages/ui/src/chat-model-switcher.tsx
@@ -17,67 +17,84 @@
* under the License.
*/
-/** Session and new-chat adapters for the shared magnetic model picker. */
+/**
+ * Chat model pickers, extracted from `components.tsx`.
+ *
+ * `ChatModelSwitcher` (in-session) and `NewChatModelPicker` (home / empty
+ * state) are consumed only by the Composer and share the grouped model-choice
+ * helpers, so they form a clean seam. `index.ts` does not re-export them (they
+ * are internal to the `@maka/ui` Composer surface).
+ *
+ * Both are ghost-trigger Astryx Selectors — the same searchable single-select
+ * list the settings pages use, so every model picker in the product shares one
+ * interaction. The one exception is the collapsed WorkHub window, which keeps
+ * the inline wheel (`presentation="wheel"`) it was built for.
+ */
-import { type ReactNode, useMemo, useState } from 'react';
+import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { Button as UiButton } from '@astryxdesign/core';
-import {
- DropdownMenu,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
-} from '@astryxdesign/core/DropdownMenu';
-import { ICON_SIZE, Check, Settings } from './icons.js';
+import { Selector } from '@astryxdesign/core/Selector';
+import { ModelWheelPicker, type ModelWheelOption } from './model-wheel-picker.js';
+import { ICON_SIZE, Settings } from './icons.js';
import {
type ChatModelChoice,
- type ModelMenuGroup,
exactModelChoiceValue,
modelChoiceDescription,
modelMenuGroups,
+ type ModelMenuGroup,
} from './chat-model-helpers.js';
+import {
+ buildModelPickerOptions,
+ providerMarkIcon,
+ renderModelPickerOption,
+ renderModelPickerValue,
+} from './model-picker-internals.js';
import { type ProviderType } from '@maka/core/llm-connections';
import { type SessionSummary } from '@maka/core/session';
import { type ThinkingLevel } from '@maka/core/model-thinking';
import { useUiLocale } from './locale-context.js';
import { getConversationCopy } from './conversation-copy.js';
-import { ModelWheelPicker, type ModelWheelOption } from './model-wheel-picker.js';
+import { getSharedUiCopy } from './shared-ui-copy.js';
+import { usePendingSelection } from './use-pending-selection.js';
import type { ComposerModelSwitchAvailability } from './composer-helpers.js';
const DEFAULT_THINKING_LEVEL = '__default__';
+/** Sentinel option value for the switch-warning row; disabled so it can never be picked. */
+const SWITCH_WARNING_VALUE = '__maka_model_switch_warning__';
-function providerMarkIcon(
- providerType: ProviderType | undefined,
- renderProviderMark: ((type: ProviderType) => ReactNode) | undefined,
-): ReactNode {
- if (!providerType || !renderProviderMark) return undefined;
- return (
- <span className="modelPickerProviderMark" data-provider={providerType} aria-hidden="true">
- {renderProviderMark(providerType)}
- </span>
- );
-}
-
-const currentCheck = <Check size={ICON_SIZE.control} aria-hidden="true" />;
+const exactChoiceValue = (choice: ChatModelChoice) =>
+ exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model);
-function wheelOptions(groups: readonly ModelMenuGroup[], locale: Parameters<typeof modelChoiceDescription>[1]): ModelWheelOption[] {
- return groups.flatMap((group) => group.choices.map((choice) => ({
- value: exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model),
- label: choice.label,
- heading: group.heading,
- description: modelChoiceDescription(choice, locale),
- })));
+function wheelOptions(
+ groups: readonly ModelMenuGroup[],
+ locale: Parameters<typeof modelChoiceDescription>[1],
+): ModelWheelOption[] {
+ return groups.flatMap((group) =>
+ group.choices.map((choice) => ({
+ value: exactChoiceValue(choice),
+ label: choice.label,
+ heading: group.heading,
+ description: modelChoiceDescription(choice, locale),
+ })),
+ );
}
/**
- * Standalone thinking-level picker. Hidden when the active model has no
- * variants — the control does not appear as a disabled husk or change the
- * model menu's shape.
+ * Standalone thinking-level picker — the same ghost Selector as the model
+ * switcher beside it, minus the search (a handful of levels never needs it).
+ * Hidden when the active model has no variants — the control does not appear
+ * as a disabled husk or change the model picker's shape.
*/
export function ThinkingLevelSelector(props: {
levels: readonly ThinkingLevel[];
current?: ThinkingLevel;
+ /** Same surface as the model picker; compact windows that cannot fit an anchored popup use 'bottom-sheet'. */
+ presentation?: 'popover' | 'bottom-sheet';
+ /** Force any open surface closed while an interaction prompt occludes the composer. */
+ isReadOnly?: boolean;
onChange?(level: ThinkingLevel | undefined): void | Promise<void>;
disabled?: boolean;
- /** Why the control is locked (mid-turn etc.); replaces the action tooltip so the reason is discoverable, matching the model switcher beside it. */
+ /** Why the control is locked (mid-turn etc.); Selector exposes it as the disabled tooltip. */
disabledReason?: string;
}) {
const copy = getConversationCopy(useUiLocale()).model;
@@ -90,50 +107,38 @@ export function ThinkingLevelSelector(props: {
[copy.defaultLevel, copy.level, props.levels],
);
+ const currentValue = props.current ?? DEFAULT_THINKING_LEVEL;
+ const selection = usePendingSelection(currentValue, (value) =>
+ props.onChange?.(value === DEFAULT_THINKING_LEVEL ? undefined : (value as ThinkingLevel)),
+ );
+
if (!hasVariants) return null;
- const currentValue = props.current ?? DEFAULT_THINKING_LEVEL;
const currentLabel = options.find((option) => option.value === currentValue)?.label ?? copy.defaultLevel;
return (
- <DropdownMenu
+ <Selector
+ label={`${copy.thinkingLevel}: ${currentLabel}`}
+ isLabelHidden
+ options={options}
+ value={selection.value}
+ variant="ghost"
+ size="sm"
placement="above"
- hasChevron={false}
- className="maka-composer-quiet-menu maka-thinking-level-menu"
- button={{
- label: currentLabel,
- variant: 'ghost',
- size: 'sm',
- isDisabled: props.disabled,
- tooltip: props.disabledReason ?? copy.changeThinkingLevel,
- className: 'maka-thinking-level-selector',
- 'aria-label': `${copy.thinkingLevel}: ${currentLabel}`,
- }}
- >
- <DropdownMenuRadioGroup
- value={currentValue}
- label={`${copy.thinkingLevel}: ${currentLabel}`}
- onChange={(value) => {
- void props.onChange?.(
- value === DEFAULT_THINKING_LEVEL ? undefined : (value as ThinkingLevel),
- );
- }}
- >
- {options.map((option) => (
- <DropdownMenuRadioItem
- key={option.value}
- value={option.value}
- label={option.label}
- endContent={option.value === currentValue ? currentCheck : undefined}
- isDisabled={props.disabled}
- />
- ))}
- </DropdownMenuRadioGroup>
- </DropdownMenu>
+ presentation={props.presentation}
+ isReadOnly={props.isReadOnly}
+ isDisabled={props.disabled}
+ disabledMessage={props.disabledReason}
+ placeholder={currentLabel}
+ className="maka-thinking-level-selector"
+ onChange={selection.onChange}
+ />
);
}
export function ChatModelSwitcher(props: {
+ /** 'popover' anchors to the trigger; 'bottom-sheet' stays inside compact windows; 'wheel' is the collapsed WorkHub's inline picker. */
+ presentation?: 'popover' | 'bottom-sheet' | 'wheel';
activeSession: SessionSummary;
activeModelConnectionId?: string;
activeModelConnectionSlug?: string;
@@ -144,9 +149,15 @@ export function ChatModelSwitcher(props: {
hasConversationHistory?: boolean;
availability?: ComposerModelSwitchAvailability;
disabledReason?: string;
- /** Optional controlled open state used by an external recovery action. */
- isMenuOpen?: boolean;
- onMenuOpenChange?(open: boolean): void;
+ /**
+ * Selector has no controlled open prop, so recovery bumps this instead: the
+ * remount keyed on it opens the panel via `isDefaultOpen`, an entirely
+ * documented surface. Session changes share the key so a switch always lands
+ * closed.
+ */
+ openNonce?: number;
+ /** Force any open surface closed while an interaction prompt occludes the composer. */
+ isReadOnly?: boolean;
/** Hide a stale display-only row while the Session requires identity recovery. */
hideUnavailableCurrentOption?: boolean;
renderProviderMark?(type: ProviderType): ReactNode;
@@ -158,6 +169,7 @@ export function ChatModelSwitcher(props: {
}) {
const locale = useUiLocale();
const copy = getConversationCopy(locale).model;
+ const searchPlaceholder = getSharedUiCopy(locale).modelPicker.searchPlaceholder;
const currentConnectionId =
props.activeModelConnectionId ?? props.activeSession.llmConnectionId;
const currentConnectionSlug =
@@ -169,62 +181,137 @@ export function ChatModelSwitcher(props: {
currentConnectionSlug,
currentModel,
)
- : undefined;
+ : '';
const availability = props.availability ?? { available: true, pending: false };
- const [internalMenuOpen, setInternalMenuOpen] = useState(false);
- const menuOpen = props.isMenuOpen ?? internalMenuOpen;
- const setMenuOpen = (open: boolean) => {
- if (props.isMenuOpen === undefined) setInternalMenuOpen(open);
- props.onMenuOpenChange?.(open);
- };
const disabled =
Boolean(props.disabledReason) ||
!availability.available || !props.onChange || props.choices.length === 0;
const grouped = modelMenuGroups(props.choices, locale);
const currentKnownChoice = props.choices.some(
- (choice) =>
- exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model) ===
- currentValue,
+ (choice) => exactChoiceValue(choice) === currentValue,
);
const displayLabel = props.activeModelLabel ?? currentModel;
- const title = props.disabledReason ?? copy.switchAriaLabel;
- const announceWarning = menuOpen && props.hasConversationHistory === true;
- const pick = async (next: { llmConnectionSlug: string; llmConnectionId: string; model: string }) => {
- if (next.llmConnectionSlug === currentConnectionSlug && next.llmConnectionId === currentConnectionId && next.model === currentModel) return;
- try { await props.onChange?.(next); } catch { /* The action owner reports the failure. */ }
- };
+ const choiceByValue = useMemo(
+ () => new Map(props.choices.map((choice) => [exactChoiceValue(choice), choice] as const)),
+ [props.choices],
+ );
+ const options = useMemo(() => {
+ const list = buildModelPickerOptions(
+ grouped,
+ !currentKnownChoice && currentValue && !props.hideUnavailableCurrentOption
+ ? { value: currentValue, label: displayLabel, providerType: props.currentProviderType, disabled: true }
+ : undefined,
+ exactChoiceValue,
+ { locale, renderProviderMark: props.renderProviderMark },
+ );
+ // Switching can abandon a provider prompt cache — a property of the pending
+ // action, shown as a disabled first row whenever the panel opens.
+ if (props.hasConversationHistory === true) {
+ list.unshift({ value: SWITCH_WARNING_VALUE, label: copy.switchWarning, disabled: true });
+ }
+ return list;
+ }, [grouped, currentKnownChoice, currentValue, props.hideUnavailableCurrentOption,
+ props.hasConversationHistory, displayLabel, props.currentProviderType, copy.switchWarning,
+ locale, props.renderProviderMark]);
+
+ // Reflect the pick on the trigger immediately and hold it until the write
+ // settles, then defer to the authoritative value. See usePendingSelection.
+ const selection = usePendingSelection(currentValue, async (value) => {
+ const choice = choiceByValue.get(value);
+ if (!choice) return;
+ try {
+ await props.onChange?.({
+ llmConnectionId: choice.connectionId,
+ llmConnectionSlug: choice.connectionSlug,
+ model: choice.model,
+ });
+ } catch { /* The action owner reports the failure. */ }
+ });
- const options = wheelOptions(grouped, locale);
- if (!currentKnownChoice && currentValue && !props.hideUnavailableCurrentOption) {
- options.unshift({ value: currentValue, label: displayLabel, disabled: true });
+ // The collapsed WorkHub window keeps the wheel it was built for. The nonce
+ // edge opens it once, then the wheel owns its own open state.
+ const [wheelOpen, setWheelOpen] = useState(false);
+ useEffect(() => {
+ if (props.openNonce) setWheelOpen(true);
+ }, [props.openNonce]);
+ // A wheel left open survives its unmount (the layer's hide callback never
+ // fires) — reset it on session and presentation flips so a remount does not
+ // reopen it spontaneously.
+ useEffect(() => {
+ if (props.presentation !== 'wheel') setWheelOpen(false);
+ }, [props.presentation]);
+ useEffect(() => setWheelOpen(false), [props.activeSession.id]);
+ if (props.presentation === 'wheel') {
+ const wheelList = wheelOptions(grouped, locale);
+ if (!currentKnownChoice && currentValue && !props.hideUnavailableCurrentOption) {
+ wheelList.unshift({ value: currentValue, label: displayLabel, disabled: true });
+ }
+ return (
+ <>
+ <ModelWheelPicker
+ options={wheelList}
+ value={selection.value}
+ label={displayLabel}
+ ariaLabel={`${copy.switchAriaLabel}: ${displayLabel}`}
+ icon={providerMarkIcon(props.currentProviderType, props.renderProviderMark)}
+ tooltip={props.disabledReason ?? copy.switchAriaLabel}
+ triggerClassName="maka-model-switcher-trigger"
+ disabled={disabled}
+ open={wheelOpen}
+ onOpenChange={setWheelOpen}
+ onValueChange={selection.onChange}
+ />
+ <span
+ className="maka-visually-hidden maka-model-switch-announcement"
+ role="status"
+ aria-live="polite"
+ aria-atomic="true"
+ >
+ {wheelOpen && props.hasConversationHistory === true ? copy.switchWarning : ''}
+ </span>
+ </>
+ );
}
- return <>
- <ModelWheelPicker options={options} value={currentValue} label={displayLabel}
- ariaLabel={`${copy.switchAriaLabel}: ${displayLabel}`}
- icon={providerMarkIcon(props.currentProviderType, props.renderProviderMark)}
- tooltip={title} triggerClassName="maka-model-switcher-trigger"
- disabled={disabled} open={menuOpen} onOpenChange={setMenuOpen}
- onValueChange={(value) => {
- const choice = props.choices.find((entry) => exactModelChoiceValue(entry.connectionId, entry.connectionSlug, entry.model) === value);
- if (choice) return pick({ llmConnectionId: choice.connectionId, llmConnectionSlug: choice.connectionSlug, model: choice.model });
- }} />
- <span className="maka-visually-hidden maka-model-switch-announcement" role="status" aria-live="polite" aria-atomic="true">
- {announceWarning ? copy.switchWarning : ''}
- </span>
- </>;
+
+ return (
+ <Selector
+ key={`${props.activeSession.id}:${props.openNonce ?? 0}`}
+ label={`${copy.switchAriaLabel}: ${displayLabel}`}
+ isLabelHidden
+ options={options}
+ value={selection.value}
+ hasSearch
+ searchPlaceholder={searchPlaceholder}
+ variant="ghost"
+ size="sm"
+ placement="above"
+ presentation={props.presentation}
+ isDisabled={disabled}
+ isReadOnly={props.isReadOnly}
+ disabledMessage={props.disabledReason}
+ isDefaultOpen={Boolean(props.openNonce)}
+ placeholder={displayLabel}
+ className="maka-model-switcher-trigger"
+ onChange={selection.onChange}
+ renderOption={renderModelPickerOption}
+ renderValue={renderModelPickerValue}
+ />
+ );
}
/**
* Home / empty-state model picker (no active session yet). Unlike
* `ChatModelSwitcher` — which is bound to a live session and switches THAT
* session's model — this one just records which model the next new chat should
- * start with. Reuses the model chip's look: a chevronless ghost button like
- * the + and permission controls beside it — the hover wash and tooltip are
- * the menu affordance. The thinking level for new chats is a separate
- * right-footer control owned by the Composer.
+ * start with. The thinking level for new chats is a separate right-footer
+ * control owned by the Composer.
*/
export function NewChatModelPicker(props: {
label: string;
+ /** Same surfaces as ChatModelSwitcher; the collapsed WorkHub window passes 'wheel'. */
+ presentation?: 'popover' | 'bottom-sheet' | 'wheel';
+ /** Force any open surface closed while an interaction prompt occludes the composer. */
+ isReadOnly?: boolean;
choices: ChatModelChoice[];
currentValue?: string;
currentProviderType?: ProviderType;
@@ -237,25 +324,78 @@ export function NewChatModelPicker(props: {
}) {
const locale = useUiLocale();
const copy = getConversationCopy(locale).model;
+ const searchPlaceholder = getSharedUiCopy(locale).modelPicker.searchPlaceholder;
const grouped = modelMenuGroups(props.choices, locale);
const currentValue = props.currentValue ?? '';
const currentKnownChoice = props.choices.some(
- (choice) =>
- exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model) ===
- currentValue,
+ (choice) => exactChoiceValue(choice) === currentValue,
+ );
+ const choiceByValue = useMemo(
+ () => new Map(props.choices.map((choice) => [exactChoiceValue(choice), choice] as const)),
+ [props.choices],
);
- const options = wheelOptions(grouped, locale);
- if (!currentKnownChoice && currentValue) {
- options.unshift({ value: currentValue, label: props.label, disabled: true });
+ const options = useMemo(
+ () => buildModelPickerOptions(
+ grouped,
+ !currentKnownChoice && currentValue
+ ? { label: props.label, value: currentValue, providerType: props.currentProviderType, disabled: true }
+ : undefined,
+ exactChoiceValue,
+ { locale, renderProviderMark: props.renderProviderMark },
+ ),
+ [grouped, currentKnownChoice, currentValue, props.label, props.currentProviderType,
+ locale, props.renderProviderMark],
+ );
+ // The only producer is synchronous state (pending new-chat model), so the
+ // pick shows through `currentValue` on the same render — no pending state.
+ const pick = (value: string) => {
+ if (value === currentValue) return Promise.resolve();
+ const choice = choiceByValue.get(value);
+ if (!choice) return Promise.resolve();
+ return Promise.resolve(props.onPick({
+ llmConnectionId: choice.connectionId,
+ llmConnectionSlug: choice.connectionSlug,
+ model: choice.model,
+ }));
+ };
+ if (props.presentation === 'wheel') {
+ const wheelList = wheelOptions(grouped, locale);
+ if (!currentKnownChoice && currentValue) {
+ wheelList.unshift({ value: currentValue, label: props.label, disabled: true });
+ }
+ return (
+ <ModelWheelPicker
+ options={wheelList}
+ value={currentValue}
+ label={props.label}
+ ariaLabel={copy.newChatAriaLabel(props.label)}
+ icon={providerMarkIcon(props.currentProviderType, props.renderProviderMark)}
+ tooltip={copy.newChatTitle(props.label)}
+ triggerClassName="maka-new-chat-model-selector"
+ onValueChange={pick}
+ />
+ );
}
- return <ModelWheelPicker options={options} value={currentValue} label={props.label}
- ariaLabel={copy.newChatAriaLabel(props.label)}
- icon={providerMarkIcon(props.currentProviderType, props.renderProviderMark)}
- tooltip={copy.newChatTitle(props.label)} triggerClassName="maka-new-chat-model-selector"
- onValueChange={(value) => {
- const choice = props.choices.find((entry) => exactModelChoiceValue(entry.connectionId, entry.connectionSlug, entry.model) === value);
- if (choice) return props.onPick({ llmConnectionId: choice.connectionId, llmConnectionSlug: choice.connectionSlug, model: choice.model });
- }} />;
+ return (
+ <Selector
+ label={copy.newChatAriaLabel(props.label)}
+ isLabelHidden
+ options={options}
+ value={currentValue}
+ hasSearch
+ searchPlaceholder={searchPlaceholder}
+ variant="ghost"
+ size="sm"
+ placement="above"
+ presentation={props.presentation}
+ isReadOnly={props.isReadOnly}
+ placeholder={props.label}
+ className="maka-new-chat-model-selector"
+ onChange={pick}
+ renderOption={renderModelPickerOption}
+ renderValue={renderModelPickerValue}
+ />
+ );
}
/**
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index 879b161a99..20ba8a109d 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -352,6 +352,14 @@ export const Composer = forwardRef<
activeModelLabel?: string;
activeProviderType?: ProviderType;
modelChoices?: ChatModelChoice[];
+ /** Model-picker surface; 'wheel' is the collapsed WorkHub's inline picker, and any non-popover surface drops the thinking picker to a bottom sheet. */
+ pickerPresentation?: 'popover' | 'bottom-sheet' | 'wheel';
+ /**
+ * Close the model/thinking pickers' open surfaces while an interaction
+ * prompt occludes the composer — a bottom sheet stays a modal dialog even
+ * inside a `hidden` subtree, which would inert the whole window.
+ */
+ pickersReadOnly?: boolean;
/** Maximum input height in the upstream editor's row units. */
maxInputRows?: number;
/** Whether this Session already has conversation history whose provider prompt cache may be rebuilt by a switch. */
@@ -534,7 +542,7 @@ export const Composer = forwardRef<
}
const [dragActive, setDragActive] = useState(false);
const [sendPending, setSendPending] = useState(false);
- const [modelPickerOpen, setModelPickerOpen] = useState(false);
+ const [modelPickerNonce, setModelPickerNonce] = useState(0);
const modelSwitchAvailability =
props.modelSwitchAvailability ??
deriveComposerModelSwitchAvailability({
@@ -543,7 +551,13 @@ export const Composer = forwardRef<
});
const modelSwitchAvailabilityRef = useRef(modelSwitchAvailability);
modelSwitchAvailabilityRef.current = modelSwitchAvailability;
- useLayoutEffect(() => setModelPickerOpen(false), [props.activeSession?.id]);
+ // Any non-popover model surface means a window too small for an anchored
+ // popup; the thinking picker has no wheel, so it drops to a bottom sheet.
+ const thinkingPresentation =
+ props.pickerPresentation && props.pickerPresentation !== 'popover'
+ ? 'bottom-sheet'
+ : 'popover';
+ useLayoutEffect(() => setModelPickerNonce(0), [props.activeSession?.id]);
const [pendingImportAction, setPendingImportAction] = useState<ComposerImportActionId | null>(null);
const composerMountedRef = useMountedRef();
const sendPendingRef = useRef(false);
@@ -1259,7 +1273,7 @@ export const Composer = forwardRef<
},
openModelPicker() {
if (!modelSwitchAvailabilityRef.current.available) return;
- setModelPickerOpen(true);
+ setModelPickerNonce((nonce) => nonce + 1);
},
}),
[],
@@ -2121,6 +2135,8 @@ export const Composer = forwardRef<
<div className="maka-model-selection-controls">
{props.activeSession ? (
<ChatModelSwitcher
+ presentation={props.pickerPresentation}
+ isReadOnly={props.pickersReadOnly}
activeSession={props.activeSession}
activeModelConnectionId={props.activeModelConnectionId}
activeModelConnectionSlug={props.activeModelConnectionSlug}
@@ -2131,8 +2147,7 @@ export const Composer = forwardRef<
hasConversationHistory={props.modelSwitchHasHistory}
availability={modelSwitchAvailability}
disabledReason={modelSwitcherDisabledReason}
- isMenuOpen={modelPickerOpen}
- onMenuOpenChange={setModelPickerOpen}
+ openNonce={modelPickerNonce}
hideUnavailableCurrentOption={props.hideUnavailableCurrentModel}
renderProviderMark={props.renderProviderMark}
onChange={props.onModelChange}
@@ -2140,6 +2155,8 @@ export const Composer = forwardRef<
) : props.onPickNewChatModel && (props.modelChoices?.length ?? 0) > 0 ? (
<NewChatModelPicker
label={modelChipLabel}
+ presentation={props.pickerPresentation}
+ isReadOnly={props.pickersReadOnly}
choices={props.modelChoices ?? []}
currentValue={
props.newChatModel
@@ -2165,6 +2182,8 @@ export const Composer = forwardRef<
<ThinkingLevelSelector
levels={props.activeThinkingLevels ?? []}
current={props.activeThinkingLevel}
+ presentation={thinkingPresentation}
+ isReadOnly={props.pickersReadOnly}
onChange={props.onThinkingLevelChange}
disabled={!modelSwitchAvailability.available}
disabledReason={thinkingSwitcherDisabledReason}
@@ -2173,6 +2192,8 @@ export const Composer = forwardRef<
<ThinkingLevelSelector
levels={props.newChatThinkingLevels ?? []}
current={props.newChatThinkingLevel}
+ presentation={thinkingPresentation}
+ isReadOnly={props.pickersReadOnly}
onChange={props.onNewChatThinkingLevelChange}
/>
)}
diff --git a/packages/ui/src/model-picker-internals.tsx b/packages/ui/src/model-picker-internals.tsx
new file mode 100644
index 0000000000..e8f50b768b
--- /dev/null
+++ b/packages/ui/src/model-picker-internals.tsx
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { ReactNode } from 'react';
+import {
+ SelectorOption,
+ type SelectorOptionData,
+ type SelectorOptionType,
+ type SelectorSection,
+} from '@astryxdesign/core/Selector';
+import type { ProviderType } from '@maka/core/llm-connections';
+import type { UiLocale } from '@maka/core/ui-locale';
+import {
+ type ModelMenuGroup,
+ modelChoiceDescription,
+} from './chat-model-helpers.js';
+
+export interface ModelPickerLeadingOption {
+ value: string;
+ label: string;
+ providerType?: ProviderType;
+ disabled?: boolean;
+}
+
+type ModelChoiceValueFn = (choice: ModelMenuGroup['choices'][number]) => string;
+
+export function providerMarkIcon(
+ providerType: ProviderType | undefined,
+ renderProviderMark: ((type: ProviderType) => ReactNode) | undefined,
+): ReactNode {
+ if (!providerType || !renderProviderMark) return undefined;
+ return (
+ <span className="modelPickerProviderMark" data-provider={providerType} aria-hidden="true">
+ {renderProviderMark(providerType)}
+ </span>
+ );
+}
+
+/**
+ * Shapes Maka's provider catalog into Astryx Selector's public option model:
+ * each option carries its provider mark as `icon` and the catalog line as
+ * `description`, so Selector's default row and trigger rendering need no
+ * product markup. Search, flattening, keyboard navigation, selection, and
+ * empty results remain entirely inside Selector.
+ *
+ * `toValue` is `modelChoiceValue` for the settings catalog (slug-scoped) and
+ * `exactModelChoiceValue` for the session switcher (connection-id-scoped).
+ */
+export function buildModelPickerOptions(
+ groups: readonly ModelMenuGroup[],
+ leadingOption: ModelPickerLeadingOption | undefined,
+ toValue: ModelChoiceValueFn,
+ extras: {
+ locale: UiLocale;
+ renderProviderMark?(type: ProviderType): ReactNode;
+ },
+): SelectorOptionType[] {
+ const sections: SelectorSection[] = groups.map((group) => ({
+ type: 'section',
+ title: group.heading,
+ options: group.choices.map((choice) => ({
+ value: toValue(choice),
+ label: choice.label,
+ icon: providerMarkIcon(group.providerType, extras.renderProviderMark),
+ description: modelChoiceDescription(choice, extras.locale),
+ })),
+ }));
+
+ if (!leadingOption) return sections;
+
+ const option: SelectorOptionData = {
+ value: leadingOption.value,
+ label: leadingOption.label,
+ icon: providerMarkIcon(leadingOption.providerType, extras.renderProviderMark),
+ disabled: leadingOption.disabled,
+ };
+ return sections.length > 0 ? [option, { type: 'divider' }, ...sections] : [option];
+}
+
+/**
+ * Ellipsizes at the START of the label: the ids long enough to truncate are
+ * mostly path-style ids (`accounts/<provider>/models/<model>`), whose tail is
+ * the actual model name. The `<bdi>` keeps weak-directional characters at the
+ * string edges from jumping sides under `direction: rtl`.
+ */
+function TailVisibleLabel({ text }: { text: string }) {
+ return (
+ <span className="modelPickerOptionLabel">
+ <bdi>{text}</bdi>
+ </span>
+ );
+}
+
+/**
+ * Selector's default row is already this layout; the product additions are the
+ * `modelPickerOption` hook that scopes the popup's width cap in
+ * model-switcher.css, and the start-ellipsizing label above.
+ */
+export function renderModelPickerOption(option: SelectorOptionData): ReactNode {
+ return (
+ <SelectorOption
+ className="modelPickerOption"
+ icon={option.icon}
+ label={<TailVisibleLabel text={option.label ?? option.value} />}
+ description={option.description}
+ />
+ );
+}
+
+/** Same icon + start-ellipsized label inside the closed trigger. */
+export function renderModelPickerValue(option: SelectorOptionData): ReactNode {
+ return (
+ <SelectorOption icon={option.icon} label={<TailVisibleLabel text={option.label ?? option.value} />} />
+ );
+}
+
diff --git a/packages/ui/src/model-picker.tsx b/packages/ui/src/model-picker.tsx
index d1ad954e14..3bdb287010 100644
--- a/packages/ui/src/model-picker.tsx
+++ b/packages/ui/src/model-picker.tsx
@@ -17,13 +17,28 @@
* under the License.
*/
-/** Settings catalog adapter for the shared magnetic model picker. */
+/**
+ * Product-specific model catalog composition over Astryx Selector.
+ *
+ * Maka owns provider/model shaping, provider marks, unknown-current display,
+ * and the selection action. Astryx owns search, empty results, option
+ * semantics, keyboard navigation, focus, scrolling, and popup behavior.
+ */
-import { type ReactNode } from 'react';
+import { useMemo, type ReactNode } from 'react';
+import { Selector } from '@astryxdesign/core/Selector';
import type { ProviderType } from '@maka/core/llm-connections';
-import { type ModelMenuGroup, modelChoiceDescription, modelChoiceValue } from './chat-model-helpers.js';
-import { ModelWheelPicker, type ModelWheelOption } from './model-wheel-picker.js';
+import type { ModelMenuGroup } from './chat-model-helpers.js';
+import {
+ buildModelPickerOptions,
+ renderModelPickerOption,
+ renderModelPickerValue,
+ type ModelPickerLeadingOption,
+} from './model-picker-internals.js';
+import { modelChoiceValue } from './chat-model-helpers.js';
import { useUiLocale } from './locale-context.js';
+import { getSharedUiCopy } from './shared-ui-copy.js';
+import { usePendingSelection } from './use-pending-selection.js';
export interface ModelPickerProps {
groups: readonly ModelMenuGroup[];
@@ -31,31 +46,62 @@ export interface ModelPickerProps {
onValueChange(value: string): void | Promise<void>;
renderProviderMark?(type: ProviderType): ReactNode;
disabled?: boolean;
- leadingOption?: { value: string; label: string; providerType?: ProviderType };
+ /**
+ * An ordinary option placed before the catalog for product values such as
+ * “not set” or a current model that is no longer listed. Astryx search treats
+ * it exactly like every other option.
+ */
+ leadingOption?: ModelPickerLeadingOption;
+ searchPlaceholder?: string;
triggerClassName?: string;
ariaLabel: string;
}
+const slugScopedValue = (choice: ModelMenuGroup['choices'][number]) =>
+ modelChoiceValue(choice.connectionSlug, choice.model);
+
export function ModelPicker(props: ModelPickerProps) {
const locale = useUiLocale();
- const choices = props.groups.flatMap((group) => group.choices);
- const current = choices.find((choice) => modelChoiceValue(choice.connectionSlug, choice.model) === props.value);
- const options: ModelWheelOption[] = props.groups.flatMap((group) => group.choices.map((choice) => ({
- value: modelChoiceValue(choice.connectionSlug, choice.model),
- label: choice.label,
- heading: group.heading,
- description: modelChoiceDescription(choice, locale),
- })));
- if (props.leadingOption) options.unshift(props.leadingOption);
- const label = options.find((option) => option.value === props.value)?.label ?? props.value;
- if (!options.some((option) => option.value === props.value)) {
- options.unshift({ value: props.value, label, disabled: true });
- }
- const provider = current?.providerType ?? (props.leadingOption?.value === props.value ? props.leadingOption.providerType : undefined);
- return <div className="maka-model-picker-root">
- <ModelWheelPicker options={options} value={props.value} label={label}
- ariaLabel={props.ariaLabel} size="md" disabled={props.disabled}
- triggerClassName={props.triggerClassName} onValueChange={props.onValueChange}
- icon={provider && props.renderProviderMark ? <span className="modelPickerProviderMark" data-provider={provider} aria-hidden="true">{props.renderProviderMark(provider)}</span> : undefined} />
- </div>;
+ const copy = getSharedUiCopy(locale).modelPicker;
+
+ const options = useMemo(
+ () =>
+ buildModelPickerOptions(props.groups, props.leadingOption, slugScopedValue, {
+ locale,
+ renderProviderMark: props.renderProviderMark,
+ }),
+ [props.groups, props.leadingOption, locale, props.renderProviderMark],
+ );
+
+ // Reflect the pick immediately and hold it until the caller's write settles,
+ // then defer to the authoritative `value`. See usePendingSelection.
+ const selection = usePendingSelection(props.value, props.onValueChange);
+
+ // size=md matches the other settings-row selectors, so the size is a fact
+ // of the component, not a prop.
+ return (
+ <div className="maka-model-picker-root">
+ <Selector
+ label={props.ariaLabel}
+ isLabelHidden
+ options={options}
+ value={selection.value}
+ hasSearch
+ searchPlaceholder={props.searchPlaceholder ?? copy.searchPlaceholder}
+ size="md"
+ placement="above"
+ isDisabled={props.disabled}
+ className={props.triggerClassName}
+ // `onChange`, not `changeAction`: the async `changeAction` path wraps
+ // the caller's save in a transition and spins the trigger (Astryx's
+ // built-in optimistic `isBusy`) for the whole round-trip. On the
+ // fire-and-forget `onChange` path the trigger never enters that busy
+ // state; usePendingSelection shows the pick at once and settles it when
+ // the write finishes.
+ onChange={selection.onChange}
+ renderOption={renderModelPickerOption}
+ renderValue={renderModelPickerValue}
+ />
+ </div>
+ );
}
diff --git a/packages/ui/src/use-pending-selection.ts b/packages/ui/src/use-pending-selection.ts
new file mode 100644
index 0000000000..234c5c2637
--- /dev/null
+++ b/packages/ui/src/use-pending-selection.ts
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useCallback, useRef, useState } from 'react';
+
+export interface PendingSelection {
+ /**
+ * The value to render: the just-picked value while its write is unsettled,
+ * otherwise the authoritative value.
+ */
+ value: string;
+ /**
+ * Show `next` at once and fire the write; resolves when that write settles —
+ * by when the authoritative `value` has caught up on success, or falls back
+ * to it on failure. Picking the already-displayed value is a no-op.
+ */
+ onChange(next: string): Promise<void>;
+}
+
+/**
+ * Reflect a just-picked value immediately and hold it until the caller's write
+ * settles, then defer to the authoritative `value`. No spinner and no lag: the
+ * pick shows from the click and clears the moment `onValueChange` resolves (by
+ * when `value` has caught up) or rejects (rolling back to `value`).
+ *
+ * A monotonic token makes the latest pick win, so a slower earlier write's
+ * settle cannot wipe a newer pick. The state is deliberately local and
+ * write-scoped: it carries no cross-read generation and no state that outlives
+ * the component, so reopening the surface always starts clean.
+ *
+ * Limitation: the pick clears when the write settles, not when `authoritative`
+ * is confirmed to carry it — so if the write resolves but the caller never
+ * lands the new value into `authoritative` (e.g. its refresh is silently
+ * dropped), the trigger falls back to the prior `authoritative` until the
+ * caller next updates it, self-healing on that next update and never wrong
+ * durably.
+ */
+export function usePendingSelection(
+ authoritative: string,
+ onValueChange: (next: string) => void | Promise<void>,
+): PendingSelection {
+ const [pending, setPending] = useState<string | null>(null);
+ const tokenRef = useRef(0);
+ // Mirrors for the callback: picking the displayed value again must not fire
+ // the write — downstream "set model" writes carry required sibling fields
+ // (e.g. thinkingLevel) that a spurious re-write would reset.
+ const pendingRef = useRef<string | null>(null);
+ const authoritativeRef = useRef(authoritative);
+ authoritativeRef.current = authoritative;
+ const onChange = useCallback(
+ (next: string) => {
+ if (next === (pendingRef.current ?? authoritativeRef.current)) {
+ return Promise.resolve();
+ }
+ const token = (tokenRef.current += 1);
+ pendingRef.current = next;
+ setPending(next);
+ // Clear on either outcome — success (authoritative caught up) or failure
+ // (roll back to authoritative) — and only if this is still the latest
+ // pick. Two-arg `then` (not `finally`) so a rejected write is consumed
+ // here rather than surfacing as an unhandled rejection.
+ const settle = () => {
+ if (tokenRef.current === token) {
+ pendingRef.current = null;
+ setPending(null);
+ }
+ };
+ return Promise.resolve(onValueChange(next)).then(settle, settle);
+ },
+ [onValueChange],
+ );
+ return { value: pending ?? authoritative, onChange };
+}
diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx
index ffd4221f5f..31bf570213 100644
--- a/packages/ui/stories/model-picker.stories.tsx
+++ b/packages/ui/stories/model-picker.stories.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { useState } from 'react';
+import { useState, type ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import type { ProviderType } from '@maka/core/llm-connections';
@@ -26,6 +26,9 @@ import type { SessionSummary } from '@maka/core/session';
import { ChatModelSwitcher, ModelChipStatic, NewChatModelPicker, ThinkingLevelSelector } from '../src/chat-model-switcher.js';
import {
exactModelChoiceValue,
+ modelChoiceDescription,
+ modelChoiceValue,
+ modelMenuGroups,
type ChatModelChoice,
} from '../src/chat-model-helpers.js';
import { ModelPicker } from '../src/model-picker.js';
@@ -61,7 +64,7 @@ const CHOICES: ChatModelChoice[] = [
choice('anthropic-team', 'anthropic', 'Anthropic', 'claude-opus-4-1', 'Claude Opus 4.1'),
choice('anthropic-team', 'anthropic', 'Anthropic', 'claude-sonnet-4', 'Claude Sonnet 4'),
choice('google-lab', 'google', 'Google Gemini', 'gemini-3-pro', 'Gemini 3 Pro'),
- choice('openrouter', 'openai-compatible', 'Custom relay', 'vendor/a-very-long-model-name-with-reasoning-and-tools-preview', 'A very long model name with reasoning and tools preview'),
+ choice('fireworks', 'openai-compatible', 'Fireworks', 'accounts/fireworks/models/deepseek-v4-flash-0731', 'accounts/fireworks/models/deepseek-v4-flash-0731'),
];
// Canonical user-facing ladder when a model offers the common set.
@@ -82,33 +85,41 @@ const MANY_CHOICES: ChatModelChoice[] = (
] satisfies Array<{ slug: string; type: ProviderType; label: string; models: string[] }>
).flatMap((group) => group.models.map((model) => choice(group.slug, group.type, group.label, model, model)));
-// A relay whose connection name, model ids, and descriptions all overflow the
-// trigger and option widths — the "very long text" state truncation must honour.
-const LONG_MODEL_LABEL =
- 'A very long model name that keeps going well past any reasonable trigger width so wrapping and truncation get exercised';
+// Real over-length ids from models.dev (2026-03): the ids that outgrow the
+// popup are path-style — `accounts/<provider>/models/<model>` on Fireworks or
+// namespaced slugs on OpenRouter — where the tail is the actual model name.
const LONG_CHOICES: ChatModelChoice[] = [
{
- connectionId: 'connection-relay-verbose',
- connectionSlug: 'relay-verbose',
+ connectionId: 'connection-fireworks',
+ connectionSlug: 'fireworks',
providerType: 'openai-compatible',
- providerLabel: 'Custom relay',
- connectionName: 'My self-hosted relay with an unusually descriptive connection name that also overflows',
- model: 'vendor/a-very-long-model-identifier-with-reasoning-tools-and-a-2026-preview-suffix',
- label: LONG_MODEL_LABEL,
- description:
- 'A deliberately verbose description that runs onto several lines so the option body’s overflow handling stays legible instead of pushing the menu wider.',
- knowledgeCutoff: '2026-01',
+ providerLabel: 'Fireworks',
+ connectionName: 'Fireworks',
+ model: 'accounts/fireworks/models/deepseek-v4-flash-0731',
+ label: 'accounts/fireworks/models/deepseek-v4-flash-0731',
+ description: 'DeepSeek V4 Flash 0731',
isDefault: false,
thinkingLevels: [],
},
{
- connectionId: 'connection-relay-verbose',
- connectionSlug: 'relay-verbose',
+ connectionId: 'connection-fireworks',
+ connectionSlug: 'fireworks',
providerType: 'openai-compatible',
- providerLabel: 'Custom relay',
- connectionName: 'My self-hosted relay with an unusually descriptive connection name that also overflows',
- model: 'vendor/second-extremely-long-model-identifier-preview-with-an-extended-context-window',
- label: 'Another exhaustively named preview model with an extended context window and a trailing note',
+ providerLabel: 'Fireworks',
+ connectionName: 'Fireworks',
+ model: 'accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b',
+ label: 'accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b',
+ isDefault: false,
+ thinkingLevels: [],
+ },
+ {
+ connectionId: 'connection-openrouter',
+ connectionSlug: 'openrouter',
+ providerType: 'openai-compatible',
+ providerLabel: 'OpenRouter',
+ connectionName: 'OpenRouter',
+ model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition',
+ label: 'cognitivecomputations/dolphin-mistral-24b-venice-edition',
isDefault: false,
thinkingLevels: [],
},
@@ -211,35 +222,20 @@ export const ExistingConversation: Story = {
const trigger = within(canvasElement).getByRole('button', {
name: /切换当前任务模型|Switch model for this task/,
});
- const announcement = canvasElement.querySelector<HTMLElement>('.maka-model-switch-announcement');
- await expect(announcement).toHaveAttribute('role', 'status');
- await expect(trigger).not.toHaveAttribute('aria-description');
- await expect(announcement).toBeEmptyDOMElement();
- await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument();
-
- await userEvent.hover(trigger);
- await within(document.body).findByText(
- english ? 'Switch model for this task' : '切换当前任务模型',
- );
- await userEvent.unhover(trigger);
await userEvent.click(trigger);
- await waitFor(() => expect(announcement).toHaveTextContent(warning));
- await expect(announcement).toHaveAttribute('aria-live', 'polite');
- await expect(announcement).toHaveAttribute('aria-atomic', 'true');
- const menu = within(document.body).getByRole('listbox');
- await expect(menu).not.toContainElement(announcement);
+ // The cache warning leads the open list as a disabled row, announced by its
+ // option text — reachable but never pickable.
+ const warningRow = await within(document.body).findByRole('option', { name: warning });
+ await expect(warningRow).toHaveAttribute('aria-disabled', 'true');
await userEvent.keyboard('{Escape}');
- await waitFor(() => expect(announcement).toBeEmptyDOMElement());
- await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument();
-
// Closing restores focus to the same trigger next frame.
await waitFor(() => expect(within(canvasElement).getByRole('button', {
name: /切换当前任务模型|Switch model for this task/,
})).toHaveFocus());
await userEvent.keyboard('{ArrowDown}');
- await waitFor(() => expect(announcement).toHaveTextContent(warning));
+ await within(document.body).findByRole('option', { name: warning });
},
};
@@ -276,15 +272,12 @@ export const EmptyConversation: Story = {
const trigger = within(canvasElement).getByRole('button', {
name: /切换当前任务模型|Switch model for this task/,
});
- const announcement = canvasElement.querySelector<HTMLElement>('.maka-model-switch-announcement');
- await expect(announcement).toHaveAttribute('role', 'status');
- await expect(trigger).not.toHaveAttribute('aria-description');
- await expect(announcement).toBeEmptyDOMElement();
- await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument();
-
await userEvent.click(trigger);
- await expect(announcement).toBeEmptyDOMElement();
- await expect(document.body.querySelector('.maka-model-switch-notice')).not.toBeInTheDocument();
+ // No conversation history, so the cache warning row does not lead the list.
+ await within(document.body).findByRole('option', { name: /Claude Sonnet 4/ });
+ await expect(
+ within(document.body).queryByRole('option', { name: /prompt cache|提示缓存/ }),
+ ).not.toBeInTheDocument();
},
};
@@ -307,6 +300,37 @@ export const EmptyCatalog: Story = {
),
};
+// Real path: Settings → 通用 → 默认模型 with the full multi-connection catalog.
+// The Selector carries search — the answer to "找不到模型" that the wheel
+// dropped.
+export const SettingsCatalog: Story = {
+ render: function SettingsCatalogRender() {
+ // The settings catalog is slug-scoped (modelChoiceValue), unlike the
+ // session switcher's connection-id-scoped exact values.
+ const [value, setValue] = useState(
+ modelChoiceValue(MANY_CHOICES[4]!.connectionSlug, MANY_CHOICES[4]!.model),
+ );
+ return (
+ <div style={{ width: 260 }}>
+ <ModelPicker
+ groups={modelMenuGroups(MANY_CHOICES, 'zh-CN')}
+ value={value}
+ leadingOption={{ value: '', label: '未设置' }}
+ renderProviderMark={providerMark}
+ ariaLabel="默认模型"
+ onValueChange={setValue}
+ />
+ </div>
+ );
+ },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole('button'));
+ // Search narrows a 19-model catalog to the typed match.
+ await userEvent.keyboard('gamma');
+ await within(document.body).findByRole('option', { name: /vendor\/gamma/ });
+ },
+};
+
// Real path: quiet composer left footer — model + adjacent thinking menu.
export const ThinkingLevelSeparate: Story = {
render: function ThinkingLevelSeparateRender() {
@@ -334,10 +358,10 @@ export const ThinkingLevelSeparate: Story = {
);
},
play: async ({ canvasElement }) => {
- const thinking = within(canvasElement).getByRole('button', { name: /思考级别/ });
+ const thinking = within(canvasElement).getByRole('combobox', { name: /思考级别/ });
await userEvent.click(thinking);
- const medium = await within(document.body).findByRole('menuitemradio', { name: '中' });
- await expect(medium).toHaveAttribute('aria-checked', 'true');
+ const medium = await within(document.body).findByRole('option', { name: '中' });
+ await expect(medium).toHaveAttribute('aria-selected', 'true');
},
};
@@ -399,22 +423,13 @@ export const ManyConnections: Story = {
});
await userEvent.click(trigger);
const menu = within(document.body);
- await expect(await menu.findAllByRole('option')).toHaveLength(MANY_CHOICES.length);
- const wheel = menu.getByRole('listbox');
- const expectFilledEdges = async () => waitFor(() => {
- const bounds = wheel.getBoundingClientRect();
- const rows = Array.from(wheel.querySelectorAll('.maka-model-wheel-option'))
- .map((row) => row.getBoundingClientRect());
- for (const y of [bounds.top + 8, bounds.bottom - 8]) {
- expect(rows.some((row) => row.top <= y && row.bottom >= y)).toBe(true);
- }
- const selected = wheel.querySelector('[aria-selected="true"]')!.getBoundingClientRect();
- expect(Math.abs((selected.top + selected.bottom - bounds.top - bounds.bottom) / 2)).toBeLessThanOrEqual(1);
- });
- await expectFilledEdges();
- await userEvent.keyboard('{End}');
- await expectFilledEdges();
- await expect(await menu.findByRole('option', { name: /vendor\/gamma/, selected: true })).toBeInTheDocument();
+ // Every connection is its own labelled group and the last group's model is
+ // reachable in the menu's accessibility tree. This drives the visual state;
+ // selection behaviour and scroll geometry are contracts left to focused
+ // tests / e2e, not asserted here.
+ const groups = await menu.findAllByRole('group');
+ await expect(groups.length).toBeGreaterThanOrEqual(7);
+ await menu.findByRole('option', { name: /vendor\/gamma/ });
},
};
@@ -439,11 +454,10 @@ export const LongModelNames: Story = {
name: /选择新任务模型|Choose a model for the new task/,
});
await userEvent.click(trigger);
- // Verifies the long-labelled model is reachable as a radio menu item. Whether the
- // long text truncates or wraps within the menu bounds is a visual check,
- // not asserted here.
+ // Verifies the long-id model is reachable as an option; the label
+ // ellipsizing at its start inside the capped popup is a visual check.
await within(document.body).findByRole('option', {
- name: /A very long model name that keeps going/,
+ name: /deepseek-v4-flash-0731/,
});
},
};
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org