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 07:36:33 UTC

(maka) branch feat/model-picker-list-restore created (now 80adca235a)

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a change to branch feat/model-picker-list-restore
in repository https://gitbox.apache.org/repos/asf/maka.git


      at 80adca235a feat(ui): restore list pickers for model and thinking selection

This branch includes the following new commits:

     new 80adca235a feat(ui): restore list pickers for model and thinking selection

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


(maka) 01/01: feat(ui): restore list pickers for model and thinking selection

Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch feat/model-picker-list-restore
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 80adca235a9f9f959e16bb31d24ad182fd72e95f
Author: AstroHan <le...@outlook.com>
AuthorDate: Thu Sep 17 15:36:15 2026 +0800

    feat(ui): restore list pickers for model and thinking selection
    
    The wheel picker (#5227) made configured models hard to scan once a
    workspace had many: three visible rows, no search, and a settle timer
    that committed whatever the wheel stopped on. Every model picker now
    shares Astryx Selector — searchable grouped list with the house
    end-check selected style — so settings, composer, new chat, and the
    collapsed WorkHub window all behave identically. The collapsed window
    uses presentation="bottom-sheet" because an anchored popover cannot
    fit inside it; the wheel component, its tests, and its CSS are gone.
    
    Model ids longer than the 340px popup cap (p99 of models.dev ids is
    44 chars) ellipsize at the start, since the ids that outgrow it are
    path-style (accounts/<provider>/models/<model>) whose tail is the
    actual model name.
    
    Generated with [Devin](https://devin.ai)
    
    Co-Authored-By: Devin <15...@users.noreply.github.com>
---
 apps/desktop/e2e/workhub-layout.spec.ts            |  92 +++----
 .../__tests__/session-health-recovery-flow.test.ts |  21 +-
 .../renderer/features/workhub/ui/workhub-root.tsx  |   1 +
 .../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 |  40 ++-
 apps/desktop/src/renderer/styles/workhub.css       |  21 +-
 .../stories/settings/settings-pages.stories.tsx    |  37 ++-
 apps/desktop/stories/workhub.stories.tsx           |  71 ++---
 .../composer-model-picker-recovery.test.tsx        |  55 ++--
 .../ui/src/__tests__/model-wheel-picker.test.tsx   | 155 -----------
 .../src/__tests__/use-pending-selection.test.tsx   | 141 ++++++++++
 packages/ui/src/chat-model-switcher.tsx            | 287 ++++++++++++---------
 packages/ui/src/composer.tsx                       |  14 +-
 packages/ui/src/index.ts                           |   1 -
 packages/ui/src/model-picker-internals.tsx         | 138 ++++++++++
 packages/ui/src/model-picker.tsx                   |  93 +++++--
 packages/ui/src/model-wheel-picker.tsx             | 249 ------------------
 packages/ui/src/styles.css                         |  18 --
 packages/ui/src/use-pending-selection.ts           |  76 ++++++
 packages/ui/stories/model-picker.stories.tsx       | 220 ++++++++++------
 23 files changed, 939 insertions(+), 869 deletions(-)

diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index 31b0669ae4..053cc97683 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -20,9 +20,9 @@
 import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend';
 import { awaitSendReady, COMPOSER_INPUT, expect, test, getWorkHubPage } from './fixtures';
 
-// The compact native window must contain the inline wheel and retain its
-// bottom anchor; native dragging must scroll the no-drag
-// wheel instead of moving the frameless window. A browser has neither boundary.
+// The compact native window must contain the model sheet inside its frame and
+// retain its bottom anchor; native dragging over the sheet must not move the
+// frameless window. A browser has neither boundary.
 test('WorkHub uses its coordination model and shared attachment composer', async ({ sessionLocalWindow: { page, app } }, testInfo) => {
   await page.evaluate(async () => {
     const { connections } = await window.maka.connections.getSnapshot();
@@ -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') });
@@ -256,75 +256,55 @@ test('WorkHub uses its coordination model and shared attachment composer', async
     }), origin);
   };
   await model.click();
-  const wheel = workhub.getByRole('listbox');
-  await expect(wheel).toBeVisible();
-  await expect(wheel.getByRole('option')).toHaveCount(3);
+  // The compact window swaps only the presentation: the same list opens as a
+  // bottom sheet inside the frame instead of growing the window.
+  const sheet = workhub.getByRole('dialog');
+  await expect(sheet).toBeVisible();
+  const options = sheet.getByRole('option');
+  await expect(options).toHaveCount(3);
   const modelChoices = await workhub.evaluate(async (id) => (await window.maka.connections.getSnapshot(id)).chatModelChoices, sessionId);
-  await expect(wheel.getByRole('option').locator('.maka-model-wheel-label')).toHaveText(modelChoices.map((choice) => choice.label));
+  await expect(options).toHaveText(modelChoices.map((choice) => choice.label));
   await expect(workhub.locator('.workHubHistory')).toBeHidden();
-  await expect.poll(() => workhub.evaluate(() => innerHeight)).toBeGreaterThan(compactHeight);
+  await expect.poll(() => workhub.evaluate(() => innerHeight)).toBe(compactHeight);
   await expect.poll(floatingBottom).toBe(anchoredBottom);
-  await expect.poll(() => workhub.evaluate(() => innerHeight)).toBeLessThanOrEqual(compactHeight + 132);
-  const expectWheelInsideWindow = () => expect.poll(() => wheel.evaluate((element) => {
+  const expectSheetInsideWindow = () => expect.poll(() => sheet.evaluate((element) => {
     const rect = element.getBoundingClientRect();
     return rect.top >= 0 && rect.left >= 0 && rect.bottom <= innerHeight && rect.right <= innerWidth;
   })).toBe(true);
-  await expectWheelInsideWindow();
-  await workhub.screenshot({ path: testInfo.outputPath('floating-composer-wheel.png') });
-  const expectSnappedSelection = async (index: number) => {
+  await expectSheetInsideWindow();
+  await workhub.screenshot({ path: testInfo.outputPath('floating-composer-sheet.png') });
+  const expectSessionModel = async (index: number) => {
     const choice = modelChoices[index]!;
-    const option = wheel.getByRole('option').nth(index);
-    await expect(option).toHaveAttribute('data-active', 'true');
-    await expect(option).toHaveAttribute('aria-selected', 'true');
-    await expect(wheel).toHaveAttribute('aria-busy', 'false');
     await expect.poll(() => workhub.evaluate(async (id) => {
       const session = await window.maka.workHub.getSession(id);
       return { connectionId: session.llmConnectionId, model: session.model };
     }, sessionId)).toEqual({ connectionId: choice.connectionId, model: choice.model });
     return choice;
   };
-  const initialIndex = await wheel.getByRole('option').evaluateAll((options) => options.findIndex((option) => option.getAttribute('aria-selected') === 'true'));
-  const scrollDirection = initialIndex < modelChoices.length - 1 ? 1 : -1;
-  await wheel.hover();
-  await workhub.mouse.wheel(0, scrollDirection * 30);
-  await expectSnappedSelection(initialIndex + scrollDirection);
-  await wheel.press('Escape');
-  await model.click();
-  await expect(wheel.getByRole('option')).toHaveCount(3);
-  await expect.poll(() => workhub.evaluate(() => innerHeight === Math.ceil(document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBe(true);
-  const dragInitialTop = await wheel.evaluate((element) => element.scrollTop);
+  // A native drag over the sheet must not move the frameless window; the
+  // sheet only tracks vertical drags, so a horizontal one stays put.
   const beforeDrag = await screenLayout();
-  const dragDistance = dragInitialTop > 0 ? 32 : -32;
-  const wheelBounds = (await wheel.boundingBox())!;
-  const dragX = wheelBounds.x + wheelBounds.width / 2;
-  const dragY = wheelBounds.y + wheelBounds.height / 2;
+  const sheetBounds = (await sheet.boundingBox())!;
+  const dragX = sheetBounds.x + sheetBounds.width / 2;
+  const dragY = sheetBounds.y + sheetBounds.height / 2;
   await workhub.mouse.move(dragX, dragY);
   await workhub.mouse.down();
-  await workhub.mouse.move(dragX, dragY + dragDistance, { steps: 8 });
-  await expect.poll(() => wheel.evaluate((element) => element.scrollTop)).toBe(dragInitialTop - dragDistance);
+  await workhub.mouse.move(dragX + 32, dragY, { steps: 8 });
   await workhub.mouse.up();
   await expect.poll(screenLayout).toEqual(beforeDrag);
-  await expect(wheel).toBeVisible();
-  const draggedIndex = Math.round((dragInitialTop - dragDistance + 44) / 44) % modelChoices.length;
-  await expectSnappedSelection(draggedIndex);
-  await wheel.press('ArrowDown');
-  await expectSnappedSelection((draggedIndex + 1) % modelChoices.length);
-  await wheel.press('Escape');
-  await expect(wheel).toHaveCount(0);
+  await expect(sheet).toBeVisible();
+  const currentIndex = await options.evaluateAll((options) => options.findIndex((option) => option.getAttribute('aria-selected') === 'true'));
+  const targetIndex = (currentIndex + 1) % modelChoices.length;
+  await options.nth(targetIndex).click();
+  const selectedChoice = await expectSessionModel(targetIndex);
+  await expect(sheet).toHaveCount(0);
   await expect(model).toBeFocused();
   await model.click();
-  const options = wheel.getByRole('option');
-  const chooseLast = await options.first().getAttribute('aria-selected') === 'true';
-  await wheel.press(chooseLast ? 'End' : 'Home');
-  const selectedChoice = await expectSnappedSelection(chooseLast ? modelChoices.length - 1 : 0);
-  await expectWheelInsideWindow();
-  await wheel.press('Enter');
-  await expect(wheel).toHaveCount(0);
+  await expect(sheet.getByRole('option', { selected: true })).toContainText(selectedChoice.label);
+  await expectSheetInsideWindow();
+  await sheet.press('Escape');
+  await expect(sheet).toHaveCount(0);
   await expect(model).toBeFocused();
-  await expect.poll(() => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).toBe(selectedChoice.model);
-  await model.click();
-  await expect(wheel.getByRole('option', { selected: true })).toContainText(selectedChoice.label);
-  await wheel.press('Escape');
   await expect.poll(() => workhub.evaluate(() => innerHeight)).toBe(compactHeight);
   await expect(editor).toHaveText('Keep this draft while folding the conversation.');
   const longDraft = Array.from({ length: 30 }, (_, index) => `第 ${index + 1} 行:长输入应当只在编辑区内滚动。`).join('\n');
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..35feedc165 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,7 @@ export function WorkHubRoot() {
               activeModelConnectionId={session?.llmConnectionId}
               activeModelConnectionSlug={session?.llmConnectionSlug}
               modelChoices={controller.choices}
+              pickerPresentation={showConversation ? 'popover' : 'bottom-sheet'}
               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 a8673d0b5e..9343790cfc 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,
@@ -604,19 +604,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..b310860989 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 a ghost-button DropdownMenu; 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 swaps the popover
+   for a bottom sheet that stays inside it. */
 
 .maka-model-picker-root {
   display: inline-flex;
@@ -77,3 +82,34 @@
   width: 100%;
   height: 100%;
 }
+.modelPickerOption {
+  min-width: 0;
+}
+
+/* The popover sizes to its widest row, so without a cap one long id stretches
+   it past the viewport and the row clips instead of ellipsizing. Bound the
+   model pickers' popups — scoped by the option class renderModelPickerOption
+   emits. At this width ~44 chars fit, which covers p99 of models.dev ids
+   (p90=32, p99=44); the rare longer ones are path-style ids whose tail holds
+   the model name, so the label ellipsizes at the start. */
+.astryx-selector-popup:has(.modelPickerOption) {
+  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..09c5a2b740 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -158,25 +158,12 @@
   .workHubLiveError { padding: var(--space-3); }
   .workHubComposerSurface { position: relative; width: 100%; }
   .workHubComposerContent { position: relative; }
-  /* Compact WorkHub keeps its original inline wheel footprint. Other hosts
-     keep the button footprint and paint the same wheel over that position. */
-  .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.
+     In the collapsed window both pickers present as bottom sheets instead. */
+  .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 +220,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 c181b90946..b0d504d550 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.
@@ -294,16 +260,13 @@ export const ProgressModelPicker: Story = {
     const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement;
     await userEvent.click(editor);
     await userEvent.type(editor, 'Keep this draft readable while choosing a model.');
+    const page = within(canvasElement.ownerDocument.body);
     const trigger = await canvas.findByRole('button', { name: /切换当前任务模型/ });
     await userEvent.click(trigger);
-    const wheel = await canvas.findByRole('listbox');
-    await waitFor(() => expect(wheel).toHaveFocus());
-    const surface = wheel.closest('.maka-model-wheel-expanded')!;
-    const pixels = new OffscreenCanvas(1, 1).getContext('2d')!;
-    pixels.fillStyle = getComputedStyle(surface).backgroundColor;
-    pixels.fillRect(0, 0, 1, 1);
-    expect(pixels.getImageData(0, 0, 1, 1).data[3]).toBeGreaterThanOrEqual(230);
-    expect(pixels.getImageData(0, 0, 1, 1).data[3]).toBeLessThan(255);
+    // The collapsed window's picker is the same Selector list as everywhere
+    // else, presented as a top-layer bottom sheet.
+    const listbox = await page.findByRole('listbox');
+    await waitFor(() => expect(listbox.querySelector('[role="option"][aria-selected="true"]')).not.toBeNull());
     expect(editor).toHaveTextContent('Keep this draft readable while choosing a model.');
   },
 };
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..00cfd9f40c 100644
--- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
+++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
@@ -67,13 +67,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 +96,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 +156,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 +172,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 +196,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,24 +211,17 @@ 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);
diff --git a/packages/ui/src/__tests__/model-wheel-picker.test.tsx b/packages/ui/src/__tests__/model-wheel-picker.test.tsx
deleted file mode 100644
index 020d472716..0000000000
--- a/packages/ui/src/__tests__/model-wheel-picker.test.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-/*
- * 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, useState } from 'react';
-import { createRoot } from 'react-dom/client';
-import { parseHTML } from 'linkedom';
-import { ModelWheelPicker } from '../model-wheel-picker.js';
-
-test('ordinary rerenders keep the layer anchor attached', async () => {
-  const original = {
-    document: globalThis.document,
-    window: globalThis.window,
-    IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & {
-      IS_REACT_ACT_ENVIRONMENT?: boolean;
-    }).IS_REACT_ACT_ENVIRONMENT,
-  };
-  const { document, window } = parseHTML('<div id="root"></div>');
-  window.getComputedStyle = () => ({ direction: 'ltr', writingMode: 'horizontal-tb',
-    getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration;
-  const styleDescriptor = Object.getOwnPropertyDescriptor(window.Element.prototype, 'style')!;
-  const styleProxies = new WeakMap<Element, CSSStyleDeclaration>();
-  const anchorNameWrites: string[] = [];
-  let recordAnchorNameWrites = false;
-  Object.defineProperty(window.Element.prototype, 'style', {
-    configurable: true,
-    get(this: Element) {
-      const existing = styleProxies.get(this);
-      if (existing) return existing;
-      const style = styleDescriptor.get!.call(this) as CSSStyleDeclaration;
-      const proxy = new Proxy(style, {
-        set: (target, property, value) => {
-          if (recordAnchorNameWrites && property === 'anchorName'
-            && this.classList.contains('maka-model-wheel-anchor')) {
-            anchorNameWrites.push(String(value));
-          }
-          return Reflect.set(target, property, value);
-        },
-      });
-      styleProxies.set(this, proxy);
-      return proxy;
-    },
-  });
-  Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true });
-  const root = createRoot(document.querySelector('#root')!);
-  const options = ['A', 'B'].map((value) => ({ value, label: value }));
-  try {
-    await act(() => root.render(<ModelWheelPicker open options={options} value="A" label="A"
-      ariaLabel="Model" onValueChange={() => {}} />));
-    recordAnchorNameWrites = true;
-
-    await act(() => root.render(<ModelWheelPicker open options={options} value="A" label="renamed"
-      ariaLabel="Model" onValueChange={() => {}} />));
-
-    assert.deepEqual(anchorNameWrites, [], 'rerendering must not remove and restore the CSS anchor name');
-  } finally {
-    await act(() => root.unmount());
-    Object.defineProperty(window.Element.prototype, 'style', styleDescriptor);
-    Object.assign(globalThis, original);
-  }
-});
-
-test('the wheel applies settled selection once and restores the saved model on failure', async () => {
-  const original = {
-    document: globalThis.document,
-    window: globalThis.window,
-    IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & {
-      IS_REACT_ACT_ENVIRONMENT?: boolean;
-    }).IS_REACT_ACT_ENVIRONMENT,
-  };
-  const { document, window } = parseHTML('<div id="root"></div>');
-  window.getComputedStyle = () => ({ direction: 'ltr', writingMode: 'horizontal-tb',
-    getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration;
-  Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true });
-  const root = createRoot(document.querySelector('#root')!);
-  const calls: string[] = [];
-  let finish: ((success: boolean) => void) | undefined;
-  const options = ['A', 'B', 'C'].map((value) => ({ value, label: value }));
-  function Harness({ disabled = false }: { disabled?: boolean }) {
-    const [value, setValue] = useState('B');
-    return <ModelWheelPicker open options={options} value={value} label={value}
-      ariaLabel="Model" disabled={disabled} onValueChange={(next) => {
-        calls.push(next);
-        return new Promise<void>((resolve, reject) => {
-          finish = (success) => {
-            if (success) { setValue(next); resolve(); }
-            else reject(new Error('save failed'));
-          };
-        });
-      }} />;
-  }
-  const event = (type: string) => new window.Event(type, { bubbles: true, cancelable: true });
-  const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 220)); });
-  try {
-    await act(() => root.render(<Harness />));
-    const wheel = document.querySelector<HTMLElement>('[role="listbox"]')!;
-    assert.equal(wheel.scrollTop, 132, 'opening centers the current model');
-    await act(() => wheel.dispatchEvent(event('scroll')));
-    await settle();
-    assert.deepEqual(calls, [], 'initial positioning never changes the model');
-
-    await act(() => {
-      wheel.dispatchEvent(event('wheel'));
-      wheel.scrollTop = 176;
-      wheel.dispatchEvent(event('scroll'));
-    });
-    await settle();
-    assert.deepEqual(calls, ['C'], 'scrolling applies the snapped model without a click');
-    assert.equal(wheel.getAttribute('aria-busy'), 'true');
-    assert.equal(wheel.querySelector('[aria-selected="true"]')?.textContent, 'C');
-    await act(() => {
-      wheel.dispatchEvent(event('scrollend'));
-      wheel.dispatchEvent(Object.assign(event('keydown'), { key: 'Home' }));
-    });
-    await settle();
-    assert.deepEqual(calls, ['C'], 'pending saves cannot overlap');
-    await act(async () => { finish?.(true); });
-    assert.equal(wheel.getAttribute('aria-busy'), 'false');
-    assert.equal(wheel.querySelector('[aria-selected="true"]')?.textContent, 'C');
-
-    await act(() => wheel.dispatchEvent(Object.assign(event('keydown'), { key: 'Home' })));
-    assert.deepEqual(calls, ['C', 'A'], 'keyboard navigation also applies immediately');
-    await act(async () => { finish?.(false); });
-    assert.equal(wheel.scrollTop, 176, 'a failed save recenters the authoritative value');
-    assert.equal(wheel.querySelector('[aria-selected="true"]')?.textContent, 'C');
-
-    await act(() => wheel.dispatchEvent(Object.assign(event('keydown'), { key: 'ArrowDown' })));
-    assert.deepEqual(calls, ['C', 'A', 'A'], 'the last model wraps to its visible next neighbor');
-    await act(async () => { finish?.(true); });
-
-    await act(() => root.render(<Harness disabled />));
-    await act(() => wheel.dispatchEvent(Object.assign(event('keydown'), { key: 'Home' })));
-    assert.deepEqual(calls, ['C', 'A', 'A'], 'disabled navigation does not change the model');
-  } finally {
-    await act(() => root.unmount());
-    Object.assign(globalThis, original);
-  }
-});
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..3a4b4321ff
--- /dev/null
+++ b/packages/ui/src/__tests__/use-pending-selection.test.tsx
@@ -0,0 +1,141 @@
+/*
+ * 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;
+  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,
+    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('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..b267095c76 100644
--- a/packages/ui/src/chat-model-switcher.tsx
+++ b/packages/ui/src/chat-model-switcher.tsx
@@ -17,67 +17,64 @@
  * 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. `presentation="bottom-sheet"` keeps the list inside the
+ * collapsed WorkHub window, where an anchored popover cannot fit.
+ */
 
-import { type ReactNode, useMemo, useState } from 'react';
+import { type ReactNode, useMemo } 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 { ICON_SIZE, Settings } from './icons.js';
 import {
   type ChatModelChoice,
-  type ModelMenuGroup,
   exactModelChoiceValue,
-  modelChoiceDescription,
   modelMenuGroups,
 } from './chat-model-helpers.js';
+import {
+  buildModelPickerOptions,
+  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" />;
-
-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),
-  })));
-}
+const exactChoiceValue = (choice: ChatModelChoice) =>
+  exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model);
 
 /**
- * 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';
   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;
@@ -96,44 +93,30 @@ export function ThinkingLevelSelector(props: {
   const currentLabel = options.find((option) => option.value === currentValue)?.label ?? copy.defaultLevel;
 
   return (
-    <DropdownMenu
+    <Selector
+      label={`${copy.thinkingLevel}: ${currentLabel}`}
+      isLabelHidden
+      options={options}
+      value={currentValue}
+      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}`,
+      presentation={props.presentation}
+      isDisabled={props.disabled}
+      disabledMessage={props.disabledReason}
+      className="maka-thinking-level-selector"
+      onChange={(value) => {
+        void props.onChange?.(
+          value === DEFAULT_THINKING_LEVEL ? undefined : (value as ThinkingLevel),
+        );
       }}
-    >
-      <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>
+    />
   );
 }
 
 export function ChatModelSwitcher(props: {
+  /** 'popover' anchors to the trigger; 'bottom-sheet' stays inside compact windows like the collapsed WorkHub. */
+  presentation?: 'popover' | 'bottom-sheet';
   activeSession: SessionSummary;
   activeModelConnectionId?: string;
   activeModelConnectionSlug?: string;
@@ -144,9 +127,13 @@ 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;
   /** Hide a stale display-only row while the Session requires identity recovery. */
   hideUnavailableCurrentOption?: boolean;
   renderProviderMark?(type: ProviderType): ReactNode;
@@ -158,6 +145,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,49 +157,76 @@ 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, announced 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]);
 
-  const options = wheelOptions(grouped, locale);
-  if (!currentKnownChoice && currentValue && !props.hideUnavailableCurrentOption) {
-    options.unshift({ value: currentValue, label: displayLabel, disabled: true });
-  }
-  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>
-  </>;
+  // 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. */ }
+  });
+
+  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}
+      disabledMessage={props.disabledReason}
+      isDefaultOpen={Boolean(props.openNonce)}
+      placeholder={displayLabel}
+      className="maka-model-switcher-trigger"
+      onChange={selection.onChange}
+      renderOption={renderModelPickerOption}
+      renderValue={renderModelPickerValue}
+    />
+  );
 }
 
 /**
@@ -237,25 +252,55 @@ 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 = useMemo(
+    () => buildModelPickerOptions(
+      grouped,
+      !currentKnownChoice && currentValue
+        ? { label: props.label, value: currentValue, providerType: props.currentProviderType }
+        : undefined,
+      exactChoiceValue,
+      { locale, renderProviderMark: props.renderProviderMark },
+    ),
+    [grouped, currentKnownChoice, currentValue, props.label, props.currentProviderType,
+      locale, props.renderProviderMark],
+  );
+  const selection = usePendingSelection(currentValue, async (value) => {
+    const choice = choiceByValue.get(value);
+    if (!choice) return;
+    await props.onPick({
+      llmConnectionId: choice.connectionId,
+      llmConnectionSlug: choice.connectionSlug,
+      model: choice.model,
+    });
+  });
+  return (
+    <Selector
+      label={copy.newChatAriaLabel(props.label)}
+      isLabelHidden
+      options={options}
+      value={selection.value}
+      hasSearch
+      searchPlaceholder={searchPlaceholder}
+      variant="ghost"
+      size="sm"
+      placement="above"
+      placeholder={props.label}
+      className="maka-new-chat-model-selector"
+      onChange={selection.onChange}
+      renderOption={renderModelPickerOption}
+      renderValue={renderModelPickerValue}
+    />
   );
-  const options = wheelOptions(grouped, locale);
-  if (!currentKnownChoice && currentValue) {
-    options.unshift({ value: currentValue, label: props.label, disabled: true });
-  }
-  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 });
-    }} />;
 }
 
 /**
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index 879b161a99..6b631f33f4 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -352,6 +352,8 @@ export const Composer = forwardRef<
     activeModelLabel?: string;
     activeProviderType?: ProviderType;
     modelChoices?: ChatModelChoice[];
+    /** Picker surface for the model and thinking pickers; compact windows that cannot fit an anchored popup use 'bottom-sheet'. */
+    pickerPresentation?: 'popover' | 'bottom-sheet';
     /** 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 +536,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 +545,7 @@ export const Composer = forwardRef<
     });
   const modelSwitchAvailabilityRef = useRef(modelSwitchAvailability);
   modelSwitchAvailabilityRef.current = modelSwitchAvailability;
-  useLayoutEffect(() => setModelPickerOpen(false), [props.activeSession?.id]);
+  useLayoutEffect(() => setModelPickerNonce(0), [props.activeSession?.id]);
   const [pendingImportAction, setPendingImportAction] = useState<ComposerImportActionId | null>(null);
   const composerMountedRef = useMountedRef();
   const sendPendingRef = useRef(false);
@@ -1259,7 +1261,7 @@ export const Composer = forwardRef<
       },
       openModelPicker() {
         if (!modelSwitchAvailabilityRef.current.available) return;
-        setModelPickerOpen(true);
+        setModelPickerNonce((nonce) => nonce + 1);
       },
     }),
     [],
@@ -2121,6 +2123,7 @@ export const Composer = forwardRef<
               <div className="maka-model-selection-controls">
                 {props.activeSession ? (
                   <ChatModelSwitcher
+                    presentation={props.pickerPresentation}
                     activeSession={props.activeSession}
                     activeModelConnectionId={props.activeModelConnectionId}
                     activeModelConnectionSlug={props.activeModelConnectionSlug}
@@ -2131,8 +2134,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}
@@ -2165,6 +2167,7 @@ export const Composer = forwardRef<
                   <ThinkingLevelSelector
                     levels={props.activeThinkingLevels ?? []}
                     current={props.activeThinkingLevel}
+                    presentation={props.pickerPresentation}
                     onChange={props.onThinkingLevelChange}
                     disabled={!modelSwitchAvailability.available}
                     disabledReason={thinkingSwitcherDisabledReason}
@@ -2173,6 +2176,7 @@ export const Composer = forwardRef<
                   <ThinkingLevelSelector
                     levels={props.newChatThinkingLevels ?? []}
                     current={props.newChatThinkingLevel}
+                    presentation={props.pickerPresentation}
                     onChange={props.onNewChatThinkingLevelChange}
                   />
                 )}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index bcc5ec32c6..b4bdf282eb 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -56,7 +56,6 @@ export * from './live-turn-buffer.js';
 export * from './transcript-projection.js';
 export * from './use-transcript-projection.js';
 export * from './model-picker.js';
-export * from './model-wheel-picker.js';
 export * from './interaction-queue.js';
 export * from './user-question-prompt.js';
 export * from './user-question-prompt-state.js';
diff --git a/packages/ui/src/model-picker-internals.tsx b/packages/ui/src/model-picker-internals.tsx
new file mode 100644
index 0000000000..3e68b272d8
--- /dev/null
+++ b/packages/ui/src/model-picker-internals.tsx
@@ -0,0 +1,138 @@
+/*
+ * 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 SelectorDivider,
+  type SelectorOptionData,
+} from '@astryxdesign/core/Selector';
+import type { ProviderType } from '@maka/core/llm-connections';
+import type { UiLocale } from '@maka/core/ui-locale';
+import {
+  type ModelMenuGroup,
+  modelChoiceDescription,
+  modelChoiceValue,
+} from './chat-model-helpers.js';
+
+export type ModelPickerOption = SelectorOptionData;
+
+export interface ModelPickerSection {
+  type: 'section';
+  title: string;
+  options: ModelPickerOption[];
+}
+
+export type ModelPickerSelectorOption = ModelPickerOption | SelectorDivider | ModelPickerSection;
+
+export interface ModelPickerLeadingOption {
+  value: string;
+  label: string;
+  providerType?: ProviderType;
+  disabled?: boolean;
+}
+
+type ModelChoiceValueFn = (choice: ModelMenuGroup['choices'][number]) => string;
+
+/**
+ * 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;
+  },
+): ModelPickerSelectorOption[] {
+  const providerIcon = (type: ProviderType | undefined) =>
+    type && extras.renderProviderMark ? (
+      <span className="modelPickerProviderMark" data-provider={type} aria-hidden="true">
+        {extras.renderProviderMark(type)}
+      </span>
+    ) : undefined;
+  const sections: ModelPickerSection[] = groups.map((group) => ({
+    type: 'section',
+    title: group.heading,
+    options: group.choices.map((choice) => ({
+      value: toValue(choice),
+      label: choice.label,
+      icon: providerIcon(group.providerType),
+      description: modelChoiceDescription(choice, extras.locale),
+    })),
+  }));
+
+  if (!leadingOption) return sections;
+
+  const option: ModelPickerOption = {
+    value: leadingOption.value,
+    label: leadingOption.label,
+    icon: providerIcon(leadingOption.providerType),
+    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} />} />
+  );
+}
+
+export const modelChoiceValueForPicker: ModelChoiceValueFn = (choice) =>
+  modelChoiceValue(choice.connectionSlug, choice.model);
diff --git a/packages/ui/src/model-picker.tsx b/packages/ui/src/model-picker.tsx
index d1ad954e14..59b327f2c0 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,
+  modelChoiceValueForPicker,
+  renderModelPickerOption,
+  renderModelPickerValue,
+  type ModelPickerLeadingOption,
+} from './model-picker-internals.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,59 @@ 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;
 }
 
 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, modelChoiceValueForPicker, {
+        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/model-wheel-picker.tsx b/packages/ui/src/model-wheel-picker.tsx
deleted file mode 100644
index 73331f3d11..0000000000
--- a/packages/ui/src/model-wheel-picker.tsx
+++ /dev/null
@@ -1,249 +0,0 @@
-/*
- * 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 CSSProperties, type ReactNode, useCallback, useId, useLayoutEffect, useRef, useState } from 'react';
-import { Button, useLayer } from '@astryxdesign/core';
-import { Check, ICON_SIZE } from './icons.js';
-
-export interface ModelWheelOption {
-  value: string;
-  label: string;
-  heading?: string;
-  description?: string;
-  disabled?: boolean;
-}
-
-/** The trigger becomes a wheel in place; the settled model takes effect. */
-export function ModelWheelPicker(props: {
-  options: readonly ModelWheelOption[];
-  value?: string;
-  label: string;
-  ariaLabel: string;
-  icon?: ReactNode;
-  tooltip?: string;
-  triggerClassName?: string;
-  size?: 'sm' | 'md';
-  disabled?: boolean;
-  open?: boolean;
-  onOpenChange?(open: boolean): void;
-  onValueChange(value: string): void | Promise<void>;
-}) {
-  const [internalOpen, setInternalOpen] = useState(false);
-  const trigger = useRef<HTMLButtonElement>(null);
-  const anchor = useRef<HTMLSpanElement>(null);
-  const open = props.open ?? internalOpen;
-  const height = ROW_HEIGHT * (props.options.length > 1 ? 3 : 1);
-  const setOpen = (next: boolean) => {
-    if (props.open === undefined) setInternalOpen(next);
-    props.onOpenChange?.(next);
-  };
-  const close = (restoreFocus: boolean) => {
-    setOpen(false);
-    if (restoreFocus) requestAnimationFrame(() => trigger.current?.focus({ preventScroll: true }));
-  };
-  const layer = useLayer({ mode: 'context', lazyMount: true, lightDismiss: true,
-    onHide: () => setOpen(false) });
-  const positionRef = useRef(layer.ref).current;
-  const anchorRef = useCallback((element: HTMLSpanElement | null) => {
-    anchor.current = element;
-    positionRef(element);
-  }, [positionRef]);
-  useLayoutEffect(() => {
-    if (open) layer.show();
-    else layer.hide();
-  }, [open, layer.show, layer.hide]);
-  useLayoutEffect(() => {
-    const element = anchor.current;
-    if (!open || !element) return;
-    // Saving another model can change the label while the wheel is open.
-    // Keep its anchor's footprint stable throughout that interaction.
-    element.style.width = `${element.getBoundingClientRect().width}px`;
-    return () => { element.style.removeProperty('width'); };
-  }, [open]);
-  return <span ref={anchorRef}
-    className="maka-model-wheel-anchor" data-open={open}
-    style={{ '--maka-model-wheel-height': `${height}px` } as CSSProperties}>
-    <Button ref={trigger} type="button" variant="ghost" size={props.size ?? 'sm'}
-    label={props.label} icon={props.icon} tooltip={open ? undefined : props.tooltip}
-    isDisabled={props.disabled || props.options.length === 0}
-    className={props.triggerClassName} aria-label={props.ariaLabel}
-    aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? layer.id : undefined}
-    onClick={() => setOpen(true)}
-    onKeyDown={(event) => {
-      if (!props.disabled && props.options.length > 0 && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
-        event.preventDefault(); event.stopPropagation(); setOpen(true);
-      }
-    }} />
-    {layer.render(open && layer.isOpen ? <ModelWheel {...props} onClose={close} /> : null,
-      { positioning: 'custom', className: 'maka-model-wheel-expanded', style: {
-        position: 'fixed', inset: 'auto',
-        top: `clamp(0px, calc(anchor(center) - ${height / 2}px), calc(100dvh - ${height}px))`,
-        left: 'clamp(0px, anchor(left), calc(100vw - min(264px, 55vw)))',
-      } })}
-  </span>;
-}
-
-const ROW_HEIGHT = 44;
-
-function ModelWheel(props: Parameters<typeof ModelWheelPicker>[0] & {
-  onClose(restoreFocus: boolean): void;
-}) {
-  const id = useId();
-  const viewport = useRef<HTMLDivElement>(null);
-  const drag = useRef<{ pointerId: number; startY: number; scrollTop: number; moved: boolean } | undefined>(undefined);
-  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
-  const interacted = useRef(false);
-  const saving = useRef(false);
-  const [pending, setPending] = useState<string | null>(null);
-  const currentValue = pending ?? props.value;
-  const count = props.options.length;
-  const height = ROW_HEIGHT * (count > 1 ? 3 : 1);
-  const inset = (height - ROW_HEIGHT) / 2;
-  // Neighboring copies let the first and last models have real neighbors,
-  // rather than empty padding. Only the middle copy is exposed to assistive technology.
-  const copies = count > 1 ? [0, 1, 2] : [1];
-  const centeredTop = (index: number) => (count > 1 ? count + index : 0) * ROW_HEIGHT - inset;
-  const selectedIndex = Math.max(0, props.options.findIndex((option) => option.value === currentValue));
-  const [preview, setPreview] = useState(selectedIndex);
-  const latest = useRef(props);
-  latest.current = props;
-  const disabled = props.disabled || pending !== null;
-  const rowAt = (element: HTMLDivElement) => Math.round((element.scrollTop + inset) / ROW_HEIGHT);
-  const indexAt = (element: HTMLDivElement) => count ? ((rowAt(element) % count) + count) % count : 0;
-  const clearTimer = () => clearTimeout(timer.current);
-  const pick = async (index: number) => {
-    const option = latest.current.options[index];
-    if (!option || option.disabled || latest.current.disabled || saving.current || option.value === latest.current.value) return;
-    saving.current = true;
-    setPending(option.value);
-    try {
-      await latest.current.onValueChange(option.value);
-    } catch { /* The action owner reports save failures. */ }
-    finally { saving.current = false; setPending(null); }
-  };
-  const settle = (element: HTMLDivElement) => {
-    clearTimer();
-    if (!interacted.current || element.dataset.dragging || saving.current) return;
-    const index = indexAt(element);
-    if (Math.abs(element.scrollTop - (rowAt(element) * ROW_HEIGHT - inset)) > 1) return;
-    interacted.current = false;
-    element.scrollTop = centeredTop(index);
-    void pick(index);
-  };
-  const moveTo = (element: HTMLDivElement, index: number) => {
-    clearTimer();
-    interacted.current = false;
-    const next = count ? ((index % count) + count) % count : 0;
-    element.scrollTop = centeredTop(next);
-    setPreview(next);
-    void pick(next);
-  };
-  const finishDrag = (element: HTMLDivElement) => {
-    if (!element.dataset.dragging) return;
-    delete element.dataset.dragging;
-    // Keep `moved` until the synthetic click has been suppressed.
-    const next = rowAt(element);
-    element.scrollTo({ top: next * ROW_HEIGHT - inset, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' });
-    clearTimer();
-    timer.current = setTimeout(() => settle(element), 180);
-  };
-  useLayoutEffect(() => {
-    if (!viewport.current) return;
-    interacted.current = false;
-    clearTimer();
-    viewport.current.scrollTop = centeredTop(selectedIndex);
-    setPreview(selectedIndex);
-  }, [selectedIndex, pending, props.value, props.options.length]);
-  useLayoutEffect(() => {
-    viewport.current?.focus({ preventScroll: true });
-    return clearTimer;
-  }, []);
-
-  return <div className="maka-model-wheel">
-    <div ref={viewport} className="maka-model-wheel-viewport" role="listbox" tabIndex={0}
-      aria-label={props.ariaLabel} aria-disabled={disabled} aria-busy={pending !== null}
-      aria-activedescendant={props.options.length ? `${id}-${preview}` : undefined}
-      style={{ height }}
-      onBlur={(event) => {
-        if (event.currentTarget.contains(event.relatedTarget)) return;
-        if (interacted.current) void pick(indexAt(event.currentTarget));
-        props.onClose(false);
-      }}
-      onWheel={() => { if (!disabled) interacted.current = true; }}
-      onTouchStart={() => { if (!disabled) interacted.current = true; }}
-      onScroll={(event) => {
-        const element = event.currentTarget;
-        setPreview(indexAt(element));
-        clearTimer();
-        timer.current = setTimeout(() => settle(element), 180);
-      }}
-      onScrollEnd={(event) => settle(event.currentTarget)}
-      onPointerDown={(event) => {
-        drag.current = undefined;
-        if (disabled || event.button !== 0 || event.pointerType !== 'mouse') return;
-        drag.current = { pointerId: event.pointerId, startY: event.clientY, scrollTop: event.currentTarget.scrollTop, moved: false };
-      }}
-      onPointerMove={(event) => {
-        const gesture = drag.current;
-        if (disabled || !gesture || gesture.pointerId !== event.pointerId || !(event.buttons & 1)) return;
-        const distance = event.clientY - gesture.startY;
-        if (!gesture.moved && Math.abs(distance) < 5) return;
-        if (!gesture.moved) {
-          gesture.moved = true;
-          event.currentTarget.setPointerCapture(event.pointerId);
-          event.currentTarget.dataset.dragging = 'true';
-        }
-        interacted.current = true;
-        event.preventDefault();
-        event.currentTarget.scrollTop = gesture.scrollTop - distance;
-      }}
-      onPointerUp={(event) => finishDrag(event.currentTarget)}
-      onPointerCancel={(event) => { finishDrag(event.currentTarget); drag.current = undefined; }}
-      onLostPointerCapture={(event) => finishDrag(event.currentTarget)}
-      onClickCapture={(event) => {
-        if (drag.current?.moved) { event.preventDefault(); event.stopPropagation(); }
-        drag.current = undefined;
-      }}
-      onKeyDown={(event) => {
-        if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); props.onClose(true); return; }
-        if (event.key === 'Enter' || event.key === ' ') {
-          event.preventDefault(); event.stopPropagation();
-          if (!disabled) moveTo(event.currentTarget, preview);
-          props.onClose(true);
-          return;
-        }
-        if (disabled) return;
-        const next = event.key === 'ArrowDown' ? preview + 1 : event.key === 'ArrowUp' ? preview - 1 : event.key === 'Home' ? 0 : event.key === 'End' ? props.options.length - 1 : undefined;
-        if (next === undefined) return;
-        event.preventDefault(); event.stopPropagation(); moveTo(event.currentTarget, next);
-      }}>
-      {copies.flatMap((copy) => props.options.map((option, index) => <div key={`${copy}-${option.value}`} id={copy === 1 ? `${id}-${index}` : undefined}
-        className="maka-model-wheel-option" role={copy === 1 ? 'option' : undefined} aria-hidden={copy !== 1 || undefined}
-        aria-selected={copy === 1 ? option.value === currentValue : undefined}
-        data-active={index === preview} aria-disabled={disabled || option.disabled}
-        style={{ height: ROW_HEIGHT }} title={[option.label, option.heading, option.description].filter(Boolean).join(' · ')}
-        onMouseDown={(event) => event.preventDefault()}
-        onClick={() => { if (!disabled && viewport.current) moveTo(viewport.current, index); }}>
-        <span className="maka-model-wheel-label">{option.label}</span>
-        {option.heading && <span className="maka-model-wheel-provider">{option.heading}</span>}
-        {option.value === currentValue && <span className="maka-model-wheel-check"><Check size={ICON_SIZE.control} aria-hidden="true" /></span>}
-      </div>))}
-    </div>
-  </div>;
-}
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index 891c4f2a41..521a63fc81 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -19,24 +19,6 @@
 
 @import 'katex/dist/katex.min.css';
 
