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