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