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