-.maka-model-wheel-anchor { display: inline-flex; min-width: 0; max-width: 100%; }
-.maka-model-wheel-anchor > button { min-width: 0; max-width: 100%; }
-.maka-model-wheel-anchor[data-open='true'] > button { visibility: hidden; }
-.maka-model-wheel-expanded { border-radius: 10px; background: color-mix(in oklch, var(--background-elevated) 94%, transparent); backdrop-filter: blur(12px); }
-.maka-model-wheel { position: relative; width: min(264px, 55vw); max-width: 100%; }
-.maka-model-wheel::before { content: ''; position: absolute; inset: 50% 0 auto; height: 44px; translate: 0 -50%; border-radius: 10px; background: color-mix(in oklch, var(--foreground) 6%, transparent); pointer-events: none; }
-.maka-model-wheel-viewport { position: relative; box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scrollbar-width: none; mask-image: linear-gradient(transparent, black 32%, black 68%, transparent); outline: none; }
-.maka-model-wheel-viewport:focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: calc(-1 * var(--focus-ring-width)); }
-.maka-model-wheel-viewport[data-dragging='true'] { scroll-snap-type: none; cursor: grabbing; }
-.maka-model-wheel-viewport[data-dragging='true'] .maka-model-wheel-option { cursor: grabbing; }
-.maka-model-wheel-option { position: relative; display: flex; flex-direction: column; justify-content: center; padding: 0 28px 0 12px; scroll-snap-align: center; scroll-snap-stop: always; cursor: pointer; user-select: none; }
-.maka-model-wheel-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--muted-foreground); transform: scale(0.96); transform-origin: left center; transition: transform 180ms ease-out, color 180ms ease-out; }
-.maka-model-wheel-option[data-active='true'] .maka-model-wheel-label { font-weight: 600; color: var(--foreground); transform: scale(1); }
-.maka-model-wheel-viewport[aria-disabled='true'] { opacity: 0.5; overflow-y: hidden; }
-@media (prefers-reduced-motion: reduce) { .maka-model-wheel-label { transition: none; } }
-.maka-model-wheel-provider { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: var(--muted-foreground); }
-.maka-model-wheel-check { position: absolute; right: 8px; top: 50%; translate: 0 -50%; color: var(--muted-foreground); }
-
 /* Maka product compositions shared across desktop renderer surfaces. Astryx
    owns generic component chrome; these selectors own only product semantics. */
 
