You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/12 08:10:40 UTC

(maka) 01/01: feat(desktop): unify magnetic model selection across entry points

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

M4n5ter pushed a commit to branch feat/unify-magnetic-model-picker
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 90b04cd189bf585c24ddce80d7b75e5ffbb35960
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Sat Sep 12 16:09:32 2026 +0800

    feat(desktop): unify magnetic model selection across entry points
    
    Use the WorkHub inline wheel for single-model pickers and apply snapped selections automatically. Keep confirmed WorkHub model revisions authoritative over delayed reads and remove the retired selection state hook.
    
    Generated-by: OpenAI Codex
---
 apps/desktop/e2e/workhub-layout.spec.ts            |  16 +-
 .../main/__tests__/workhub-send-visibility.test.ts |  51 +++-
 .../workhub/controller/use-workhub-controller.ts   |  16 +-
 .../renderer/features/workhub/ui/workhub-root.tsx  |   1 -
 .../settings/daily-review-settings-page.tsx        |  15 +-
 .../src/renderer/settings/provider-add-form.tsx    |  25 +-
 .../renderer/settings/subagent-settings-page.tsx   |  35 +--
 .../desktop/src/renderer/styles/model-switcher.css |  56 +---
 .../stories/settings/settings-pages.stories.tsx    |  12 +-
 .../composer-model-picker-recovery.test.tsx        |  32 +--
 .../ui/src/__tests__/model-wheel-picker.test.tsx   |  96 +++++++
 .../src/__tests__/use-pending-selection.test.tsx   | 141 ----------
 packages/ui/src/chat-model-switcher.tsx            | 311 +++------------------
 packages/ui/src/composer.tsx                       |   5 +-
 packages/ui/src/index.ts                           |   1 +
 packages/ui/src/model-picker-internals.ts          | 101 -------
 packages/ui/src/model-picker.tsx                   | 123 ++------
 packages/ui/src/model-wheel-picker.tsx             | 207 ++++++++++++++
 packages/ui/src/styles.css                         |   4 +-
 packages/ui/src/use-pending-selection.ts           |  76 -----
 packages/ui/stories/model-picker.stories.tsx       |  23 +-
 21 files changed, 511 insertions(+), 836 deletions(-)

diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index ff9364d83f..920c5058cc 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -183,8 +183,11 @@ test('WorkHub uses its coordination model and shared attachment composer', async
   await expect(workhub.locator('.workHubHistory')).toBeHidden();
   await expect.poll(() => workhub.evaluate(() => innerHeight)).toBeGreaterThan(compactHeight);
   await expect.poll(floatingBottom).toBe(anchoredBottom);
-  const modelBeforeBrowsing = await workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId);
-  const selectedBeforeBrowsing = await wheel.getByRole('option', { selected: true }).getAttribute('id');
+  const expectSnappedSelection = async () => {
+    await expect(wheel.locator('[data-active="true"]')).toHaveAttribute('aria-selected', 'true');
+    const label = await wheel.locator('[data-active="true"] .maka-model-wheel-label').innerText();
+    await expect.poll(() => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).toBe(label);
+  };
   await wheel.hover();
   await workhub.mouse.wheel(0, 30);
   await expect.poll(() => wheel.evaluate((element) => {
@@ -192,8 +195,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
     const viewport = element.getBoundingClientRect();
     return Math.abs((selected.top + selected.bottom - viewport.top - viewport.bottom) / 2);
   })).toBeLessThanOrEqual(1);
-  await expect(wheel.getByRole('option', { selected: true })).toHaveAttribute('id', selectedBeforeBrowsing!);
-  expect(await workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).toBe(modelBeforeBrowsing);
+  await expectSnappedSelection();
   await wheel.press('Escape');
   await model.click();
   await expect(wheel.getByRole('option')).toHaveCount(3);
@@ -210,7 +212,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
   await workhub.mouse.up();
   await expect(wheel).toBeVisible();
   await expect.poll(() => wheel.evaluate((element) => element.scrollTop)).toBe(Math.round((dragInitialTop - dragDistance) / 44) * 44);
-  expect(await workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).toBe(modelBeforeBrowsing);
+  await expectSnappedSelection();
   await wheel.press('ArrowDown');
   await wheel.press('Escape');
   await expect(wheel).toHaveCount(0);
@@ -218,7 +220,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
   await model.click();
   const options = wheel.getByRole('option');
   await wheel.press(await options.first().getAttribute('aria-selected') === 'true' ? 'End' : 'Home');
