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:39 UTC

(maka) branch feat/unify-magnetic-model-picker created (now 90b04cd189)

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

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


      at 90b04cd189 feat(desktop): unify magnetic model selection across entry points

This branch includes the following new commits:

     new 90b04cd189 feat(desktop): unify magnetic model selection across entry points

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(desktop): unify magnetic model selection across entry points

Posted by wy...@apache.org.
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