You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/10/14 02:36:25 UTC
(maka) branch main updated: feat(ui): unify composer model and effort options in one Astryx menu (#5789)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new f61091963b feat(ui): unify composer model and effort options in one Astryx menu (#5789)
f61091963b is described below
commit f61091963b999ff75cd25b62b819833ad4942d8b
Author: fansehep <75...@users.noreply.github.com>
AuthorDate: Wed Oct 14 10:36:20 2026 +0800
feat(ui): unify composer model and effort options in one Astryx menu (#5789)
For native Maka sessions, replace the composer footer's separate model and thinking-level selectors with a single Astryx DropdownMenu that has Model and Effort submenus. The prompt-cache warning stays as the first Model row when the session has history. New-task composers keep their existing pickers, and Fast stays configurable in Settings.
Generated-by: Cursor agent (Claude)
---
apps/desktop/e2e/workhub-layout.spec.ts | 3 +-
.../__tests__/session-health-recovery-flow.test.ts | 17 +-
apps/desktop/src/renderer/styles/composer.css | 27 +++
apps/desktop/stories/app-shell.stories.tsx | 27 ++-
apps/desktop/stories/workhub.stories.tsx | 29 +--
docs/astryx-surface-file-inventory.md | 1 +
docs/astryx-surface-file-inventory.paths | 1 +
.../composer-model-picker-recovery.test.tsx | 64 +++++--
.../src/__tests__/composer-options-menu.test.tsx | 131 ++++++++++++++
packages/ui/src/composer-options-menu.tsx | 200 +++++++++++++++++++++
packages/ui/src/composer.tsx | 41 ++++-
packages/ui/src/conversation-copy.ts | 7 +-
packages/ui/stories/model-picker.stories.tsx | 21 ++-
13 files changed, 516 insertions(+), 53 deletions(-)
diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index d53e0a43e5..588c46931b 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -445,7 +445,8 @@ test('WorkHub uses its coordination model and shared attachment composer', async
await expect.poll(floatingBottom).toBe(anchoredBottom);
await expect.poll(() => workhub.locator('[data-chat-scroll-container]').evaluate((element) => element.scrollTop)).toBe(scrollTop);
await expect(editor).toHaveText('Keep this draft while folding the conversation.');
- await expect(model).toHaveAttribute('aria-haspopup', 'listbox');
+ // Expanded again, the wheel gives way to the composer's popover options menu.
+ await expect(model).toHaveAttribute('aria-haspopup', 'menu');
});
diff --git a/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts b/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts
index 13e4e1d3c3..8893769eb0 100644
--- a/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts
+++ b/apps/desktop/src/main/__tests__/session-health-recovery-flow.test.ts
@@ -159,6 +159,7 @@ async function renderFlow(props: Parameters<typeof RecoveryFlow>[0]) {
}) as unknown as CSSStyleDeclaration;
// linkedom lacks the popover API; record the calls so tests can tell an open
// panel from one that is mounted but hidden.
+ window.getSelection = () => null;
window.HTMLElement.prototype.showPopover = function () {
this.setAttribute('data-popover-open', '');
};
@@ -200,10 +201,18 @@ test('recovery CTA opens the production Composer model picker', async () => {
onOpenSettings: assert.fail,
});
await act(() => flow.action.dispatchEvent(new flow.window.Event('click', { bubbles: true })));
- const popup = [...flow.document.querySelectorAll<HTMLElement>('[data-popover-open]')]
- .find((panel) => panel.querySelector('[role="option"]'));
- assert.ok(popup, 'the recovery action opens the shared model picker');
- const options = popup.querySelectorAll('[role="option"]');
+ assert.equal(
+ flow.document.querySelector('.maka-composer-options-trigger')?.getAttribute('aria-expanded'),
+ 'true',
+ 'the recovery action opens the composer model menu',
+ );
+ // Astryx has no controlled open for DropdownMenuSubMenu, so the model list is
+ // one step further: its row in the open menu.
+ const modelRow = [...flow.document.querySelectorAll<HTMLElement>('[role="menuitem"][aria-haspopup="menu"]')]
+ .find((row) => row.textContent?.includes('Model'));
+ assert.ok(modelRow, 'the open menu offers the model list');
+ await act(() => modelRow.dispatchEvent(new flow.window.Event('click', { bubbles: true })));
+ const options = [...flow.document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')];
assert.equal(options.length, 1, 'only the available exact account-and-model choice is offered');
assert.equal(options[0]?.textContent?.includes(CHOICE.label), true);
});
diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css
index ab7970a750..90368531b4 100644
--- a/apps/desktop/src/renderer/styles/composer.css
+++ b/apps/desktop/src/renderer/styles/composer.css
@@ -474,6 +474,33 @@
justify-content: flex-start;
text-align: left;
}
+.maka-composer-left-controls .maka-composer-options-trigger {
+ /* local: one trigger now carries model, context and effort, so it gets the
+ model + thinking pair's former combined share instead of the model cap. */
+ max-width: min(280px, 40vw);
+}
+.maka-composer-options-label {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-1-5);
+ min-width: 0;
+}
+.maka-composer-options-details {
+ flex: 0 0 auto;
+ color: var(--muted-foreground);
+ white-space: nowrap;
+}
+.maka-composer-options-row {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-6);
+ /* local: keeps a short row name and its value apart in a narrow flyout. */
+ min-width: 9rem;
+}
+.maka-composer-options-value {
+ color: var(--muted-foreground);
+}
.maka-composer-model-label {
display: block;
min-width: 0;
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index fb7a62ba06..bd135d6f2e 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -1736,11 +1736,12 @@ function EmptyComposerLifecycle() {
chatHidden={state.chatHidden} composer={{ draftKey: state.draftKey, disabled: state.disabled, onSend: emptyComposerSend }} />;
}
-// Real path: 新任务 → no session exists yet. The composer swaps
-// ChatModelSwitcher for NewChatModelPicker and drops the thinking selector,
-// because both are keyed on an active session (composer.tsx). That branch has
-// no other story, so without this one nothing renders the picker a user meets
-// before their first send.
+// Real path: 新任务 → no session exists yet. The composer shows
+// NewChatModelPicker (plus the new-task thinking selector when the model offers
+// levels) instead of the open Session's unified options menu, which is keyed on
+// an active session (composer.tsx, phase 1 of #5787). That branch has no other
+// story, so without this one nothing renders the picker a user meets before
+// their first send.
export const NewChatComposer: Story = {
beforeEach: () => {
// AppShell can mount the composer beneath an inert ancestor while a
@@ -4526,7 +4527,7 @@ export const NarrowWorkbarCappedTitlebarReserve: Story = {
// Real path: a session with the right workbar open while the conversation
// column is narrow enough for a long model label to exercise the composer's
-// footer shrink contract. Model and thinking controls remain available while
+// footer shrink contract. The unified model menu remains available while
// the lower-priority usage action is hidden.
export const NarrowComposerFooter: Story = {
parameters: {
@@ -4572,20 +4573,18 @@ export const NarrowComposerFooter: Story = {
const send = within(card).getByRole('button', { name: '发送' });
const contextGauge = within(card).queryByRole('button', { name: '打开用量追踪' });
- const thinkingField = card.querySelector<HTMLElement>(
- '.maka-model-selection-controls .astryx-field:has(.maka-thinking-level-selector)',
- );
- if (!thinkingField) throw new Error('thinking level field is missing');
+ const modelMenu = card.querySelector<HTMLElement>('.maka-composer-options-trigger');
+ if (!modelMenu) throw new Error('composer model menu is missing');
await waitFor(() => {
const cardBox = card.getBoundingClientRect();
const sendBox = send.getBoundingClientRect();
expect(sendBox.left).toBeGreaterThanOrEqual(cardBox.left - 1);
expect(sendBox.right).toBeLessThanOrEqual(cardBox.right + 1);
expect(contextGauge).toBeNull();
- expect(getComputedStyle(thinkingField).display).not.toBe('none');
- const thinkingBox = thinkingField.getBoundingClientRect();
- expect(thinkingBox.left).toBeGreaterThanOrEqual(cardBox.left - 1);
- expect(thinkingBox.right).toBeLessThanOrEqual(sendBox.left + 1);
+ expect(getComputedStyle(modelMenu).display).not.toBe('none');
+ const modelBox = modelMenu.getBoundingClientRect();
+ expect(modelBox.left).toBeGreaterThanOrEqual(cardBox.left - 1);
+ expect(modelBox.right).toBeLessThanOrEqual(sendBox.left + 1);
// The remaining controls must stay inside their flex slot rather than
// painting over the fixed send slot when the window narrows.
const controlsBox = leftControls.getBoundingClientRect();
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx
index 7475765cbb..79262528b8 100644
--- a/apps/desktop/stories/workhub.stories.tsx
+++ b/apps/desktop/stories/workhub.stories.tsx
@@ -230,7 +230,8 @@ export const StandardComposer: Story = {
await waitFor(() => expect(canvas.getByRole('button', { name: /切换当前任务模型/ })).toBeEnabled());
await waitFor(() => expect(canvas.getByRole('button', { name: '打开用量追踪' }).textContent).toContain('1%'));
await userEvent.click(canvas.getByRole('button', { name: /切换当前任务模型/ }));
- await userEvent.click(page.getByRole('option', { name: /model-b/ }));
+ await userEvent.click(await page.findByRole('menuitem', { name: /^模型/ }));
+ await userEvent.click(await page.findByRole('menuitemradio', { name: /model-b/ }));
await waitFor(() => expect(writes.model).toHaveBeenCalledWith(sessionId, {
expectedRevision: 1, modelTarget: { kind: 'explicit', connectionId: 'connection-test', connectionSlug: 'test', model: 'model-b' }, thinkingLevel: null,
}));
@@ -242,29 +243,35 @@ export const StandardComposer: Story = {
await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0));
},
};
-// Real path: WorkHub composer → thinking level → choose an override or restore the default.
+// Real path: WorkHub composer → model menu → Effort → choose an override or restore the default.
export const ThinkingLevelPicker: Story = {
render: () => <Surface />,
play: async ({ canvasElement }) => {
Object.values(writes).forEach((spy) => spy.mockClear());
const canvas = within(canvasElement); const page = within(canvasElement.ownerDocument.body);
- await waitFor(() => expect(canvas.getByRole('combobox', { name: '思考级别: 默认' })).toBeEnabled());
+ const trigger = () => canvas.getByRole('button', { name: /切换当前任务模型/ });
+ const openEffort = async () => {
+ await userEvent.click(trigger());
+ await userEvent.click(await page.findByRole('menuitem', { name: /^思考强度/ }));
+ };
+ await waitFor(() => expect(trigger()).toBeEnabled());
const usage = canvas.getByRole('button', { name: '打开用量追踪' });
await waitFor(() => expect(usage.textContent).toContain('1%'));
- await userEvent.click(canvas.getByRole('combobox', { name: '思考级别: 默认' }));
- await userEvent.click(page.getByRole('option', { name: /^高$/ }));
- await waitFor(() => expect(canvas.getByRole('combobox', { name: '思考级别: 高' })).toBeEnabled());
+ await openEffort();
+ await expect(page.getByRole('menuitem', { name: /^思考强度/ })).toHaveTextContent(/默认$/);
+ await userEvent.click(await page.findByRole('menuitemradio', { name: /^高$/ }));
+ await waitFor(() => expect(trigger()).toHaveAccessibleName(/ 高$/));
await expect(writes.model).toHaveBeenCalledWith(sessionId, {
expectedRevision: 1, modelTarget: { kind: 'explicit', connectionId: 'connection-test', connectionSlug: 'test', model: 'model-a' }, thinkingLevel: 'high',
});
- await userEvent.click(canvas.getByRole('combobox', { name: '思考级别: 高' }));
- await userEvent.click(page.getByRole('option', { name: /^默认$/ }));
- await waitFor(() => expect(canvas.getByRole('combobox', { name: '思考级别: 默认' })).toBeEnabled());
+ await openEffort();
+ await userEvent.click(await page.findByRole('menuitemradio', { name: /^默认$/ }));
+ await waitFor(() => expect(trigger()).not.toHaveAccessibleName(/ 高$/));
await expect(writes.model).toHaveBeenLastCalledWith(sessionId, {
expectedRevision: 2, modelTarget: { kind: 'explicit', connectionId: 'connection-test', connectionSlug: 'test', model: 'model-a' }, thinkingLevel: null,
});
- await userEvent.click(canvas.getByRole('combobox', { name: '思考级别: 默认' }));
- await expect(page.getByRole('option', { name: /^默认$/ })).toHaveAttribute('aria-selected', 'true');
+ await openEffort();
+ await expect(await page.findByRole('menuitemradio', { name: /^默认$/ })).toHaveAttribute('aria-checked', 'true');
},
};
// Real path: a floating WorkHub progress card → edit its composer → open the model picker.
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 592f4e7b07..bd4faec355 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -302,6 +302,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/composer-message-queue-view.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem, Tooltip | raw `<textarea` (API Use-the-System) | polish |
| `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/composer-model-label.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `packages/ui/src/composer-options-menu.tsx` | shell-chrome-or-panel | DropdownMenu, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSubMenu, Icon | aligned — uses Astryx (DropdownMenu, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSubMenu, Icon) | aligned |
| `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, HoverCard, IconButton, Lightbox, Popover, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem) | aligned |
| `packages/ui/src/daily-review-panel.tsx` | module-hub | Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem, SegmentedControl, SegmentedControlItem, Skeleton, StackItem, Text, Toolbar, VStack | aligned — uses Astryx (Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem) | aligned |
| `packages/ui/src/directory-reference-chip.tsx` | ui-composition | Token, Tooltip | aligned — uses Astryx (Token, Tooltip) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 6fab66f4bd..067ebac0a8 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -272,6 +272,7 @@ packages/ui/src/components.tsx
packages/ui/src/composer-message-queue-view.tsx
packages/ui/src/composer-message-queue.tsx
packages/ui/src/composer-model-label.tsx
+packages/ui/src/composer-options-menu.tsx
packages/ui/src/composer.tsx
packages/ui/src/daily-review-panel.tsx
packages/ui/src/directory-reference-chip.tsx
diff --git a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
index e1e4aa3090..fd5796613e 100644
--- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
+++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
@@ -70,8 +70,29 @@ test('the native model pair remains the model-selection slot fallback', () => {
);
const native = render(false);
- assert.match(native, /maka-model-switcher-trigger/u);
- assert.match(native, /maka-thinking-level-selector/u);
+ assert.match(native, /maka-composer-options-trigger/u);
+ assert.doesNotMatch(native, /maka-thinking-level-selector/u);
+
+ const detailed = renderToStaticMarkup(
+ <LocaleProvider locale="en">
+ <Composer
+ activeSession={session}
+ activeModelConnectionId={choice.connectionId}
+ activeModelConnectionSlug={choice.connectionSlug}
+ activeModel={choice.model}
+ activeModelLabel={choice.label}
+ activeThinkingLevels={choice.thinkingLevels}
+ activeThinkingLevel="high"
+ modelChoices={[{ ...choice, contextWindow: 1_048_576 }]}
+ onModelChange={() => undefined}
+ onThinkingLevelChange={() => undefined}
+ onSend={() => undefined}
+ onStop={() => undefined}
+ />
+ </LocaleProvider>,
+ );
+ assert.match(detailed, /maka-composer-model-label" dir="ltr" title="Native model">Native model</u);
+ assert.match(detailed, /maka-composer-options-details">1M High</u);
const pluginExecutorWithoutClientContribution = render(true);
assert.match(pluginExecutorWithoutClientContribution, /maka-model-switcher-trigger/u);
@@ -243,23 +264,40 @@ test('the recovery handle opens the existing exact account-and-model picker', as
/>
</LocaleProvider>,
));
- assert.match(document.documentElement.innerHTML, /aria-expanded="false"[^>]*aria-haspopup="listbox"/);
+ assert.equal(
+ document.querySelector('.maka-composer-options-trigger')?.getAttribute('aria-expanded'),
+ 'false',
+ );
await act(() => composer.current?.openModelPicker());
- assert.match(document.documentElement.innerHTML, /aria-expanded="true"[^>]*aria-haspopup="listbox"/);
- assert.match(document.documentElement.innerHTML, /GPT-5/);
- const items = [...document.querySelectorAll<HTMLElement>('[role="option"]')];
- assert.equal(items.length, 1, 'the stale legacy target is not a selectable current row');
+ assert.equal(
+ document.querySelector('.maka-composer-options-trigger')?.getAttribute('aria-expanded'),
+ 'true',
+ );
+ const openModel = () => {
+ const row = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
+ .find((candidate) => candidate.textContent?.includes('Model'));
+ assert.ok(row);
+ return act(() => row.dispatchEvent(new window.Event('click', { bubbles: true })));
+ };
+ await openModel();
+ const items = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')];
+ const gpt = items.find((row) => row.textContent?.includes('GPT-5'));
+ assert.ok(gpt, 'the stale legacy target is not a selectable current row');
+ assert.equal(items.some((row) => row.textContent?.includes('legacy')), false);
- await act(() => items[0]?.dispatchEvent(new window.Event('click', { bubbles: true })));
+ await act(() => gpt.dispatchEvent(new window.Event('click', { bubbles: true })));
assert.deepEqual(selected, {
llmConnectionId: 'connection-openrouter',
llmConnectionSlug: 'openrouter',
model: 'openai/gpt-5',
});
- assert.match(document.documentElement.innerHTML, /aria-expanded="false"[^>]*aria-haspopup="listbox"/);
+ assert.equal(
+ document.querySelector('.maka-composer-options-trigger')?.getAttribute('aria-expanded'),
+ 'false',
+ );
await act(() => root.render(
<LocaleProvider locale="en">
@@ -282,13 +320,17 @@ test('the recovery handle opens the existing exact account-and-model picker', as
</LocaleProvider>,
));
await act(() => composer.current?.openModelPicker());
+ const modelRow = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
+ .find((candidate) => candidate.textContent?.includes('Model'));
+ assert.ok(modelRow);
+ await act(() => modelRow.dispatchEvent(new window.Event('click', { bubbles: true })));
const selectedOption = document.querySelector<HTMLElement>(
- '[role="option"][aria-selected="true"]',
+ '[role="menuitemradio"][aria-checked="true"]',
);
assert.equal(selectedOption?.textContent?.includes('GPT-5'), true);
assert.match(
- document.querySelector<HTMLElement>('.maka-model-switcher-trigger')?.textContent ?? '',
+ document.querySelector<HTMLElement>('.maka-composer-options-trigger')?.textContent ?? '',
/GPT-5/,
);
diff --git a/packages/ui/src/__tests__/composer-options-menu.test.tsx b/packages/ui/src/__tests__/composer-options-menu.test.tsx
new file mode 100644
index 0000000000..266f0c3023
--- /dev/null
+++ b/packages/ui/src/__tests__/composer-options-menu.test.tsx
@@ -0,0 +1,131 @@
+/*
+ * 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 } from 'react';
+import type { ChatModelChoice } from '@maka/core/chat-model-choice';
+import type { ThinkingLevel } from '@maka/core/model-thinking';
+import type { SessionSummary } from '@maka/core/session';
+import { Composer } from '../composer.js';
+import { LocaleProvider } from '../locale-context.js';
+import { installTranscriptDom } from './transcript-test-dom.js';
+
+const choice: ChatModelChoice = {
+ connectionId: 'native',
+ connectionSlug: 'native',
+ connectionName: 'Native',
+ providerType: 'openai',
+ providerLabel: 'OpenAI',
+ model: 'gpt-5.5',
+ label: 'GPT-5.5',
+ isDefault: true,
+ thinkingLevels: ['low', 'high'],
+};
+
+function session(id: string): SessionSummary {
+ return { id, llmConnectionId: choice.connectionId, llmConnectionSlug: choice.connectionSlug, model: choice.model } as SessionSummary;
+}
+
+test('"Model default" in the effort submenu clears the level', async () => {
+ const dom = installTranscriptDom();
+ dom.window.getSelection = () => null;
+ const levels: (ThinkingLevel | undefined)[] = [];
+ const click = async (element: Element | null | undefined) => {
+ assert.ok(element);
+ await act(async () => { element.dispatchEvent(new dom.window.Event('click', { bubbles: true })); });
+ };
+ const row = (role: string, text: string) =>
+ [...dom.document.querySelectorAll(`[role="${role}"]`)].find((element) => element.textContent?.startsWith(text));
+ try {
+ await dom.render(
+ <LocaleProvider locale="en">
+ <Composer
+ activeSession={session('a')}
+ activeModelConnectionId={choice.connectionId}
+ activeModelConnectionSlug={choice.connectionSlug}
+ activeModel={choice.model}
+ activeModelLabel={choice.label}
+ activeThinkingLevels={choice.thinkingLevels}
+ activeThinkingLevel="high"
+ modelChoices={[choice]}
+ onModelChange={() => undefined}
+ onThinkingLevelChange={(level) => { levels.push(level); }}
+ onSend={() => undefined}
+ onStop={() => undefined}
+ />
+ </LocaleProvider>,
+ );
+ assert.equal(dom.document.querySelector('[role="menuitemcheckbox"]'), null, 'no Fast row in this menu');
+ assert.equal(
+ dom.document.querySelector('.maka-composer-options-trigger')?.getAttribute('aria-label'),
+ 'Switch model for this task: GPT-5.5 High',
+ 'the trigger is named by its action, not only the visible model details',
+ );
+ const label = dom.document.querySelector('.maka-composer-options-trigger .maka-composer-model-label');
+ assert.equal(label?.getAttribute('title'), 'GPT-5.5', 'an ellipsized model id stays readable on hover');
+ assert.equal(label?.getAttribute('dir'), 'ltr');
+ await click(dom.document.querySelector('.maka-composer-options-trigger'));
+ await click(row('menuitem', 'Effort'));
+ await click(row('menuitemradio', 'Model default'));
+ assert.deepEqual(levels, [undefined]);
+ } finally {
+ await dom.cleanup();
+ }
+});
+
+test('switching Sessions drops an effort pick that is still settling', async () => {
+ const dom = installTranscriptDom();
+ dom.window.getSelection = () => null;
+ const render = (id: string) => dom.render(
+ <LocaleProvider locale="en">
+ <Composer
+ activeSession={session(id)}
+ activeModelConnectionId={choice.connectionId}
+ activeModelConnectionSlug={choice.connectionSlug}
+ activeModel={choice.model}
+ activeModelLabel={choice.label}
+ activeThinkingLevels={choice.thinkingLevels}
+ modelChoices={[choice]}
+ onModelChange={() => undefined}
+ onThinkingLevelChange={() => new Promise<void>(() => undefined)}
+ onSend={() => undefined}
+ onStop={() => undefined}
+ />
+ </LocaleProvider>,
+ );
+ const click = async (element: Element | null | undefined) => {
+ assert.ok(element);
+ await act(async () => { element.dispatchEvent(new dom.window.Event('click', { bubbles: true })); });
+ };
+ const row = (role: string, text: string) =>
+ [...dom.document.querySelectorAll(`[role="${role}"]`)].find((element) => element.textContent?.startsWith(text));
+ const details = () => dom.document.querySelector('.maka-composer-options-details')?.textContent ?? '';
+ try {
+ await render('a');
+ await click(dom.document.querySelector('.maka-composer-options-trigger'));
+ await click(row('menuitem', 'Effort'));
+ await click(row('menuitemradio', 'High'));
+ assert.match(details(), /High/, 'the pending pick shows on the Session it was made in');
+ await render('b');
+ assert.doesNotMatch(details(), /High/, 'another Session does not inherit the pending pick');
+ } finally {
+ await dom.cleanup();
+ }
+});
diff --git a/packages/ui/src/composer-options-menu.tsx b/packages/ui/src/composer-options-menu.tsx
new file mode 100644
index 0000000000..beb8de28b2
--- /dev/null
+++ b/packages/ui/src/composer-options-menu.tsx
@@ -0,0 +1,200 @@
+/*
+ * 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 { useEffect, useRef, useState, type ReactNode } from 'react';
+import {
+ DropdownMenu,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSubMenu,
+} from '@astryxdesign/core/DropdownMenu';
+import { Icon } from '@astryxdesign/core/Icon';
+import type { ThinkingLevel } from '@maka/core/model-thinking';
+import type { ProviderType } from '@maka/core/llm-connections';
+import { exactModelChoiceValue, type ChatModelChoice } from './chat-model-helpers.js';
+import { formatCompactTokenCount } from './compact-token-count.js';
+import { ComposerModelLabel } from './composer-model-label.js';
+import { providerMarkIcon } from './model-picker-internals.js';
+import { useUiLocale } from './locale-context.js';
+import { getConversationCopy } from './conversation-copy.js';
+import { usePendingSelection } from './use-pending-selection.js';
+
+const DEFAULT_EFFORT = '__default__';
+
+// DropdownMenuSubMenu has no endContent slot, so the current value rides in the
+// label until Astryx offers one.
+function OptionRow(props: { label: string; value: string }) {
+ return (
+ <span className="maka-composer-options-row">
+ <span>{props.label}</span>
+ <span className="maka-composer-options-value">{props.value}</span>
+ </span>
+ );
+}
+
+/**
+ * One footer menu for the native model: thinking effort and the model. The
+ * trigger shows the context window read-only beside the model name.
+ */
+export function ComposerOptionsMenu(props: {
+ label: string;
+ disabled?: boolean;
+ disabledReason?: string;
+ isReadOnly?: boolean;
+ openNonce?: number;
+ choices: readonly ChatModelChoice[];
+ currentModelValue?: string;
+ onModelChange?(input: {
+ llmConnectionId: string;
+ llmConnectionSlug: string;
+ model: string;
+ }): void | Promise<void>;
+ thinkingLevels?: readonly ThinkingLevel[];
+ thinkingLevel?: ThinkingLevel;
+ onThinkingLevelChange?(level: ThinkingLevel | undefined): void | Promise<void>;
+ hasConversationHistory?: boolean;
+ sessionId?: string;
+ renderProviderMark?(type: ProviderType): ReactNode;
+}) {
+ const locale = useUiLocale();
+ const copy = getConversationCopy(locale).model;
+ const choice = props.choices.find(
+ (candidate) => exactModelChoiceValue(candidate.connectionId, candidate.connectionSlug, candidate.model) === props.currentModelValue,
+ );
+ const [open, setOpen] = useState(false);
+ const nonceRef = useRef(props.openNonce ?? 0);
+ useEffect(() => {
+ const nonce = props.openNonce ?? 0;
+ if (nonce === nonceRef.current) return;
+ nonceRef.current = nonce;
+ if (!props.disabled && !props.isReadOnly) setOpen(true);
+ }, [props.openNonce, props.disabled, props.isReadOnly]);
+ useEffect(() => {
+ if (props.isReadOnly) setOpen(false);
+ }, [props.isReadOnly]);
+
+ const effortSelection = usePendingSelection(
+ props.thinkingLevel ?? DEFAULT_EFFORT,
+ (value) => props.onThinkingLevelChange?.(value === DEFAULT_EFFORT ? undefined : (value as ThinkingLevel)),
+ );
+ const modelSelection = usePendingSelection(props.currentModelValue ?? '', async (value) => {
+ const next = props.choices.find(
+ (candidate) => exactModelChoiceValue(candidate.connectionId, candidate.connectionSlug, candidate.model) === value,
+ );
+ if (!next) return;
+ await props.onModelChange?.({
+ llmConnectionId: next.connectionId,
+ llmConnectionSlug: next.connectionSlug,
+ model: next.model,
+ });
+ });
+
+ const [acknowledgedSessions, setAcknowledgedSessions] = useState<ReadonlySet<string>>(() => new Set());
+ const noticeShown = props.hasConversationHistory === true
+ && props.sessionId !== undefined
+ && !acknowledgedSessions.has(props.sessionId);
+
+ const effortValue = props.thinkingLevels?.find((level) => level === effortSelection.value);
+ const effortRow = effortValue ? copy.level[effortValue] : copy.defaultLevel;
+ const triggerDetails = [
+ choice?.contextWindow ? formatCompactTokenCount(choice.contextWindow) : undefined,
+ effortValue ? copy.level[effortValue] : undefined,
+ ].filter((detail): detail is string => detail !== undefined).join(' ');
+ const triggerLabel = triggerDetails ? `${props.label} ${triggerDetails}` : props.label;
+ const modelRow = props.choices.find(
+ (candidate) => exactModelChoiceValue(candidate.connectionId, candidate.connectionSlug, candidate.model) === modelSelection.value,
+ )?.label ?? props.label;
+ const severalConnections = new Set(props.choices.map((candidate) => candidate.connectionSlug)).size > 1;
+
+ return (
+ <DropdownMenu
+ placement="above"
+ menuWidth={240}
+ isMenuOpen={open}
+ onOpenChange={(next) => {
+ if (props.isReadOnly && next) return;
+ setOpen(next);
+ }}
+ button={{
+ // With visible `children`, Astryx makes `label` the accessible name and
+ // overrides any `aria-label`, so the name must carry the action too.
+ label: `${copy.switchAriaLabel}: ${triggerLabel}`,
+ variant: 'ghost',
+ size: 'sm',
+ isDisabled: props.disabled,
+ tooltip: props.disabledReason ?? copy.switchAriaLabel,
+ className: 'maka-model-switcher-trigger maka-composer-options-trigger',
+ endContent: <Icon icon="chevronDown" size="sm" color="secondary" />,
+ children: (
+ <span className="maka-composer-options-label">
+ <ComposerModelLabel text={props.label} />
+ {triggerDetails ? <span className="maka-composer-options-details">{triggerDetails}</span> : null}
+ </span>
+ ),
+ }}
+ >
+ {props.thinkingLevels && props.thinkingLevels.length > 0 && props.onThinkingLevelChange ? (
+ <DropdownMenuSubMenu label={<OptionRow label={copy.effort} value={effortRow} />}>
+ <DropdownMenuRadioGroup
+ label={copy.effort}
+ value={effortSelection.value}
+ onChange={(value) => { void effortSelection.onChange(value); }}
+ >
+ <DropdownMenuRadioItem value={DEFAULT_EFFORT} label={copy.defaultLevel} />
+ {props.thinkingLevels.map((level) => (
+ <DropdownMenuRadioItem key={level} value={level} label={copy.level[level]} />
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubMenu>
+ ) : null}
+ <DropdownMenuSubMenu label={<OptionRow label={copy.model} value={modelRow} />}>
+ {noticeShown ? (
+ <DropdownMenuItem
+ label={copy.switchWarning}
+ description={copy.switchWarningDismiss}
+ hasCloseOnSelect={false}
+ onClick={() => {
+ if (!props.sessionId) return;
+ setAcknowledgedSessions((current) => new Set(current).add(props.sessionId!));
+ }}
+ />
+ ) : null}
+ <DropdownMenuRadioGroup
+ label={copy.model}
+ value={modelSelection.value || undefined}
+ onChange={(value) => { void modelSelection.onChange(value); }}
+ >
+ {props.choices.map((candidate) => {
+ const value = exactModelChoiceValue(candidate.connectionId, candidate.connectionSlug, candidate.model);
+ const connection = severalConnections ? candidate.connectionName ?? candidate.providerLabel : undefined;
+ return (
+ <DropdownMenuRadioItem
+ key={value}
+ value={value}
+ label={connection ? `${candidate.label} · ${connection}` : candidate.label}
+ icon={providerMarkIcon(candidate.providerType, props.renderProviderMark)}
+ />
+ );
+ })}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubMenu>
+ </DropdownMenu>
+ );
+}
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index 70c0207b10..53040bd04f 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -61,6 +61,7 @@ import {
NewChatModelPicker,
ThinkingLevelSelector,
} from './chat-model-switcher.js';
+import { ComposerOptionsMenu } from './composer-options-menu.js';
import { useUiLocale } from './locale-context.js';
import { getConversationCopy } from './conversation-copy.js';
import { type ChatModelChoice, exactModelChoiceValue } from './chat-model-helpers.js';
@@ -1913,6 +1914,42 @@ export const Composer = forwardRef<
onChange={props.onNewChatThinkingLevelChange}
/>
);
+ // Phase 1 of #5787: only an open native Session uses the unified menu. The
+ // new-task composer keeps its pickers until executor selection joins the menu.
+ const optionsSession = props.activeSession;
+ const optionsConnectionId = props.activeModelConnectionId ?? optionsSession?.llmConnectionId;
+ const unifiedModelOptions =
+ optionsSession !== undefined &&
+ (props.pickerPresentation === undefined || props.pickerPresentation === 'popover') &&
+ !props.executorTarget &&
+ (props.modelChoices?.length ?? 0) > 0 &&
+ Boolean(props.onModelChange);
+ const renderComposerOptions = (): ReactNode => optionsSession ? (
+ <ComposerOptionsMenu
+ // A pick still settling belongs to the Session it was made in.
+ key={optionsSession.id}
+ label={props.activeModelLabel?.trim() || modelChipLabel}
+ disabled={!modelSwitchAvailability.available}
+ disabledReason={modelSwitcherDisabledReason}
+ isReadOnly={props.pickersReadOnly}
+ openNonce={modelPickerNonce}
+ choices={props.modelChoices ?? []}
+ currentModelValue={optionsConnectionId
+ ? exactModelChoiceValue(
+ optionsConnectionId,
+ props.activeModelConnectionSlug ?? optionsSession.llmConnectionSlug,
+ props.activeModel ?? optionsSession.model,
+ )
+ : undefined}
+ onModelChange={onNativeModelChange}
+ thinkingLevels={props.activeThinkingLevels}
+ thinkingLevel={props.activeThinkingLevel}
+ onThinkingLevelChange={props.onThinkingLevelChange}
+ hasConversationHistory={props.modelSwitchHasHistory}
+ sessionId={optionsSession.id}
+ renderProviderMark={props.renderProviderMark}
+ />
+ ) : null;
return (
<>
@@ -2530,7 +2567,9 @@ export const Composer = forwardRef<
onExecutorTargetChange: props.onExecutorTargetChange,
}}
options={{
- fallback: (
+ fallback: unifiedModelOptions && !props.executorPicker ? (
+ renderComposerOptions()
+ ) : (
<>
{props.activeSession ? (
<ChatModelSwitcher
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index ed882c62ba..7c62c4ae87 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -157,6 +157,7 @@ export interface ConversationCopy {
chooseThinkingLevel: string;
level: Record<ThinkingLevel, string>;
switching: string;
+ effort: string;
model: string;
switchAriaLabel: string;
switchWarning: string;
@@ -474,7 +475,7 @@ const conversationCopyZhCn: ConversationCopy = {
// Canonical per-chat ladder: 默认 (model default, overriding Settings) / 关 / 低 / 中 / 高 / 超高
// (minimal/max when offered).
level: { off: '关', minimal: '最少', low: '低', medium: '中', high: '高', xhigh: '超高', max: '最高' },
- switching: '切换中', model: '模型', switchAriaLabel: '切换当前任务模型',
+ switching: '切换中', effort: '思考强度', model: '模型', switchAriaLabel: '切换当前任务模型',
switchWarning: '切换模型可能需要重建服务商提示缓存,使下一次请求更慢或成本更高。',
switchWarningDismiss: '选择即可关闭',
newChatAriaLabel: (label) => `选择新任务模型,当前 ${label}`, newChatTitle: (label) => `新任务使用的模型:${label}`,
@@ -598,7 +599,7 @@ const conversationCopyZhTw: ConversationCopy = {
// Canonical per-chat ladder: 預設 (model default, overriding Settings) / 關 / 低 / 中 / 高 / 超高
// (minimal/max when offered).
level: { off: '關', minimal: '最少', low: '低', medium: '中', high: '高', xhigh: '超高', max: '最高' },
- switching: '切換中', model: '模型', switchAriaLabel: '切換目前任務模型',
+ switching: '切換中', effort: '思考強度', model: '模型', switchAriaLabel: '切換目前任務模型',
switchWarning: '切換模型可能需要重建服務商提示快取,使下一次請求更慢或成本更高。',
switchWarningDismiss: '選擇即可關閉',
newChatAriaLabel: (label) => `選擇新任務模型,目前 ${label}`, newChatTitle: (label) => `新任務使用的模型:${label}`,
@@ -719,7 +720,7 @@ const conversationCopyEn: ConversationCopy = {
model: {
thinkingLevel: 'Thinking level', thinkingUnsupported: 'This model does not support thinking-level changes', changeThinkingLevel: 'Change the current model thinking level', defaultLevel: 'Model default', chooseThinkingLevel: 'Choose thinking level',
level: { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Maximum' },
- switching: 'Switching', model: 'Model', switchAriaLabel: 'Switch model for this task',
+ switching: 'Switching', effort: 'Effort', model: 'Model', switchAriaLabel: 'Switch model for this task',
switchWarning: 'Switching may rebuild the provider prompt cache, making the next request slower or more expensive.',
switchWarningDismiss: 'Select to dismiss',
newChatAriaLabel: (label) => `Choose a model for the new task, currently ${label}`, newChatTitle: (label) => `Model for the new task: ${label}`,
diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx
index a9204e5467..b8d995950a 100644
--- a/packages/ui/stories/model-picker.stories.tsx
+++ b/packages/ui/stories/model-picker.stories.tsx
@@ -588,8 +588,8 @@ function LongModelNameComposer({ kind }: { kind: 'existing' | 'new' | 'unified'
// Real path: a custom relay exposes two path-like model IDs differing only in
// their suffix. Each panel uses the production Composer that owns the trigger's
-// width cap: an existing native session, the standalone new-chat fallback,
-// and the unified home / side-chat default picker.
+// width cap: an existing native session (the unified options menu), the
+// standalone new-chat fallback, and the unified home / side-chat default picker.
// The stacked arrangement is a review scaffold, not a single application screen.
export const LongModelNames: Story = {
render: () => (
@@ -633,8 +633,16 @@ export const LongModelNames: Story = {
await expect(range.getBoundingClientRect().right).toBeGreaterThan(clip.right);
};
await expectEndEllipsis(SUFFIX_CHOICES[0]!.label);
- await userEvent.click(trigger);
- await userEvent.click(await within(document.body).findByRole('option', { name: 'GPT-5' }));
+ // An open Session picks through the unified menu's Model submenu; the
+ // new-task panels keep their searchable picker until phase 2 of #5787.
+ const openChoice = async (choiceName: string | RegExp) => {
+ await userEvent.click(trigger);
+ const page = within(document.body);
+ if (name !== 'Existing conversation') return page.findByRole('option', { name: choiceName });
+ await userEvent.click(await page.findByRole('menuitem', { name: /^(模型|Model)/ }));
+ return page.findByRole('menuitemradio', { name: choiceName });
+ };
+ await userEvent.click(await openChoice('GPT-5'));
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false'));
await waitFor(() => expect(trigger.querySelector('.maka-composer-model-label')).toHaveTextContent(/^GPT-5$/));
await expect(trigger).toHaveAccessibleName(/GPT-5/);
@@ -643,10 +651,7 @@ export const LongModelNames: Story = {
shortRange.selectNodeContents(shortLabel);
await expect(shortRange.getBoundingClientRect().left).toBeCloseTo(shortLabel.getBoundingClientRect().left, 0);
await expect(shortRange.getBoundingClientRect().right).toBeLessThanOrEqual(shortLabel.getBoundingClientRect().right + 1);
- await userEvent.click(trigger);
- const option = await within(document.body).findByRole('option', {
- name: new RegExp(SUFFIX_CHOICES[1]!.label),
- });
+ const option = await openChoice(new RegExp(SUFFIX_CHOICES[1]!.label));
await expect(option.querySelector('.modelPickerProviderMark')).not.toBeNull();
await userEvent.click(option);
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false'));
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org