diff --git a/packages/ui/src/use-pending-selection.ts b/packages/ui/src/use-pending-selection.ts
new file mode 100644
index 0000000000..c067045661
--- /dev/null
+++ b/packages/ui/src/use-pending-selection.ts
@@ -0,0 +1,76 @@
+/*
+ * 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; the pick clears when that write
+   * settles — by when the authoritative `value` has caught up on success, or
+   * falling back to it on failure.
+   */
+  onChange(next: string): 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);
+  const onChange = useCallback(
+    (next: string) => {
+      const token = (tokenRef.current += 1);
+      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) setPending(null);
+      };
+      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..d41e41b52a 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/,
     });
   },
 };
@@ -495,3 +509,65 @@ export const StaleCurrentModel: Story = {
     await menu.findByRole('option', { name: /Claude Sonnet 4/ });
   },
 };
+
+// The collapsed floating WorkHub window is ~96px tall and cannot fit a popup,
+// so its model picker presents as a bottom sheet pinned inside the window —
+// the same searchable grouped list as everywhere else. The frame carries the
+// production `workHubLive` collapsed/floating data attributes so the same CSS
+// applies as in the real window.
+const COLLAPSED_ACTIVE = MANY_CHOICES[4]!;
+
+function CollapsedWindowFrame(props: { height?: number; caption: string; children: ReactNode }) {
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+      <div
+        className="workHubLive workhub-surface"
+        data-placement="floating"
+        data-conversation-expanded="false"
+        style={{ position: 'relative', width: 340, height: props.height ?? 96, border: '1px solid var(--border)', borderRadius: 20, background: 'var(--background)', overflow: 'hidden' }}
+      >
+        <div className="workHubComposerSurface" style={{ position: 'absolute', inset: 'auto 0 0 0', padding: '0 8px 8px' }}>
+          <div className="maka-model-selection-controls">{props.children}</div>
+        </div>
+      </div>
+      <span style={{ font: 'var(--maka-text-supporting)', color: 'var(--muted-foreground)' }}>{props.caption}</span>
+    </div>
+  );
+}
+
+const collapsedSession = {
+  id: 'storybook-collapsed',
+  name: 'Collapsed window',
+  isFlagged: false,
+  isArchived: false,
+  labels: [],
+  hasUnread: false,
+  status: 'active',
+  backend: 'ai-sdk',
+  llmConnectionId: COLLAPSED_ACTIVE.connectionId,
+  llmConnectionSlug: COLLAPSED_ACTIVE.connectionSlug,
+  connectionLocked: false,
+  model: COLLAPSED_ACTIVE.model,
+  permissionMode: 'ask',
+} satisfies SessionSummary;
+
+// Real path: collapsed floating WorkHub → model picker. The bottom sheet is a
+// top-layer dialog pinned to the viewport bottom — in the real window the
+// window is the ~96px viewport, so the sheet covers it with the same
+// searchable grouped list the composer and settings use.
+export const CollapsedWindowSheet: Story = {
+  render: () => (
+    <CollapsedWindowFrame caption="bottom-sheet:贴窗口底边的同一套可搜索列表">
+      <ChatModelSwitcher
+        presentation="bottom-sheet"
+        activeSession={collapsedSession}
+        activeModelLabel={COLLAPSED_ACTIVE.label}
+        currentProviderType={COLLAPSED_ACTIVE.providerType}
+        choices={MANY_CHOICES}
+        openNonce={1}
+        renderProviderMark={providerMark}
+        onChange={() => undefined}
+      />
+    </CollapsedWindowFrame>
+  ),
+};


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org