-  await expect(wheel.locator('[data-active="true"]')).toHaveAttribute('aria-selected', 'false');
+  await expectSnappedSelection();
   const previewLabel = await wheel.locator('[data-active="true"] .maka-model-wheel-label').innerText();
   const appearance = await page.evaluate(async () => (await window.maka.settings.getClient()).appearance);
   for (const theme of ['light', 'dark'] as const) {
@@ -232,7 +234,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
   await wheel.press('Enter');
   await expect(wheel).toHaveCount(0);
   await expect(model).toBeFocused();
-  await expect.poll(() => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).not.toBe(modelBeforeBrowsing);
+  await expect.poll(() => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).toBe(previewLabel);
   await model.click();
   await expect(wheel.getByRole('option', { selected: true })).toContainText(previewLabel);
   await wheel.press('Escape');
diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
index 8d3e4c705a..f93f1d952a 100644
--- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
@@ -35,7 +35,7 @@ import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
 
 afterEach(cleanupFakeDom);
 
-async function mountController(failFirstRead = false) {
+async function mountController(failFirstRead = false, overrides: Partial<WorkHubServices> = {}) {
   let hostEpoch = 'host-epoch-1';
   let openCount = 0;
   let onPhase!: (phase: 'pending' | 'ready') => void;
@@ -114,6 +114,7 @@ async function mountController(failFirstRead = false) {
       const result = await invoke('sessions:stop', target, { source: 'stop_button', expectedTurnId: turnId }) as DesktopSessionStopResult;
       return result?.kind === 'interrupted' ? result.retractedMessageIds : undefined;
     },
+    ...overrides,
   } as unknown as WorkHubServices;
   function Probe() { controller = useWorkHubController(); return null; }
   await act(async () => {
@@ -139,6 +140,54 @@ async function mountController(failFirstRead = false) {
   };
 }
 
+test('WorkHub model selection settles with its new revision and cannot be undone by a delayed catalog read', async () => {
+  type Session = Awaited<ReturnType<WorkHubServices['getSession']>>;
+  const initial = {
+    id: JSON.stringify(['host-1', 'workhub-coordination']),
+    revision: 1, model: 'A', llmConnectionId: 'connection', llmConnectionSlug: 'provider',
+    runningTurnIds: [],
+  } as unknown as Session;
+  let snapshot = initial;
+  let notify!: () => void;
+  let nextRead: Promise<Session> | undefined;
+  const requests: Array<Parameters<WorkHubServices['configureModel']>[1]> = [];
+  const h = await mountController(false, {
+    getSession: async () => {
+      const read = nextRead;
+      nextRead = undefined;
+      return read ?? snapshot;
+    },
+    subscribeSessions: (handler) => { notify = handler; return () => {}; },
+    configureModel: async (_id, input) => {
+      requests.push(input);
+      snapshot = { ...snapshot, model: input.modelTarget.model, revision: snapshot.revision + 1 };
+      return { kind: 'committed', session: snapshot } as unknown as Awaited<ReturnType<WorkHubServices['configureModel']>>;
+    },
+  });
+  const staleRead = deferred<Session>();
+  nextRead = staleRead.promise;
+  await act(async () => { notify(); });
+  const confirmation = deferred<Session>();
+  nextRead = confirmation.promise;
+  let settled = false;
+  let change!: Promise<void>;
+  await act(async () => {
+    change = h.controller.changeModel({ llmConnectionId: 'connection', llmConnectionSlug: 'provider', model: 'B' });
+    void change.then(() => { settled = true; });
+  });
+  assert.equal(settled, false, 'the wheel must remain pending until the saved session is available');
+  await act(async () => { confirmation.resolve(snapshot); await change; });
+  assert.equal(h.controller.session?.model, 'B');
+  assert.equal(h.controller.session?.revision, 2);
+  await act(async () => { staleRead.resolve(initial); });
+  assert.equal(h.controller.session?.model, 'B', 'a late background snapshot cannot roll back a successful pick');
+  await act(async () => {
+    await h.controller.changeModel({ llmConnectionId: 'connection', llmConnectionSlug: 'provider', model: 'C' });
+  });
+  assert.equal(requests[1]?.expectedRevision, 2, 'the next pick uses the committed revision');
+  assert.equal(h.controller.session?.model, 'C');
+});
+
 test('WorkHub shows the submitted prompt before admission and keeps it until its durable user record arrives', async () => {
   const h = await mountController();
   await act(() => {
diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
index 6f405f05d8..f80366b808 100644
--- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
+++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
@@ -210,7 +210,11 @@ export function useWorkHubController() {
       void Promise.all([services.listSessions(), sessionId ? services.getSession(sessionId) : undefined])
         .then(([next, coordination]) => {
           if (!disposed && read === revision) {
-            setSessions(coordination ? [...next, coordination] : next);
+            setSessions((current) => {
+              if (!coordination) return next;
+              const known = current.find((entry) => entry.id === coordination.id);
+              return [...next, known && known.revision > coordination.revision ? known : coordination];
+            });
             for (const turnId of coordination?.runningTurnIds ?? []) reconcileAdmission(sessionId!, turnId);
           }
         })
@@ -479,11 +483,19 @@ export function useWorkHubController() {
           model: input.model,
         },
       });
-      refreshSessions.current();
       if (result.kind === 'revision_conflict')
         throw new Error(workHubLiveCopy[localeRef.current].modelConflict);
+      // Complete the selection only after its authoritative model and revision
+      // are available to the next pick. Older background reads must not undo it.
+      const updated = await services.getSession(sessionId);
+      if (currentSessionId.current !== sessionId) return;
+      setSessions((current) => current.map((entry) =>
+        entry.id === sessionId && entry.revision <= updated.revision ? updated : entry,
+      ));
       setError(undefined);
     } catch (reason) {
+      if (currentSessionId.current !== sessionId) return;
+      refreshSessions.current();
       report(reason);
     }
   }
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 72593460fe..d0a110ad2a 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
@@ -292,7 +292,6 @@ export function WorkHubRoot() {
               activeModelConnectionId={session?.llmConnectionId}
               activeModelConnectionSlug={session?.llmConnectionSlug}
               modelChoices={controller.choices}
-              modelPickerPresentation={showConversation ? 'menu' : 'wheel'}
               maxInputRows={progress && !editingProgress ? 1 : showConversation ? undefined : 6}
               onModelChange={controller.changeModel}
               modelSwitchHasHistory={transcript.messages.length > 0}
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 d8e244ecc8..0784fca6f6 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 { Selector, Switch, TextInput, useMountedRef, useUiLocale } from '@maka/ui';
+import { ModelWheelPicker, 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,14 +176,15 @@ export function DailyReviewSettingsPage(props: { connections: readonly Projected
         <SettingsRow
           label={copy.model}
           description={copy.modelHelp}
-          end={<Selector
+          end={<ModelWheelPicker
             value={selectedModelValue}
-            label={copy.model}
-            isLabelHidden
+            label={modelOptions.find((option) => option.value === selectedModelValue)?.label ?? selectedModelValue}
+            ariaLabel={copy.model}
             options={modelOptions}
-            placement="below"
-            isDisabled={formDisabled || modelOptions.length === 0}
-            onChange={(value) => void patchConfig('modelKey', {
+            size="md"
+            triggerClassName="settingsModelPickerTrigger"
+            disabled={formDisabled || modelOptions.length === 0}
+            onValueChange={(value) => 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 339cebc7a6..5eb663dc38 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,
-  Selector,
+  ModelWheelPicker,
   TextInput,
   useMountedRef,
   useUiLocale,
@@ -584,16 +584,19 @@ export function AddProviderForm(props: {
             ))}
           </CheckboxList>
         )}
-        <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%"
-        />
+        <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>
         <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 abffa4fd13..5ee7fc695f 100644
--- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx
@@ -53,11 +53,12 @@ import {
   Button,
   EmptyState,
   IconButton,
+  ModelWheelPicker,
   Selector,
   Switch,
   TextArea,
   TextInput,
-  type SelectorOptionData,
+  type ModelWheelOption,
   useToast,
   useUiLocale,
 } from '@maka/ui';
@@ -413,7 +414,7 @@ function SubagentPresetEditor(props: {
       disabled: true,
     });
   }
-  const modelOptions: SelectorOptionData[] = offerableModels.map((entry) => ({
+  const modelOptions: ModelWheelOption[] = offerableModels.map((entry) => ({
     value: entry.id,
     label: entry.displayName?.trim() || entry.id,
   }));
@@ -597,21 +598,21 @@ function SubagentPresetEditor(props: {
         <SettingsRow
           label={copy.editor.model}
           end={(
-            <Selector
-              label={copy.editor.model}
-              isLabelHidden
-              value={draft.model}
-              options={modelOptions}
-              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: '' }))}
-            />
+            <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>
           )}
         />
         {thinkingLevels.length > 0 ? (
diff --git a/apps/desktop/src/renderer/styles/model-switcher.css b/apps/desktop/src/renderer/styles/model-switcher.css
index 06b95b61a2..bb9fdc026e 100644
--- a/apps/desktop/src/renderer/styles/model-switcher.css
+++ b/apps/desktop/src/renderer/styles/model-switcher.css
@@ -26,47 +26,8 @@
   max-width: 100%;
 }
 
-/* The composer footer's model and thinking pickers are ghost-button
-   DropdownMenus — the same toolbar primitive as + and permission, so their
-   resting, hover, focus, and disabled chrome all derive from the Astryx
-   Button and 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 Button's label span truncates inside that cap. */
-
-/* Connection group heading inside the model menus. Mirrors Astryx's
-   data-driven DropdownMenu section title (supporting role, secondary ink,
-   sm menu padding) so the compound menu reads like a native sectioned one. */
-.maka-model-menu-group-heading {
-  padding-block: var(--space-1);
-  padding-inline: var(--space-2);
-  font: var(--maka-text-supporting);
-  color: var(--muted-foreground);
-  user-select: none;
-  white-space: nowrap;
-}
-
-/* Switching can abandon a provider prompt cache, but that is a property of
-   the pending action, not the current model. Keep the notice inside the open
-   picker so a healthy resting control never looks unhealthy. */
-.maka-model-switch-notice {
-  display: flex;
-  align-items: flex-start;
-  gap: var(--space-1-5);
-  margin: var(--space-1);
-  padding: var(--space-1-5) var(--space-2);
-  border: var(--border-width-hairline) solid var(--border);
-  border-radius: var(--radius-control);
-  background: var(--color-warning-muted);
-  color: var(--foreground);
-  font: var(--maka-text-supporting);
-  white-space: normal;
-}
-
-.maka-model-switch-notice > svg {
-  margin-top: 1px;
-  color: var(--warning-text);
-  flex: 0 0 auto;
-}
+/* Model and thinking triggers share the footer's ghost-button chrome.
+   The model control expands into the shared inline magnetic wheel. */
 
 .maka-model-picker-root {
   display: inline-flex;
@@ -116,16 +77,3 @@
   width: 100%;
   height: 100%;
 }
-
-.modelPickerOption {
-  min-width: 0;
-}
-
-.modelPickerOptionLabel {
-  display: block;
-  min-width: 0;
-  max-width: min(260px, 56vw);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index e2d7bff260..6980d6e052 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -2002,7 +2002,7 @@ async function openDailyReviewModelSelector(canvasElement: HTMLElement): Promise
   );
   await userEvent.click(selector);
   await waitForStoryCondition(
-    () => selector.getAttribute('aria-expanded') === 'true',
+    () => canvasElement.querySelector('.maka-model-wheel-viewport') !== null,
     'Daily Review model selector did not open',
   );
   return selector;
@@ -2068,9 +2068,8 @@ export const General: Story = {
   decorators: [withSettingsBridge],
   render: () => <SettingsStory section="general" />,
 };
-// Real path: 设置 → 通用 → 默认模型. The popover remains a DOM descendant
-// of its Item after entering the top layer, so focused search must not ring
-// the whole settings row.
+// Real path: 设置 → 通用 → 默认模型. Focus stays on the inline magnetic wheel;
+// the containing settings row must not add a second focus ring.
 export const GeneralPickerOpenFocusRing: Story = {
   decorators: [withSettingsBridge],
   render: () => <SettingsStory section="general" />,
@@ -2080,8 +2079,7 @@ export const GeneralPickerOpenFocusRing: Story = {
     await userEvent.click(trigger);
     await waitFor(() => {
       const active = document.activeElement as HTMLElement | null;
-      expect(document.querySelector('[popover]:popover-open')).not.toBeNull();
-      expect(active?.closest('[popover]:popover-open')).not.toBeNull();
+      expect(active?.matches('.maka-model-wheel-viewport')).toBe(true);
     });
     const active = document.activeElement as HTMLElement;
     const row = active.closest<HTMLElement>('.astryx-item');
@@ -2716,7 +2714,7 @@ export const DailyReviewNarrow: Story = {
   parameters: { viewport: { defaultViewport: 'mobile2' } },
 };
 
-// Real path with the Astryx model selector expanded.
+// Real path with the shared magnetic model selector expanded.
 // Real path: Settings → Daily Review → Analysis model.
 export const DailyReviewModelSelectorOpen: Story = {
   decorators: [withSettingsBridge],
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 29f5856e24..14c23b2233 100644
--- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
+++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
@@ -117,13 +117,13 @@ test('the recovery handle opens the existing exact account-and-model picker', as
         />
       </LocaleProvider>,
     ));
-    assert.match(document.documentElement.innerHTML, /aria-expanded="false"[^>]*aria-haspopup="menu"/);
+    assert.match(document.documentElement.innerHTML, /aria-expanded="false"/);
 
     await act(() => composer.current?.openModelPicker());
 
-    assert.match(document.documentElement.innerHTML, /aria-expanded="true"[^>]*aria-haspopup="menu"/);
+    assert.ok(document.querySelector('.maka-model-wheel-viewport'));
     assert.match(document.documentElement.innerHTML, /GPT-5/);
-    const items = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')];
+    const items = [...document.querySelectorAll<HTMLElement>('.maka-model-wheel-viewport [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 })));
@@ -133,7 +133,8 @@ test('the recovery handle opens the existing exact account-and-model picker', as
       llmConnectionSlug: 'openrouter',
       model: 'openai/gpt-5',
     });
-    assert.match(document.documentElement.innerHTML, /aria-expanded="false"[^>]*aria-haspopup="menu"/);
+    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');
 
     await act(() => root.render(
       <LocaleProvider locale="en">
@@ -158,11 +159,11 @@ test('the recovery handle opens the existing exact account-and-model picker', as
     await act(() => composer.current?.openModelPicker());
 
     const selectedRadio = document.querySelector<HTMLElement>(
-      '[role="menuitemradio"][aria-checked="true"]',
+      '.maka-model-wheel-viewport [role="option"][aria-selected="true"]',
     );
     assert.equal(selectedRadio?.textContent?.includes('GPT-5'), true);
     assert.match(
-      document.querySelector<HTMLElement>('.maka-model-switcher-trigger')?.getAttribute('aria-label') ?? '',
+      document.querySelector<HTMLElement>('.maka-model-wheel-viewport')?.getAttribute('aria-label') ?? '',
       /GPT-5/,
     );
 
@@ -172,7 +173,7 @@ 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}
-        modelPickerPresentation="wheel" modelChoices={[choice, second]}
+        modelChoices={[choice, second]}
         onModelChange={(input) => { selected = input; }} onSend={() => { sends++; }} onStop={() => undefined} />
       </LocaleProvider>,
     ));
@@ -184,20 +185,11 @@ test('the recovery handle opens the existing exact account-and-model picker', as
       await act(() => wheel.dispatchEvent(new window.Event('scroll')));
       return wheel;
     };
-    let wheel = await browseNext();
-    assert.equal(selected, undefined, 'browsing must not reconfigure the model');
-    const options = wheel.querySelectorAll('[role="option"]');
-    assert.equal(wheel.getAttribute('aria-activedescendant'), options[1]!.id);
-    assert.equal(options[0]!.getAttribute('aria-selected'), 'true');
-    assert.equal(options[1]!.getAttribute('aria-selected'), 'false');
-    await act(() => wheel.dispatchEvent(Object.assign(new window.Event('keydown', { bubbles: true, cancelable: true }), { key: 'Escape' })));
-    assert.equal(Boolean(document.querySelector('.maka-model-wheel-viewport')), false);
-    assert.equal(selected, undefined, 'cancelling leaves the model untouched');
-    await act(() => composer.current?.openModelPicker());
-    wheel = await browseNext();
+    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' })));
-    assert.deepEqual(selected, { llmConnectionId: second.connectionId, llmConnectionSlug: second.connectionSlug, model: second.model });
-    assert.equal(sends, 0, 'confirming a model must not send the composer draft');
+    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());
diff --git a/packages/ui/src/__tests__/model-wheel-picker.test.tsx b/packages/ui/src/__tests__/model-wheel-picker.test.tsx
new file mode 100644
index 0000000000..3746bb21ad
--- /dev/null
+++ b/packages/ui/src/__tests__/model-wheel-picker.test.tsx
@@ -0,0 +1,96 @@
+/*
+ * 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('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>');
+  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, 44, '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 = 88;
+      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, 88, 'a failed save recenters the authoritative value');
+    assert.equal(wheel.querySelector('[aria-selected="true"]')?.textContent, 'C');
+
+    await act(() => root.render(<Harness disabled />));
+    await act(() => wheel.dispatchEvent(Object.assign(event('keydown'), { key: 'Home' })));
+    assert.deepEqual(calls, ['C', '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
deleted file mode 100644
index 3a4b4321ff..0000000000
--- a/packages/ui/src/__tests__/use-pending-selection.test.tsx
+++ /dev/null
@@ -1,141 +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, 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 eef4faa1c8..f107ed7325 100644
--- a/packages/ui/src/chat-model-switcher.tsx
+++ b/packages/ui/src/chat-model-switcher.tsx
@@ -17,33 +17,16 @@
  * under the License.
  */
 
-/**
- * Chat model pickers, extracted from `components.tsx`.
- *
- * `ChatModelSwitcher` (in-session) and `NewChatModelPicker` (home / empty
- * state) were ~200 lines of Select JSX living next to the Composer in the
- * 8k-line `components.tsx`. They 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).
- *
- * Composer footer pickers are ghost-button DropdownMenus — the same toolbar
- * primitive as the + and permission controls beside them, so resting, hover,
- * focus, and disabled chrome all derive from one Button instead of a product
- * overlay restyling a form field. The Astryx Selector (a field primitive,
- * with search) remains the right shape for Settings forms via `ModelPicker`.
- *
- * Thinking level is a separate menu (not nested in the model menu). The
- * Composer places it immediately after the model control in the left footer.
- */
+/** Session and new-chat adapters for the shared magnetic model picker. */
 
-import { type ReactNode, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { type ReactNode, useMemo, useState } from 'react';
 import { Button as UiButton } from '@astryxdesign/core';
 import {
   DropdownMenu,
   DropdownMenuRadioGroup,
   DropdownMenuRadioItem,
 } from '@astryxdesign/core/DropdownMenu';
-import { ICON_SIZE, AlertTriangle, Check, Settings } from './icons.js';
+import { ICON_SIZE, Check, Settings } from './icons.js';
 import {
   type ChatModelChoice,
   type ModelMenuGroup,
@@ -56,6 +39,7 @@ 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 type { ComposerModelSwitchAvailability } from './composer-helpers.js';
 
 const DEFAULT_THINKING_LEVEL = '__default__';
@@ -74,169 +58,13 @@ function providerMarkIcon(
 
 const currentCheck = <Check size={ICON_SIZE.control} aria-hidden="true" />;
 
-/** Scroll previews a model; click or Enter commits it. No popup escapes the window. */
-function ModelWheel(props: {
-  groups: readonly ModelMenuGroup[];
-  currentValue?: string;
-  label: string;
-  disabled: boolean;
-  onPick(choice: ChatModelChoice): void;
-  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 choices = props.groups.flatMap((group) => group.choices.map((choice) => ({ choice, heading: group.heading, value: exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model) })));
-  const [preview, setPreview] = useState(() => Math.max(0, choices.findIndex((entry) => entry.value === props.currentValue)));
-  const rowHeight = 44;
-  const finishDrag = (element: HTMLDivElement) => {
-    if (!element.dataset.dragging) return;
-    delete element.dataset.dragging;
-    element.scrollTo({
-      top: Math.round(element.scrollTop / rowHeight) * rowHeight,
-      behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth',
-    });
-  };
-  useLayoutEffect(() => {
-    if (!viewport.current) return;
-    viewport.current.scrollTop = preview * rowHeight;
-    viewport.current.focus({ preventScroll: true });
-  }, []);
-  return <div className="maka-model-wheel">
-    <div ref={viewport} className="maka-model-wheel-viewport" role="listbox" tabIndex={0} aria-label={props.label} aria-disabled={props.disabled} aria-activedescendant={`${id}-${preview}`}
-      style={{ height: rowHeight * 3, paddingBlock: rowHeight }}
-      onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) props.onClose(false); }}
-      onScroll={(event) => setPreview(Math.max(0, Math.min(choices.length - 1, Math.round(event.currentTarget.scrollTop / rowHeight))))}
-      onPointerDown={(event) => {
-        drag.current = undefined;
-        if (props.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 (!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';
-        }
-        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 (props.disabled) return;
-        if (event.key === 'Enter' || event.key === ' ') {
-          event.preventDefault(); event.stopPropagation();
-          const entry = choices[preview];
-          if (entry) props.onPick(entry.choice);
-          return;
-        }
-        const next = event.key === 'ArrowDown' ? preview + 1 : event.key === 'ArrowUp' ? preview - 1 : event.key === 'Home' ? 0 : event.key === 'End' ? choices.length - 1 : undefined;
-        if (next === undefined) return;
-        event.preventDefault(); event.stopPropagation();
-        event.currentTarget.scrollTop = Math.max(0, Math.min(choices.length - 1, next)) * rowHeight;
-      }}>
-      {choices.map(({ choice, heading, value }, index) => <div key={value} id={`${id}-${index}`} className="maka-model-wheel-option" role="option" aria-selected={value === props.currentValue} data-active={index === preview} aria-disabled={props.disabled} style={{ height: rowHeight }} title={`${choice.label} · ${heading}`}
-        onMouseDown={(event) => event.preventDefault()}
-        onClick={() => { if (!props.disabled) props.onPick(choice); }}>
-        <span className="maka-model-wheel-label">{choice.label}</span>
-        <span className="maka-model-wheel-provider">{heading}</span>
-        {value === props.currentValue && <span className="maka-model-wheel-check">{currentCheck}</span>}
-      </div>)}
-    </div>
-  </div>;
-}
-
-/**
- * The one shared body of both model menus: an optional leading row for a
- * current model the catalog no longer lists, then one `role="group"` section
- * per connection (heading + its models). Radio semantics expose the selected
- * value to assistive technology while the aria-hidden check preserves the
- * quiet footer's visual density. `disabled` locks every row, not just the trigger: an aria-disabled
- * trigger still opens its menu on ArrowDown (Astryx DropdownMenu's keydown
- * path does not consult `isDisabled`), so the lock must live on the items too.
- */
-function ModelMenuItems(props: {
-  groups: readonly ModelMenuGroup[];
-  currentValue?: string;
-  label: string;
-  leadingOption?: { label: string; providerType?: ProviderType };
-  renderProviderMark?(type: ProviderType): ReactNode;
-  disabled?: boolean;
-  onPick(input: {
-    llmConnectionId: string;
-    llmConnectionSlug: string;
-    model: string;
-  }): void | Promise<void>;
-}) {
-  const locale = useUiLocale();
-  return (
-    <DropdownMenuRadioGroup
-      value={props.currentValue}
-      label={props.label}
-      onChange={(value) => {
-        const choice = props.groups
-          .flatMap((group) => group.choices)
-          .find((entry) => exactModelChoiceValue(
-            entry.connectionId,
-            entry.connectionSlug,
-            entry.model,
-          ) === value);
-        if (choice) {
-          void props.onPick({
-            llmConnectionId: choice.connectionId,
-            llmConnectionSlug: choice.connectionSlug,
-            model: choice.model,
-          });
-        }
-      }}
-    >
-      {props.leadingOption ? (
-        <DropdownMenuRadioItem
-          value={props.currentValue ?? ''}
-          icon={providerMarkIcon(props.leadingOption.providerType, props.renderProviderMark)}
-          label={props.leadingOption.label}
-          endContent={currentCheck}
-          isDisabled={props.disabled}
-        />
-      ) : null}
-      {props.groups.map((group) => (
-        <div role="group" aria-label={group.heading} key={group.connectionSlug}>
-          <div className="maka-model-menu-group-heading" aria-hidden="true">
-            {group.heading}
-          </div>
-          {group.choices.map((choice) => {
-            const value = exactModelChoiceValue(
-              choice.connectionId,
-              choice.connectionSlug,
-              choice.model,
-            );
-            return (
-              <DropdownMenuRadioItem
-                key={value}
-                value={value}
-                icon={providerMarkIcon(choice.providerType, props.renderProviderMark)}
-                label={choice.label}
-                description={modelChoiceDescription(choice, locale)}
-                endContent={value === props.currentValue ? currentCheck : undefined}
-                isDisabled={props.disabled}
-              />
-            );
-          })}
-        </div>
-      ))}
-    </DropdownMenuRadioGroup>
-  );
+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),
+  })));
 }
 
 /**
@@ -306,7 +134,6 @@ export function ThinkingLevelSelector(props: {
 }
 
 export function ChatModelSwitcher(props: {
-  presentation?: 'menu' | 'wheel';
   activeSession: SessionSummary;
   activeModelConnectionId?: string;
   activeModelConnectionSlug?: string;
@@ -345,7 +172,6 @@ export function ChatModelSwitcher(props: {
     : undefined;
   const availability = props.availability ?? { available: true, pending: false };
   const [internalMenuOpen, setInternalMenuOpen] = useState(false);
-  const trigger = useRef<HTMLButtonElement>(null);
   const menuOpen = props.isMenuOpen ?? internalMenuOpen;
   const setMenuOpen = (open: boolean) => {
     if (props.isMenuOpen === undefined) setInternalMenuOpen(open);
@@ -368,68 +194,24 @@ export function ChatModelSwitcher(props: {
     try { await props.onChange?.(next); } catch { /* The action owner reports the failure. */ }
   };
 
-  if (props.presentation === 'wheel') {
-    const close = (restoreFocus: boolean) => {
-      setMenuOpen(false);
-      if (restoreFocus) requestAnimationFrame(() => trigger.current?.focus({ preventScroll: true }));
-    };
-    return menuOpen ? <ModelWheel groups={grouped} currentValue={currentValue} label={copy.switchAriaLabel} disabled={disabled}
-      onClose={close}
-      onPick={(choice) => {
-        close(true);
-        void pick({ llmConnectionId: choice.connectionId, llmConnectionSlug: choice.connectionSlug, model: choice.model });
-      }} /> : <UiButton ref={trigger} type="button" variant="ghost" size="sm" label={displayLabel} icon={providerMarkIcon(props.currentProviderType, props.renderProviderMark)} isDisabled={disabled} className="maka-model-switcher-trigger" aria-label={`${copy.switchAriaLabel}: ${displayLabel}`} aria-expanded={false} onClick={() => setMenuOpen(true)} />;
+  const options = wheelOptions(grouped, locale);
+  if (!currentKnownChoice && currentValue && !props.hideUnavailableCurrentOption) {
+    options.unshift({ value: currentValue, label: displayLabel, disabled: true });
   }
-
-  return (
-    <>
-      <DropdownMenu
-        {...(props.isMenuOpen === undefined ? {} : { isMenuOpen: props.isMenuOpen })}
-        placement="above"
-        hasChevron={false}
-        className="maka-composer-quiet-menu"
-        onOpenChange={setMenuOpen}
-        button={{
-          label: displayLabel,
-          icon: providerMarkIcon(props.currentProviderType, props.renderProviderMark),
-          variant: 'ghost',
-          size: 'sm',
-          isDisabled: disabled,
-          tooltip: title,
-          className: 'maka-model-switcher-trigger',
-          'aria-label': `${copy.switchAriaLabel}: ${displayLabel}`,
-        }}
-      >
-        {announceWarning ? (
-          <div className="maka-model-switch-notice" aria-hidden="true">
-            <AlertTriangle size={ICON_SIZE.meta} />
-            <span>{copy.switchWarning}</span>
-          </div>
-        ) : null}
-        <ModelMenuItems
-          groups={grouped}
-          currentValue={currentValue}
-          label={`${copy.switchAriaLabel}: ${displayLabel}`}
-          leadingOption={
-            !currentKnownChoice && !props.hideUnavailableCurrentOption
-              ? { label: displayLabel, providerType: props.currentProviderType }
-              : undefined
-          }
-          renderProviderMark={props.renderProviderMark}
-          disabled={disabled}
-          onPick={pick}
-        />
-      </DropdownMenu>
-      <span
-        className="maka-visually-hidden maka-model-switch-announcement"
-        role="status"
-        aria-live="polite"
-        aria-atomic="true"
-      >
-        {announceWarning ? copy.switchWarning : ''}
-      </span>
-    </>
-  );
+  return <>
+    <ModelWheelPicker options={options} value={currentValue} label={displayLabel}
+      ariaLabel={`${copy.switchAriaLabel}: ${displayLabel}`}
+      icon={providerMarkIcon(props.currentProviderType, props.renderProviderMark)}
+      tooltip={title} triggerClassName="maka-model-switcher-trigger"
+      disabled={disabled} open={menuOpen} onOpenChange={setMenuOpen}
+      onValueChange={(value) => {
+        const choice = props.choices.find((entry) => exactModelChoiceValue(entry.connectionId, entry.connectionSlug, entry.model) === value);
+        if (choice) return pick({ llmConnectionId: choice.connectionId, llmConnectionSlug: choice.connectionSlug, model: choice.model });
+      }} />
+    <span className="maka-visually-hidden maka-model-switch-announcement" role="status" aria-live="polite" aria-atomic="true">
+      {announceWarning ? copy.switchWarning : ''}
+    </span>
+  </>;
 }
 
 /**
@@ -462,31 +244,18 @@ export function NewChatModelPicker(props: {
       exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model) ===
       currentValue,
   );
-  return (
-    <DropdownMenu
-      placement="above"
-      hasChevron={false}
-      className="maka-composer-quiet-menu"
-      button={{
-        label: props.label,
-        icon: providerMarkIcon(props.currentProviderType, props.renderProviderMark),
-        variant: 'ghost',
-        size: 'sm',
-        tooltip: copy.newChatTitle(props.label),
-        className: 'maka-new-chat-model-selector',
-        'aria-label': copy.newChatAriaLabel(props.label),
-      }}
-    >
-      <ModelMenuItems
-        groups={grouped}
-        currentValue={currentValue}
-        label={copy.newChatAriaLabel(props.label)}
-        leadingOption={!currentKnownChoice && currentValue ? { label: props.label, providerType: props.currentProviderType } : undefined}
-        renderProviderMark={props.renderProviderMark}
-        onPick={props.onPick}
-      />
-    </DropdownMenu>
-  );
+  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 0b38402c93..166f786c25 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -351,8 +351,6 @@ export const Composer = forwardRef<
     activeModelLabel?: string;
     activeProviderType?: ProviderType;
     modelChoices?: ChatModelChoice[];
-    /** Inline browsing for compact windows that cannot fit a popup menu. */
-    modelPickerPresentation?: 'menu' | 'wheel';
     /** 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. */
@@ -544,7 +542,7 @@ export const Composer = forwardRef<
     });
   const modelSwitchAvailabilityRef = useRef(modelSwitchAvailability);
   modelSwitchAvailabilityRef.current = modelSwitchAvailability;
-  useLayoutEffect(() => setModelPickerOpen(false), [props.activeSession?.id, props.modelPickerPresentation]);
+  useLayoutEffect(() => setModelPickerOpen(false), [props.activeSession?.id]);
   const [pendingImportAction, setPendingImportAction] = useState<ComposerImportActionId | null>(null);
   const composerMountedRef = useMountedRef();
   const sendPendingRef = useRef(false);
@@ -2106,7 +2104,6 @@ export const Composer = forwardRef<
               <div className="maka-model-selection-controls">
                 {props.activeSession ? (
                   <ChatModelSwitcher
-                    presentation={props.modelPickerPresentation}
                     activeSession={props.activeSession}
                     activeModelConnectionId={props.activeModelConnectionId}
                     activeModelConnectionSlug={props.activeModelConnectionSlug}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 187a1084ae..1de285e734 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -55,6 +55,7 @@ export * from './live-turn-projection.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.ts b/packages/ui/src/model-picker-internals.ts
deleted file mode 100644
index 4cd488d876..0000000000
--- a/packages/ui/src/model-picker-internals.ts
+++ /dev/null
@@ -1,101 +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 { SelectorDivider, 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;
-}
-
-/**
- * Shapes Maka's provider catalog into Astryx Selector's public option model.
- * Search, flattening, keyboard navigation, selection, and empty results remain
- * entirely inside Selector. Provider marks use the separate public-value map
- * below rather than relying on Selector to preserve product-only fields.
- */
-export function buildModelPickerOptions(
-  groups: readonly ModelMenuGroup[],
-  leadingOption?: ModelPickerLeadingOption,
-): ModelPickerSelectorOption[] {
-  const sections: ModelPickerSection[] = groups.map((group) => ({
-    type: 'section',
-    title: group.heading,
-    options: group.choices.map((choice) => ({
-      value: modelChoiceValue(choice.connectionSlug, choice.model),
-      label: choice.label,
-    })),
-  }));
-
-  if (!leadingOption) return sections;
-
-  const option: ModelPickerOption = {
-    value: leadingOption.value,
-    label: leadingOption.label,
-  };
-  return sections.length > 0 ? [option, { type: 'divider' }, ...sections] : [option];
-}
-
-export function buildModelPickerProviderTypes(
-  groups: readonly ModelMenuGroup[],
-  leadingOption?: ModelPickerLeadingOption,
-): ReadonlyMap<string, ProviderType> {
-  const entries: [string, ProviderType][] = groups.flatMap((group) =>
-    group.choices.map((choice) => [
-      modelChoiceValue(choice.connectionSlug, choice.model),
-      group.providerType,
-    ]),
-  );
-  if (leadingOption?.providerType) {
-    entries.unshift([leadingOption.value, leadingOption.providerType]);
-  }
-  return new Map(entries);
-}
-
-export function buildModelPickerDescriptions(
-  groups: readonly ModelMenuGroup[],
-  locale: UiLocale,
-): ReadonlyMap<string, string | undefined> {
-  return new Map(
-    groups.flatMap((group) =>
-      group.choices.map((choice) => [
-        modelChoiceValue(choice.connectionSlug, choice.model),
-        modelChoiceDescription(choice, locale),
-      ] as const),
-    ),
-  );
-}
diff --git a/packages/ui/src/model-picker.tsx b/packages/ui/src/model-picker.tsx
index 764c15d865..d1ad954e14 100644
--- a/packages/ui/src/model-picker.tsx
+++ b/packages/ui/src/model-picker.tsx
@@ -17,34 +17,13 @@
  * under the License.
  */
 
-/**
- * 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.
- */
+/** Settings catalog adapter for the shared magnetic model picker. */
 
-import {
-  useMemo,
-  type ReactNode,
-} from 'react';
-import {
-  Selector,
-  SelectorOption,
-  type SelectorOptionData,
-} from '@astryxdesign/core/Selector';
+import { type ReactNode } from 'react';
 import type { ProviderType } from '@maka/core/llm-connections';
-import type { ModelMenuGroup } from './chat-model-helpers.js';
-import {
-  buildModelPickerOptions,
-  buildModelPickerDescriptions,
-  buildModelPickerProviderTypes,
-  type ModelPickerLeadingOption,
-} from './model-picker-internals.js';
+import { type ModelMenuGroup, modelChoiceDescription, modelChoiceValue } from './chat-model-helpers.js';
+import { ModelWheelPicker, type ModelWheelOption } from './model-wheel-picker.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[];
@@ -52,83 +31,31 @@ export interface ModelPickerProps {
   onValueChange(value: string): void | Promise<void>;
   renderProviderMark?(type: ProviderType): ReactNode;
   disabled?: boolean;
-  /**
-   * 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;
+  leadingOption?: { value: string; label: string; providerType?: ProviderType };
   triggerClassName?: string;
   ariaLabel: string;
 }
 
 export function ModelPicker(props: ModelPickerProps) {
   const locale = useUiLocale();
-  const copy = getSharedUiCopy(locale).modelPicker;
-
-  const options = useMemo(
-    () => buildModelPickerOptions(props.groups, props.leadingOption),
-    [props.groups, props.leadingOption],
-  );
-  const providerTypes = useMemo(
-    () => buildModelPickerProviderTypes(props.groups, props.leadingOption),
-    [props.groups, props.leadingOption],
-  );
-  const descriptions = useMemo(
-    () => buildModelPickerDescriptions(props.groups, locale),
-    [locale, props.groups],
-  );
-
-  // 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. Settings is the only
-  // production host since the composer footer moved to ghost DropdownMenus,
-  // 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={(option: SelectorOptionData) => {
-          const providerType = providerTypes.get(option.value);
-          const providerMark =
-            providerType && props.renderProviderMark ? (
-              <span
-                className="modelPickerProviderMark"
-                data-provider={providerType}
-                aria-hidden="true"
-              >
-                {props.renderProviderMark(providerType)}
-              </span>
-            ) : undefined;
-          return (
-            <SelectorOption
-              className="modelPickerOption"
-              icon={providerMark}
-              label={<span className="modelPickerOptionLabel">{option.label ?? option.value}</span>}
-              description={descriptions.get(option.value)}
-            />
-          );
-        }}
-      />
-    </div>
-  );
+  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>;
 }
diff --git a/packages/ui/src/model-wheel-picker.tsx b/packages/ui/src/model-wheel-picker.tsx
new file mode 100644
index 0000000000..d36c31d4d5
--- /dev/null
+++ b/packages/ui/src/model-wheel-picker.tsx
@@ -0,0 +1,207 @@
+/*
+ * 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, useId, useLayoutEffect, useRef, useState } from 'react';
+import { Button } from '@astryxdesign/core';
+import { Check, ICON_SIZE } from './icons.js';
+
+export interface ModelWheelOption {
+  value: string;
+  label: string;
+  heading?: string;
+  description?: string;
+  disabled?: boolean;
+}
+
+/** Shared inline picker: the model at the settled snap position 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 open = props.open ?? internalOpen;
+  const setOpen = (next: boolean) => {
+    if (props.open === undefined) setInternalOpen(next);
+    props.onOpenChange?.(next);
+  };
+  return open ? <ModelWheel {...props} onClose={(restoreFocus) => {
+    setOpen(false);
+    if (restoreFocus) requestAnimationFrame(() => trigger.current?.focus({ preventScroll: true }));
+  }} /> : <Button ref={trigger} type="button" variant="ghost" size={props.size ?? 'sm'}
+    label={props.label} icon={props.icon} tooltip={props.tooltip}
+    isDisabled={props.disabled || props.options.length === 0}
+    className={props.triggerClassName} aria-label={props.ariaLabel}
+    aria-haspopup="listbox" aria-expanded={false}
+    onClick={() => setOpen(true)}
+    onKeyDown={(event) => {
+      if (!props.disabled && props.options.length > 0 && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
+        event.preventDefault(); event.stopPropagation(); setOpen(true);
+      }
+    }} />;
+}
+
+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 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 indexAt = (element: HTMLDivElement) => Math.max(0, Math.min(latest.current.options.length - 1, Math.round(element.scrollTop / ROW_HEIGHT)));
+  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 - index * ROW_HEIGHT) > 1) return;
+    interacted.current = false;
+    void pick(index);
+  };
+  const moveTo = (element: HTMLDivElement, index: number) => {
+    clearTimer();
+    interacted.current = false;
+    const next = Math.max(0, Math.min(latest.current.options.length - 1, index));
+    element.scrollTop = next * ROW_HEIGHT;
+    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 = indexAt(element);
+    element.scrollTo({ top: next * ROW_HEIGHT, 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 = selectedIndex * ROW_HEIGHT;
+    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: ROW_HEIGHT * 3, paddingBlock: ROW_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);
+      }}>
+      {props.options.map((option, index) => <div key={option.value} id={`${id}-${index}`}
+        className="maka-model-wheel-option" role="option" aria-selected={option.value === currentValue}
+        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 e2d6256478..0ae2bbe405 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -19,7 +19,7 @@
 
 @import 'katex/dist/katex.min.css';
 
-.maka-model-wheel { position: relative; width: min(264px, 55vw); }
+.maka-model-wheel { position: relative; width: min(264px, 55vw); max-width: 100%; }
 .maka-model-wheel::before { content: ''; position: absolute; inset: 44px 0; 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)); }
@@ -28,7 +28,7 @@
 .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; }
+.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); }
diff --git a/packages/ui/src/use-pending-selection.ts b/packages/ui/src/use-pending-selection.ts
deleted file mode 100644
index c067045661..0000000000
--- a/packages/ui/src/use-pending-selection.ts
+++ /dev/null
@@ -1,76 +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 { 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 d7ac2758da..49b1e967d7 100644
--- a/packages/ui/stories/model-picker.stories.tsx
+++ b/packages/ui/stories/model-picker.stories.tsx
@@ -224,14 +224,10 @@ export const ExistingConversation: Story = {
     await userEvent.unhover(trigger);
 
     await userEvent.click(trigger);
-    const notice = document.body.querySelector('.maka-model-switch-notice');
-    await expect(notice).toBeInTheDocument();
-    await expect(notice).toHaveAttribute('aria-hidden', 'true');
-    await expect(notice).toHaveTextContent(warning);
     await expect(announcement).toHaveTextContent(warning);
     await expect(announcement).toHaveAttribute('aria-live', 'polite');
     await expect(announcement).toHaveAttribute('aria-atomic', 'true');
-    const menu = within(document.body).getByRole('menu');
+    const menu = within(document.body).getByRole('listbox');
     await expect(menu).not.toContainElement(announcement);
 
     await userEvent.keyboard('{Escape}');
@@ -240,7 +236,6 @@ export const ExistingConversation: Story = {
 
     await userEvent.keyboard('{ArrowDown}');
     await expect(announcement).toHaveTextContent(warning);
-    await expect(document.body.querySelector('.maka-model-switch-notice')).toBeInTheDocument();
   },
 };
 
@@ -400,13 +395,9 @@ export const ManyConnections: Story = {
     });
     await userEvent.click(trigger);
     const menu = within(document.body);
-    // 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('menuitemradio', { name: 'vendor/gamma' });
+    await expect(await menu.findAllByRole('option')).toHaveLength(MANY_CHOICES.length);
+    await userEvent.keyboard('{End}');
+    await expect(await menu.findByRole('option', { name: /vendor\/gamma/, selected: true })).toBeInTheDocument();
   },
 };
 
@@ -434,7 +425,7 @@ export const LongModelNames: Story = {
     // 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.
-    await within(document.body).findByRole('menuitemradio', {
+    await within(document.body).findByRole('option', {
       name: /A very long model name that keeps going/,
     });
   },
@@ -482,8 +473,8 @@ export const StaleCurrentModel: Story = {
     await userEvent.click(trigger);
     const menu = within(document.body);
     // The dropped model leads the menu as the current selection…
-    await menu.findByRole('menuitemradio', { name: /claude-opus-3-retired/ });
+    await menu.findByRole('option', { name: /claude-opus-3-retired/ });
     // …while its connection's remaining models still follow underneath.
-    await menu.findByRole('menuitemradio', { name: 'Claude Sonnet 4' });
+    await menu.findByRole('option', { name: /Claude Sonnet 4/ });
   },
 };


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