Преглед изворни кода

Merge backend delegation settings from #4304

Dudu-0223 пре 2 недеља
родитељ
комит
25650d3d35
58 измењених фајлова са 1039 додато и 238 уклоњено
  1. 6 0
      .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.i18n.yaml
  2. 44 0
      .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md
  3. 44 0
      .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md
  4. 39 2
      apps/web/tests/declared-reasoning.e2e.ts
  5. 2 2
      apps/web/tests/expected/access-confirmation/slash-picker.expected.md
  6. 2 22
      apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
  7. 0 2
      apps/web/tests/expected/deepseek-messages-settings/picker.expected.md
  8. 2 22
      apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
  9. 5 7
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  10. 2 2
      docs/config-catalog.i18n.yaml
  11. 1 1
      docs/config-catalog.md
  12. 1 1
      docs/config-catalog.zh.md
  13. 2 2
      packages/client/ui-commands/README.i18n.yaml
  14. 1 1
      packages/client/ui-commands/README.md
  15. 1 1
      packages/client/ui-commands/README.zh.md
  16. 19 5
      packages/client/ui-commands/src/client/PopupSelectView.tsx
  17. 6 0
      packages/client/ui-commands/src/client/contract.ts
  18. 24 3
      packages/client/ui-commands/src/client/popup.ts
  19. 7 20
      packages/client/ui-commands/src/client/service.ts
  20. 52 7
      packages/client/ui-commands/tests/popup-view.client.spec.tsx
  21. 38 7
      packages/client/ui-commands/tests/popup.client.spec.ts
  22. 10 33
      packages/client/ui-commands/tests/service.client.spec.ts
  23. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  24. 1 1
      packages/client/ui-conversation/README.md
  25. 1 1
      packages/client/ui-conversation/README.zh.md
  26. 1 1
      packages/client/ui-conversation/src/client/contract/draft-editor.ts
  27. 6 0
      packages/client/ui-conversation/src/client/contract/input.ts
  28. 9 3
      packages/client/ui-conversation/src/client/input/editor/keymap.ts
  29. 10 0
      packages/client/ui-conversation/src/client/input/facade.ts
  30. 7 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  31. 29 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  32. 4 0
      packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx
  33. 2 2
      packages/client/ui-input-trigger/README.i18n.yaml
  34. 1 1
      packages/client/ui-input-trigger/README.md
  35. 1 1
      packages/client/ui-input-trigger/README.zh.md
  36. 61 1
      packages/client/ui-input-trigger/src/client/controller.ts
  37. 77 2
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  38. 2 2
      packages/client/ui-model-selection/README.i18n.yaml
  39. 1 1
      packages/client/ui-model-selection/README.md
  40. 1 1
      packages/client/ui-model-selection/README.zh.md
  41. 82 9
      packages/client/ui-model-selection/src/client/ModelSelect.tsx
  42. 187 0
      packages/client/ui-model-selection/tests/model-select.client.spec.tsx
  43. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  44. 1 1
      packages/client/ui-primitives/README.md
  45. 1 1
      packages/client/ui-primitives/README.zh.md
  46. 13 0
      packages/client/ui-primitives/src/Menu.module.css
  47. 127 11
      packages/client/ui-primitives/src/Menu.tsx
  48. 2 1
      packages/client/ui-primitives/src/Modal.tsx
  49. 79 0
      packages/client/ui-primitives/tests/atoms.client.spec.tsx
  50. 2 2
      packages/llm/llm-deepseek/README.i18n.yaml
  51. 2 2
      packages/llm/llm-deepseek/README.md
  52. 2 2
      packages/llm/llm-deepseek/README.zh.md
  53. 0 12
      packages/llm/llm-deepseek/src/common/models.ts
  54. 1 1
      packages/llm/llm-deepseek/src/config.ts
  55. 8 28
      packages/llm/llm-deepseek/tests/adapter.spec.ts
  56. 2 2
      packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
  57. 2 2
      packages/llm/llm-deepseek/tests/messages/adapter.spec.ts
  58. 2 2
      packages/llm/llm-deepseek/tests/messages/serialize.spec.ts

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md
+2026-09-14-composer-selection-keyboard.md: fb353a7072e7c2245aeeb22af9018edd9a6009f0
+2026-09-14-composer-selection-keyboard.zh.md: f51e5ca5096785ae6d059e3c8497292483490437

Разлика између датотеке није приказан због своје велике величине
+ 44 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md


Разлика између датотеке није приказан због своје велике величине
+ 44 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md


+ 39 - 2
apps/web/tests/declared-reasoning.e2e.ts

@@ -76,15 +76,52 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
     const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
 
-    // Picking a level is the same gesture that saves the default selection, so
+    // Keyboard: the clicked cell unmounts with its pane, so the drilled pane's
+    // checked row takes the focus it left behind. ↑↓ walk the rows from there
+    // and Tab settles the focused one exactly as Enter would.
+    await expect.poll(
+      () => levels.nth(0).evaluate(element => element === document.activeElement),
+      { timeout: 10_000 },
+    ).toBe(true)
+    await page.keyboard.press('ArrowDown')
+    await expect.poll(
+      () => levels.nth(1).evaluate(element => element === document.activeElement),
+      { timeout: 10_000 },
+    ).toBe(true)
+    await page.keyboard.press('ArrowDown')
+    await expect.poll(
+      () => levels.nth(2).evaluate(element => element === document.activeElement),
+      { timeout: 10_000 },
+    ).toBe(true)
+
+    // Settling with Tab is the same gesture that saves the default selection, so
     // the effort lands in the Agent default Settings section beside provider/model.
-    await page.getByRole('menuitemradio', { name: 'High' }).click()
+    await page.keyboard.press('Tab')
+    await expect.poll(() => levels.count(), { timeout: 10_000 }).toBe(0)
     await expect.poll(
       async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
       { timeout: 10_000 },
     ).toContain('reasoningEffort: high')
     await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
       .toBe('选择模型,当前 Acme Think,推理等级 High')
+
+    // Reopening the drilled pane parks the keyboard on the level in use, and
+    // Shift+Tab walks back out like Escape: to the drilled cell, then closed.
+    await trigger.click()
+    await page.getByRole('menuitem', { name: /推理等级/ }).click()
+    const high = page.getByRole('menuitemradio', { name: 'High' })
+    await expect.poll(
+      () => high.evaluate(element => element === document.activeElement),
+      { timeout: 10_000 },
+    ).toBe(true)
+    await page.keyboard.press('Shift+Tab')
+    await expect.poll(
+      () => page.getByRole('menuitem', { name: /推理等级/ })
+        .evaluate(element => element === document.activeElement),
+      { timeout: 10_000 },
+    ).toBe(true)
+    await page.keyboard.press('Shift+Tab')
+    await expect.poll(() => page.getByRole('menu').count(), { timeout: 10_000 }).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 

+ 2 - 2
apps/web/tests/expected/access-confirmation/slash-picker.expected.md

@@ -1,8 +1,8 @@
 - textbox "筛选选项":
   - /placeholder: 搜索…
 - listbox "/permission 匹配项":
-  - option "仅可查看" [selected]
-  - option "工作区内修改":
+  - option "仅可查看"
+  - option "工作区内修改" [selected]:
     - text: 工作区内修改
     - img
   - option "完全权限"

+ 2 - 22
apps/web/tests/expected/deepseek-messages-settings/cards.expected.md

@@ -49,34 +49,14 @@
             - img
           - textbox "模型 ID 2":
             - /placeholder: 模型 ID
-            - text: deepseek-v4-flash
+            - text: deepseek-v4-pro
           - textbox "显示名称 2":
             - /placeholder: 显示名称
-            - text: DeepSeek-V4-Flash
+            - text: DeepSeek-V4-Pro
           - button "模型选项 2":
             - img
           - button "删除模型 2":
             - img
-          - textbox "模型 ID 3":
-            - /placeholder: 模型 ID
-            - text: deepseek-v4-pro
-          - textbox "显示名称 3":
-            - /placeholder: 显示名称
-            - text: DeepSeek-V4-Pro
-          - button "模型选项 3":
-            - img
-          - button "删除模型 3":
-            - img
-          - textbox "模型 ID 4":
-            - /placeholder: 模型 ID
-            - text: deepseek-v4-flash-vision-exp
-          - textbox "显示名称 4":
-            - /placeholder: 显示名称
-            - text: DeepSeek-V4-Flash-Vision-Exp
-          - button "模型选项 4":
-            - img
-          - button "删除模型 4":
-            - img
           - button "添加模型":
             - img
             - text: 添加模型

+ 0 - 2
apps/web/tests/expected/deepseek-messages-settings/picker.expected.md

@@ -4,6 +4,4 @@
     - menuitemradio "Messages Flash" [checked]:
       - text: Messages Flash
       - img
-    - menuitemradio "DeepSeek-V4-Flash"
     - menuitemradio "DeepSeek-V4-Pro"
-    - menuitemradio "DeepSeek-V4-Flash-Vision-Exp"

+ 2 - 22
apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md

@@ -61,34 +61,14 @@
             - option "不支持"
           - textbox "模型 ID 2":
             - /placeholder: 模型 ID
-            - text: deepseek-v4-flash
+            - text: deepseek-v4-pro
           - textbox "显示名称 2":
             - /placeholder: 显示名称
-            - text: DeepSeek-V4-Flash
+            - text: DeepSeek-V4-Pro
           - button "模型选项 2":
             - img
           - button "删除模型 2":
             - img
-          - textbox "模型 ID 3":
-            - /placeholder: 模型 ID
-            - text: deepseek-v4-pro
-          - textbox "显示名称 3":
-            - /placeholder: 显示名称
-            - text: DeepSeek-V4-Pro
-          - button "模型选项 3":
-            - img
-          - button "删除模型 3":
-            - img
-          - textbox "模型 ID 4":
-            - /placeholder: 模型 ID
-            - text: deepseek-v4-flash-vision-exp
-          - textbox "显示名称 4":
-            - /placeholder: 显示名称
-            - text: DeepSeek-V4-Flash-Vision-Exp
-          - button "模型选项 4":
-            - img
-          - button "删除模型 4":
-            - img
           - button "添加模型":
             - img
             - text: 添加模型

+ 5 - 7
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -205,10 +205,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await settings.getByText('自定义设置').click()
     expect(await settings.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash')
     expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V41-Flash')
-    expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-flash')
-    expect(await settings.getByLabel('模型 ID 3').inputValue()).toBe('deepseek-v4-pro')
-    expect(await settings.getByLabel('模型 ID 4').inputValue()).toBe('deepseek-v4-flash-vision-exp')
-    expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(4)
+    expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-pro')
+    expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(2)
     await settings.getByRole('button', { name: '模型选项 1' }).click()
     expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('enabled')
     const defaultModels = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
@@ -225,12 +223,12 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
       name: 'Configured Flash', inputModalities: ['text'], systemPromptUpdate: 'in-history',
     })
-    await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash-vision-exp')).resolves.toMatchObject({
-      inputModalities: ['text', 'image'],
+    await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro')).resolves.toMatchObject({
+      name: 'DeepSeek-V4-Pro', inputModalities: ['text'],
     })
     await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
     await settings.getByText('自定义设置').click()
-    for (let index = 0; index < 4; index++) {
+    for (let index = 0; index < 2; index++) {
       await settings.getByRole('button', { name: /删除模型/ }).first().click()
     }
     await settings.getByRole('button', { name: '添加模型' }).click()

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: c1632bcaaa173d9b7e06bf282cf2c83c73cb6cb0
-config-catalog.zh.md: d175313d2ba0a71d22e031f63661bac6e8470c3c
+config-catalog.md: 617c9d0f4a8e16c0518e98cb20be35b9bcd8fdd0
+config-catalog.zh.md: 6a4a71a6740f2bd9cc309dc08da7de038f2ab9f5

+ 1 - 1
docs/config-catalog.md

@@ -1150,7 +1150,7 @@ export interface Config {
   maxTokens?: number
   /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
   defaultContextWindow?: number
-  /** Advisory models shown by discovery consumers; defaults to V41 Flash, V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
+  /** Advisory models shown by discovery consumers; defaults to V41 Flash and V4 Pro. */
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number

+ 1 - 1
docs/config-catalog.zh.md

@@ -1152,7 +1152,7 @@ export interface Config {
   maxTokens?: number
   /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
   defaultContextWindow?: number
-  /** Advisory models shown by discovery consumers; defaults to V41 Flash, V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
+  /** Advisory models shown by discovery consumers; defaults to V41 Flash and V4 Pro. */
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number

+ 2 - 2
packages/client/ui-commands/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md
-README.md: f802179e08009cd4db8a3b786e52e81b26bb21da
-README.zh.md: 1dee58ab42d67a62099d57d4e631e440d3349f21
+README.md: f8cba29179f9960042a4a9e67e86496591a76d08
+README.zh.md: 1cea3ae7aea857f2b2d219abf0a7192c2f222792

+ 1 - 1
packages/client/ui-commands/README.md

@@ -25,7 +25,7 @@ Typing a `/` command opens a registered popup, a client action, a host command's
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
+Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup holds composer focus: typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
 
 ### Kinds and decorations
 

+ 1 - 1
packages/client/ui-commands/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
+与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。弹窗持有 composer 焦点:键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
 
 ### 种类与装饰
 

+ 19 - 5
packages/client/ui-commands/src/client/PopupSelectView.tsx

@@ -3,11 +3,12 @@
  * store into the conversation.input.overlay anchor. Unlike the slash menu
  * (combobox — textarea keeps focus), this shell HOLDS focus while open: the
  * inner search input takes focus, plain typing filters the loaded options
- * locally, Enter/↑↓ drive the filtered highlight (scrolled into view), Escape
- * dismisses back to the composer, and ←→ keep the search input's native
- * caret. Any pointer interaction outside the box dismisses (the click's own
- * target takes focus). Closed state renders null; the overlay slot stays
- * mounted. The card height clamps to the space above the composer.
+ * locally, Enter and Tab accept the filtered highlight, ↑↓ walk it (wrapping,
+ * scrolled into view), and Escape and Shift+Tab dismiss back to the composer.
+ * ←→ keep the search input's native caret. Any pointer interaction outside the
+ * box dismisses (the click's own target takes focus). Closed state renders
+ * null; the overlay slot stays mounted. The card height clamps to the space
+ * above the composer.
  */
 import { useEffect, useRef } from 'react'
 import { useSyncExternalStore } from 'react'
@@ -95,6 +96,19 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
         ev.preventDefault()
         void popup.select(state.active)
         return
+      // Tab settles like Enter and Shift+Tab dismisses like Escape, so the
+      // card's keys mean what they mean in the composer. Both must be consumed:
+      // the shell HOLDS focus, and native traversal would leave an open card
+      // whose search input lost focus.
+      case 'Tab':
+        // With nothing to settle — still loading, failed, or filtered empty —
+        // the keystroke stays the browser's, which is how the error strip's
+        // retry button remains reachable.
+        if (!ev.shiftKey && (state.status !== 'ready' || rows.length === 0)) return
+        ev.preventDefault()
+        if (ev.shiftKey) popup.dismiss({ focusComposer: true })
+        else void popup.select(state.active)
+        return
       case 'Escape':
         ev.preventDefault()
         popup.dismiss({ focusComposer: true })

+ 6 - 0
packages/client/ui-commands/src/client/contract.ts

@@ -24,6 +24,12 @@ export interface SelectOption {
   /** Optional short marker rendered as a superscript beside the label. */
   readonly badge?: string
   readonly detail?: string
+  /**
+   * The row the shell's highlight parks on when the panel opens, so an accept
+   * gesture made without looking confirms the value in use. A business package
+   * that marks a row `active` for presentation alone would make that row the
+   * default pick.
+   */
   readonly active?: boolean
   /** Optional in-page risk gate owned by the shared popup shell. */
   readonly confirmation?: SelectConfirmation

+ 24 - 3
packages/client/ui-commands/src/client/popup.ts

@@ -63,7 +63,12 @@ export interface PopupState {
   readonly options: readonly SelectOption[]
   /** Local filter text over the loaded options. */
   readonly search: string
-  /** Highlight index into the filtered row list (0 when empty/pending). */
+  /**
+   * Highlight index into the filtered row list: 0 until options land; afterwards
+   * the row the loaded list marks as the current value
+   * ({@link SelectOption.active}), else 0. A search rebases it to the top of the
+   * filtered rows.
+   */
   readonly active: number
   /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
   readonly submitting: boolean
@@ -93,6 +98,20 @@ export function filterOptions(options: readonly SelectOption[], search: string):
   return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false))
 }
 
+/**
+ * Highlight index for a freshly loaded row list: the row marked as the current
+ * value when the live search still shows it, else the top row. Opening parks
+ * the highlight on the value the session already uses, so an accept gesture
+ * made without looking confirms that value instead of the topmost row.
+ * @param options - the loaded rows.
+ * @param search - the shell's live filter text (non-empty after a retry).
+ * @returns index into the filtered rows.
+ */
+function currentIndex(options: readonly SelectOption[], search: string): number {
+  const at = filterOptions(options, search).findIndex(option => option.active === true)
+  return at === -1 ? 0 : at
+}
+
 /** One open shell's bindings (spec + open-time context + segment snapshot + options-fetch abort). */
 interface OpenBinding<TCtx> {
   readonly command: string
@@ -145,7 +164,8 @@ export class PopupSelectController<TCtx = unknown> {
     binding.spec.options(binding.context, binding.abort.signal).then(
       (options) => {
         if (this.binding !== binding) return
-        this.state.set({ ...this.state.getSnapshot(), status: 'ready', options, active: 0, error: null })
+        const current = this.state.getSnapshot()
+        this.state.set({ ...current, status: 'ready', options, active: currentIndex(options, current.search), error: null })
       },
       (error: unknown) => {
         if (this.binding !== binding) return
@@ -166,7 +186,8 @@ export class PopupSelectController<TCtx = unknown> {
 
   /**
    * Replace the local search text (pure local filter — the provider is never
-   * re-queried) and rebase the highlight onto the new filtered list.
+   * re-queried) and rebase the highlight to the top of the new filtered list:
+   * typing searches for something other than the current value.
    * @param search - the shell search input's text.
    */
   setSearch(search: string): void {

+ 7 - 20
packages/client/ui-commands/src/client/service.ts

@@ -148,7 +148,10 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    */
   dismiss(name: string): void {
     for (const popup of this.live.popups.values()) {
-      if (popup.state.getSnapshot().command === name) popup.dismiss()
+      // A catalog that went stale underneath the card takes its rows away; the
+      // composer keeps the keyboard the card was holding, like every other
+      // dismissal path.
+      if (popup.state.getSnapshot().command === name) popup.dismiss({ focusComposer: true })
     }
   }
 
@@ -156,7 +159,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * Resolve the per-session popup controller (lazy; dies with the session
    * scope). The controller's consume callback dispatches the scoped
    * consume-token event back to this session; focusComposer reaches the
-   * composer through the overlay slot currency.
+   * session's composer through the conversation input face.
    * @param actx - session-scope ctx.
    * @returns the resident controller.
    */
@@ -173,33 +176,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
           ? { kind: 'span', span: segment.span }
           : { kind: 'bare-token', token: segment.token },
       }) === true,
-      focusComposer: () => { this.focusHooks.get(id)?.() },
+      // The shell took the keyboard; the composer restores it, caret included.
+      focusComposer: () => { actx.get('conversation')?.input.for(actx).focus() },
     })
     popups.set(id, controller)
     actx.effect(() => () => {
       controller.dispose()
       popups.delete(id)
-      this.focusHooks.delete(id)
     }, 'command: session popup')
     return controller
   }
 
-  /** Composer focus hooks by session (the overlay wiring binds the textarea focus here). */
-  private readonly focusHooks = new Map<SessionId, () => void>()
-
-  /**
-   * Bind one session's composer-focus hook (overlay slot wiring; unbind on unmount).
-   * @param id - session id.
-   * @param focus - textarea focus callback.
-   * @returns the unbind disposer.
-   */
-  bindComposerFocus(id: SessionId, focus: () => void): () => void {
-    this.focusHooks.set(id, focus)
-    return () => {
-      if (this.focusHooks.get(id) === focus) this.focusHooks.delete(id)
-    }
-  }
-
   /**
    * Menu candidates: host catalog + contribution availability, built-in rows
    * localized, then position filtering; sections for an empty query, the

+ 52 - 7
packages/client/ui-commands/tests/popup-view.client.spec.tsx

@@ -2,8 +2,9 @@
 /**
  * PopupSelectView interaction spec: the search input takes
  * focus on open and plain typing filters locally, ↑↓ move the filtered
- * highlight while ←→ stay native to the input, Enter selects single-flight,
- * Escape dismisses back through focusComposer, outside pointerdown dismisses
+ * highlight while ←→ stay native to the input, Enter and Tab select
+ * single-flight, Escape and Shift+Tab dismiss back through focusComposer,
+ * outside pointerdown dismisses
  * plainly, the submitting/failed states render pending text and a working
  * retry button, the highlighted row scrolls into view, and the card height
  * clamps to the space above the composer.
@@ -112,24 +113,69 @@ describe('PopupSelectView', () => {
 
   it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
     const { search } = await mountOpen()
+    // Open parks the highlight on the current-value row (Light, index 1).
+    expect(screen.getAllByRole('option')[1]!.getAttribute('aria-selected')).toBe('true')
     act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
     let options = screen.getAllByRole('option')
-    expect(options[1]!.getAttribute('aria-selected')).toBe('true')
+    expect(options[2]!.getAttribute('aria-selected')).toBe('true')
     act(() => { fireEvent.keyDown(search, { key: 'ArrowUp' }) })
     options = screen.getAllByRole('option')
-    expect(options[0]!.getAttribute('aria-selected')).toBe('true')
+    expect(options[1]!.getAttribute('aria-selected')).toBe('true')
     // fireEvent returns false when preventDefault was called: arrow left/right must NOT be intercepted.
     expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true)
     expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
   })
 
+  it('Tab accepts the highlighted row like Enter; Shift+Tab dismisses like Escape', async () => {
+    const onSelect = vi.fn()
+    const leaving = await mountOpen({ onSelect })
+    // false = preventDefault ran: the shell holds focus, so Tab may not fall through.
+    // Shift+Tab leaves without settling: no pick, focus back to the composer.
+    expect(fireEvent.keyDown(leaving.search, { key: 'Tab', shiftKey: true })).toBe(false)
+    expect(onSelect).not.toHaveBeenCalled()
+    expect(leaving.focusComposer).toHaveBeenCalledTimes(1)
+    expect(leaving.view.container.childElementCount).toBe(0)
+    cleanup()
+
+    const settling = await mountOpen({ onSelect })
+    // Tab settles the parked highlight — the current value — exactly like Enter.
+    await act(async () => { fireEvent.keyDown(settling.search, { key: 'Tab' }) })
+    expect(onSelect).toHaveBeenCalledExactlyOnceWith(OPTIONS[1], 'ctx-A')
+    expect(settling.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
+    expect(settling.focusComposer).toHaveBeenCalledTimes(1)
+    expect(settling.view.container.childElementCount).toBe(0)
+  })
+
+  it('Tab stays the browser\'s while the rows are still loading: no pick, no escape', async () => {
+    const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
+    render(<PopupSelectView popup={popup} t={t} />)
+    await act(async () => { popup.open('theme', spec({ options: () => new Promise(() => {}) }), 'ctx-A', SEGMENT) })
+    const search = screen.getByRole('textbox', { name: '筛选选项' })
+    // Nothing is settleable yet, so the keystroke is not swallowed.
+    expect(fireEvent.keyDown(search, { key: 'Tab' })).toBe(true)
+    expect(document.activeElement).toBe(search)
+    expect(screen.getByText('正在加载选项…')).toBeTruthy()
+  })
+
+  it('Tab stays the browser\'s on a failed load, so the retry stays reachable', async () => {
+    const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
+    render(<PopupSelectView popup={popup} t={t} />)
+    await act(async () => {
+      popup.open('theme', spec({ options: () => Promise.reject(new Error('directory down')) }), 'ctx-A', SEGMENT)
+      await Promise.resolve()
+    })
+    const search = screen.getByRole('textbox', { name: '筛选选项' })
+    expect(fireEvent.keyDown(search, { key: 'Tab' })).toBe(true)
+    expect(screen.getByRole('button', { name: '重试' })).toBeTruthy()
+  })
+
   it('scrolls the highlighted row into view when the highlight moves', async () => {
     const { search } = await mountOpen()
     scrollIntoView.mockClear()
     act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
     const options = screen.getAllByRole('option')
     expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
-    expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
+    expect(scrollIntoView.mock.instances.at(-1)).toBe(options[2])
   })
 
   it('caps the card height at the design maximum when the composer sits low enough', async () => {
@@ -144,12 +190,11 @@ describe('PopupSelectView', () => {
     expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px')
   })
 
-  it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
+  it('Enter accepts the parked highlight — the current value on open — then consumes, closes, and refocuses', async () => {
     const seen: Array<{ option: SelectOption; context: string }> = []
     const { view, search, consume, focusComposer } = await mountOpen({
       onSelect: (option, context) => { seen.push({ option, context }) },
     })
-    act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
     await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
     expect(seen).toEqual([{ option: OPTIONS[1], context: 'ctx-A' }])
     expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)

+ 38 - 7
packages/client/ui-commands/tests/popup.client.spec.ts

@@ -65,7 +65,37 @@ describe('open and options load', () => {
     expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme', status: 'pending', search: '', submitting: false, error: null })
     release(OPTIONS)
     await Promise.resolve()
-    expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, active: 0 })
+    // The highlight parks on the row the list marks as the current value
+    // (Light at index 1), not on the topmost row.
+    expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, active: 1 })
+  })
+
+  it('parks the highlight on the top row when no row marks the current value', async () => {
+    const rows: SelectOption[] = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }]
+    const popup = new PopupSelectController<Ctx>(makeDeps())
+    popup.open('theme', spec({ options: () => Promise.resolve(rows) }), CTX_A, SEGMENT)
+    await Promise.resolve()
+    expect(popup.state.getSnapshot().active).toBe(0)
+  })
+
+  it('a retry re-parks the highlight inside the search text it retained', async () => {
+    let attempts = 0
+    const rows: SelectOption[] = [
+      { id: 'sun', label: 'Sun' },
+      { id: 'moon', label: 'Moon', active: true },
+      { id: 'star', label: 'Star' },
+    ]
+    const { popup } = await readyPopup({
+      options: () => {
+        attempts += 1
+        return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(rows)
+      },
+    })
+    await Promise.resolve()
+    popup.setSearch('n') // Sun, Moon — the current value sits at filtered index 1
+    popup.retry()
+    await Promise.resolve()
+    expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', search: 'n', active: 1 })
   })
 
   it('loads options exactly once: search filters locally without re-querying the provider', async () => {
@@ -147,14 +177,15 @@ describe('open and options load', () => {
 })
 
 describe('search / move / highlight over the filtered list', () => {
-  it('setSearch rebases the highlight to 0 and ignores closed shells and identical text', async () => {
+  it('setSearch rebases the highlight to the top row and ignores closed shells and identical text', async () => {
     const { popup } = await readyPopup()
+    expect(popup.state.getSnapshot().active).toBe(1) // parked on the current-value row
     popup.move(1)
-    expect(popup.state.getSnapshot().active).toBe(1)
-    popup.setSearch('s')
-    expect(popup.state.getSnapshot()).toMatchObject({ search: 's', active: 0 })
+    expect(popup.state.getSnapshot().active).toBe(2)
+    popup.setSearch('a') // Dark, Sepia
+    expect(popup.state.getSnapshot()).toMatchObject({ search: 'a', active: 0 })
     const before = popup.state.getSnapshot()
-    popup.setSearch('s')
+    popup.setSearch('a')
     expect(popup.state.getSnapshot()).toBe(before)
     const closed = new PopupSelectController<Ctx>(makeDeps())
     closed.setSearch('x')
@@ -261,7 +292,7 @@ describe('select', () => {
     popup.setSearch('x') // locked while submitting
     popup.move(1)
     popup.highlight(1)
-    expect(popup.state.getSnapshot()).toMatchObject({ search: '', active: 0 })
+    expect(popup.state.getSnapshot()).toMatchObject({ search: '', active: 1 })
     release()
     await first
     expect(onSelect).toHaveBeenCalledTimes(1)

+ 10 - 33
packages/client/ui-commands/tests/service.client.spec.ts

@@ -122,12 +122,15 @@ async function bench(opts: BenchOptions = {}) {
   })
   /** Notices the fake conversation face collected (runDetached routing). */
   const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
+  /** Composer focuses the fake conversation face collected (popup dismissal). */
+  const focuses: Array<SessionId | undefined> = []
   const removeConversation = ctx.provide('conversation', {
     input: {
       for: (actx: Context) => ({
         notify: (level: 'info' | 'error', text: string) => {
           notices.push({ scope: scopeOf(actx), level, text })
         },
+        focus: () => { focuses.push(scopeOf(actx)) },
       }),
     },
   })
@@ -146,7 +149,7 @@ async function bench(opts: BenchOptions = {}) {
     await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal })
   }
   return {
-    ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote,
+    ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, focuses, remote,
     removeSessions, removeConversation,
   }
 }
@@ -939,7 +942,7 @@ describe('popupFor', () => {
   })
 
   it('dismisses matching popups and confirmations while preserving other commands and drafts', async () => {
-    const { command, source, mint } = await bench()
+    const { command, source, mint, focuses } = await bench()
     const pending = Promise.withResolvers<readonly SelectOption[]>()
     let pendingSignal: AbortSignal | undefined
     const onSelect = vi.fn()
@@ -967,8 +970,6 @@ describe('popupFor', () => {
     const other = command.popupFor(mint('other').ctx)
     const consume = vi.fn(() => true as const)
     scope.ctx.on('slash/input-consume-token', consume)
-    const focus = vi.fn()
-    command.bindComposerFocus(sid('s1'), focus)
     menuPick(source, 'theme', proj('s1'))
     menuPick(source, 'theme', proj('s2'))
     menuPick(source, 'other', proj('other'))
@@ -990,7 +991,8 @@ describe('popupFor', () => {
     expect(second.state.getSnapshot()).toMatchObject({ open: false, options: [] })
     expect(onSelect).not.toHaveBeenCalled()
     expect(consume).not.toHaveBeenCalled()
-    expect(focus).not.toHaveBeenCalled()
+    // The stale catalog takes the rows away; the composer keeps the keyboard.
+    expect(focuses).toEqual([sid('s1'), sid('s2')])
   })
 
   it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => {
@@ -1002,8 +1004,8 @@ describe('popupFor', () => {
     expect(() => command.popupFor(ctx)).toThrow('requires a session scope')
   })
 
-  it('a successful select dispatches the scoped consume-token and fires the bound composer focus', async () => {
-    const { command, source, mint } = await bench()
+  it('a successful select dispatches the scoped consume-token and focuses the composer', async () => {
+    const { command, source, mint, focuses } = await bench()
     const onSelect = vi.fn()
     command.register(themeContribution({ ui: themeUi({ onSelect }) }))
     const scope = mint('s1')
@@ -1012,38 +1014,13 @@ describe('popupFor', () => {
       consumes.push(r)
       return true
     })
-    const focus = vi.fn()
-    command.bindComposerFocus(sid('s1'), focus)
-
     expect(menuPick(source, 'theme', proj('s1'), 6)).toBe('handled')
     const popup = command.popupFor(scope.ctx)
     await Promise.resolve() // options land
     await popup.select(0)
     expect(onSelect).toHaveBeenCalledExactlyOnceWith({ id: 'dark', label: 'Dark' } satisfies SelectOption, proj('s1'))
     expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 6, draftRev: 3 } } }])
-    expect(focus).toHaveBeenCalledTimes(1)
-  })
-
-  it('unbinding an old composer preserves its replacement and unbinding the current one removes focus', async () => {
-    const { command, source, mint } = await bench()
-    command.register(themeContribution())
-    const scope = mint('s1')
-    scope.ctx.on('slash/input-consume-token', () => true)
-    const oldFocus = vi.fn()
-    const newFocus = vi.fn()
-    const unbindOld = command.bindComposerFocus(sid('s1'), oldFocus)
-    const unbindNew = command.bindComposerFocus(sid('s1'), newFocus)
-    unbindOld()
-    menuPick(source, 'theme', proj('s1'))
-    await Promise.resolve()
-    await command.popupFor(scope.ctx).select(0)
-    expect(oldFocus).not.toHaveBeenCalled()
-    expect(newFocus).toHaveBeenCalledOnce()
-    unbindNew()
-    menuPick(source, 'theme', proj('s1'))
-    await Promise.resolve()
-    await command.popupFor(scope.ctx).select(0)
-    expect(newFocus).toHaveBeenCalledOnce()
+    expect(focuses).toEqual([sid('s1')])
   })
 
   it('the enter path opens with the bare-token guard', async () => {

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 3c2697487ffe5b79bc8209eb9be953cc70977d79
-README.zh.md: 137357eb133a42debbce3232ca7b0858a26ee8c1
+README.md: 802ba3b8d853457e00cab3180c251ffec5291f19
+README.zh.md: 83199417289770640c9c038d84ee3e0fa6ac7866

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -52,7 +52,7 @@ The shell reads the persisted View preference before rendering when a Session fi
 
 Active transcripts expose content-width drag handles in their uncovered side gutters. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)).
 
-The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
+The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
 
 Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -52,7 +52,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。
 
-常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
+常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 

+ 1 - 1
packages/client/ui-conversation/src/client/contract/draft-editor.ts

@@ -20,7 +20,7 @@ export interface ReferenceInsert {
 }
 
 /** Keyboard keys intercepted by an open trigger menu. */
-export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab'
+export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab' | 'tabBack'
 
 /** Trigger-menu keyboard routing result. */
 export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass'

+ 6 - 0
packages/client/ui-conversation/src/client/contract/input.ts

@@ -197,6 +197,12 @@ export interface SessionInput extends InputTarget {
    * @param text - notice body.
    */
   notify(level: 'info' | 'error', text: string): void
+
+  /**
+   * Return the keyboard to the composer with the caret it last held, for
+   * callers that took focus away from it (an overlay that held its own).
+   */
+  focus(): void
   /** Input state store (InputZone currency + decorations read here). */
   readonly state: SnapshotStore<InputState>
 }

+ 9 - 3
packages/client/ui-conversation/src/client/input/editor/keymap.ts

@@ -100,9 +100,15 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
     editor.registerUpdateListener(syncComposition),
     editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
     editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
-    // Tab acts only when the trigger menu has a highlighted completion;
-    // otherwise it passes so the browser keeps its native focus traversal.
-    editor.registerCommand(KEY_TAB_COMMAND, arrow('tab'), COMMAND_PRIORITY_CRITICAL),
+    // Tab settles the highlighted completion and passes without one, keeping
+    // native focus traversal; Shift+Tab leaves the menu like Escape whenever it
+    // is open, highlight or not, so the two Tab gestures never disagree about
+    // consuming the draft.
+    editor.registerCommand(
+      KEY_TAB_COMMAND,
+      event => arrow(event.shiftKey ? 'tabBack' : 'tab')(event),
+      COMMAND_PRIORITY_CRITICAL,
+    ),
     editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => {
       // Escape layering: an open overlay closes; claimed without an overlay
       // does NOT release (backspacing the token is the only exit gesture).

+ 10 - 0
packages/client/ui-conversation/src/client/input/facade.ts

@@ -452,6 +452,16 @@ export class SessionInputShell implements SessionInput {
     this.notices.set({ level, text, seq: this.noticeSeq })
   }
 
+  /**
+   * Return the keyboard to the composer with the caret it last held. Lexical's
+   * own focus restores its stored selection; a bare DOM focus on the
+   * contenteditable would land the caret at the start instead.
+   */
+  focus(): void {
+    this.editor.getRootElement()?.focus({ preventScroll: true })
+    this.editor.focus()
+  }
+
   // ---- wiring-layer extras (not on the frozen SessionInput face) ----
 
   /**

+ 7 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -258,7 +258,13 @@ export const InputBar = memo(function InputBar({
   }
 
   const onToggleCommandMenu = (): void => {
-    if (keyboard !== undefined) toggleCommandMenu?.(keyboard.caretSpan())
+    if (keyboard === undefined) return
+    // The menu is a combobox over the editor, so the keyboard has to be there
+    // before the launcher opens it: activating the button from the keyboard
+    // leaves focus on the button, and restoring it afterwards would re-track an
+    // empty draft and close the menu again.
+    if (editor !== null) focusDraftEditor(editor, revealSelection)
+    toggleCommandMenu?.(keyboard.caretSpan())
   }
 
   // The no-session Workspace trigger: the resident editable div acts as the

+ 29 - 0
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -269,6 +269,23 @@ function writeDraft(shell: SessionInputShell, text: string): void {
   act(() => { shell.setDraft(text) })
 }
 
+describe('composer focus handoff', () => {
+  it('focus() returns the keyboard to the editor through Lexical, not a bare DOM focus', () => {
+    const { shell, textarea } = bench()
+    writeDraft(shell, 'draft text')
+    textarea.blur()
+    expect(document.activeElement).not.toBe(textarea)
+
+    const lexicalFocus = vi.spyOn(shell.editor, 'focus')
+    act(() => { shell.focus() })
+    expect(document.activeElement).toBe(textarea)
+    // Lexical's own focus restores its stored selection; a bare DOM focus would
+    // land the caret at the start of the draft. jsdom carries no caret, so the
+    // selection itself is asserted in the browser lane.
+    expect(lexicalFocus).toHaveBeenCalled()
+  })
+})
+
 describe('composer placeholder visibility', () => {
   it.each([' ', '   ', '\t', '\n'])('hides for whitespace %j and returns after deletion', async (draft) => {
     const { view, shell, textarea, button, sink, props } = bench()
@@ -1600,6 +1617,18 @@ describe('command launcher chrome and control seats', () => {
     expect(launcher.getAttribute('aria-expanded')).toBe('true')
   })
 
+  it('opening the command menu from the button puts the keyboard in the editor first', () => {
+    const toggleCommandMenu = vi.fn()
+    const { view, textarea } = bench({ toggleCommandMenu })
+    // Tab to the button and activate it: the keyboard is on the button, and the
+    // menu is a combobox whose arrows live on the editor.
+    textarea.blur()
+    expect(document.activeElement).not.toBe(textarea)
+    fireEvent.click(view.getByLabelText('添加文件或调用指令'))
+    expect(document.activeElement).toBe(textarea)
+    expect(toggleCommandMenu).toHaveBeenCalledTimes(1)
+  })
+
   it('a registered entry fills its seat and receives the locked owner prop', () => {
     const { view, slotCalls } = bench({
       disabled: true,

+ 4 - 0
packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx

@@ -94,5 +94,9 @@ describe('keymap keydown routing', () => {
     expect(picked).toBe(false) // picked: the completion replaces native traversal
     const passed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
     expect(passed).toBe(true) // pass: the browser keeps native focus traversal
+
+    // Shift+Tab is the menu's exit key, never its settle key.
+    fireEvent.keyDown(root, { key: 'Tab', keyCode: 9, shiftKey: true })
+    expect(arbitrate).toHaveBeenLastCalledWith('tabBack', false)
   })
 })

+ 2 - 2
packages/client/ui-input-trigger/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md
-README.md: ad10001829dea48b961b922c4e90cad0ce9efa92
-README.zh.md: d34d05410902ee6f98175339f0c881f958da621c
+README.md: 10c0f8df7b2cc0698ba7e10f695da09c1fa72a33
+README.zh.md: fb0f0a969965c27bbd129e70381692c4b1297f5b

+ 1 - 1
packages/client/ui-input-trigger/README.md

@@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu
 
 ### Keyboard and mouse
 
-The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight, Tab passes untouched so native focus traversal survives. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
+The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Opening it from the launcher button puts the keyboard back in the editor before the menu opens, so the arrows drive it there too. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight Tab passes untouched so native focus traversal survives. Escape and Shift+Tab leave the menu instead, never settling: the exit gesture cannot consume or rewrite the draft. A dismissed menu stays dismissed: the same token re-tracked with the same query keeps its menu closed, so restoring the caret after a pointer dismissal — or after a settled command closes the surface it opened — cannot bring the menu back; typing a new query or moving to another token re-arms it. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
 
 A source may implement `openReference(session, reference)` to open a draft reference without submitting it. Acceptance may precede asynchronous catalog loading. Chips route by source name; editable tokens route through the current source lexicon. Returning `false`, a missing source, or a disposed controller leaves the editor gesture unchanged.
 

+ 1 - 1
packages/client/ui-input-trigger/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 键盘与鼠标
 
-菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:流水线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
+菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。从 launcher 按钮打开菜单时会先把键盘还给编辑器再开菜单,因此方向键在那里同样可用。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。Escape 与 Shift+Tab 则是离开菜单——它们从不选定,因此退出动作不会消耗或改写草稿。被关闭的菜单保持关闭:同一个 token 以同一查询重新 track 时菜单不会重开——因此指针关闭之后恢复光标、或已选定的命令关闭它自己打开的界面,都不会把菜单召回来;输入新的查询或移到另一个 token 才会重新武装。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:流水线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
 
 来源可以实现 `openReference(session, reference)`,打开草稿引用而不提交。来源可以先接受预览请求,再异步加载目录。标签按来源名称路由;可编辑文本按来源当前的词表路由。返回 `false`、来源缺失或控制器已释放时,保留编辑器原有的手势处理。
 

+ 61 - 1
packages/client/ui-input-trigger/src/client/controller.ts

@@ -21,6 +21,21 @@ import type {
   SubmitEnvelope, TriggerChar, TriggerGuard,
 } from '../types.ts'
 
+/** Token identity a dismissal sticks to: the same trigger, query, and span bounds. */
+interface DismissedHit {
+  readonly trigger: string
+  readonly query: string
+  readonly quoted: boolean
+  readonly start: number
+  readonly end: number
+}
+
+/** Whether a tracked hit is the one the user just dismissed (same token, same query). */
+function dismissedHit(dismissed: DismissedHit, hit: TriggerHit): boolean {
+  return dismissed.trigger === hit.trigger && dismissed.query === hit.query && dismissed.quoted === hit.quoted
+    && dismissed.start === hit.span.start && dismissed.end === hit.span.end
+}
+
 /** Roster access the controller borrows from the root service (registration order preserved). */
 export interface SourceRoster {
   sources(trigger: string): readonly InputTriggerSource[]
@@ -74,6 +89,13 @@ export class InputTriggerController {
 
   /** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
   private hit: TriggerHit | null = null
+  /**
+   * Identity of the hit whose menu the user dismissed. A dismissal means "not
+   * this one, not now": the same token with the same query keeps its menu
+   * closed, so restoring the caret after a dismissal cannot reopen it. Typing
+   * (a new query) or moving to another token clears it.
+   */
+  private dismissed: DismissedHit | null = null
   /** Whether the open menu was reached by a drill pick; cleared with the menu. */
   private drilled = false
   private fetch: AbortController | null = null
@@ -107,12 +129,32 @@ export class InputTriggerController {
     this.clearLauncher()
     const raw = detectTrigger(draft, caret, guard)
     if (raw === null) {
+      // A launcher-opened menu is opened by a gesture, not by a typed token:
+      // the focus it takes to drive it with the keyboard re-tracks an empty
+      // draft right away, and that track must not close what the gesture
+      // opened. The launcher flag is cleared here, so this holds for that one
+      // follow-up track only; typing or picking takes over from there.
+      if (launched) return
       this.hit = null
+      // A frozen-tier track is the submit gesture's own bookkeeping, not a new
+      // intent from the user: a command submitted after a dismissal must not
+      // re-arm the menu the dismissal closed.
+      if (guard.tier !== 'frozen') this.dismissed = null
       this.stopFetch()
       this.reduce({ type: 'close' })
       return
     }
     const hit: TriggerHit = { ...raw, span: { ...raw.span, draftRev } }
+    // A launcher-opened menu is a fresh intent: the dismissal that closed the
+    // same token earlier must not silence it.
+    if (launched) this.dismissed = null
+    if (this.dismissed !== null) {
+      if (!dismissedHit(this.dismissed, hit)) this.dismissed = null
+      else {
+        this.hit = hit
+        return
+      }
+    }
     const prev = this.menu.getSnapshot()
     const same = !launched && prev.open && prev.hit !== null
       && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query
@@ -233,7 +275,11 @@ export class InputTriggerController {
         this.reduce({ type: 'move', dir: 1 })
         return 'consumed'
       }
-      case 'escape': {
+      case 'escape':
+      case 'tabBack': {
+        // Escape leaves, and Shift+Tab leaves with it: the exit gesture never
+        // settles a candidate, so it cannot consume or rewrite the draft.
+        this.rememberDismissed()
         this.stopFetch()
         this.reduce({ type: 'close' })
         return 'consumed'
@@ -381,6 +427,7 @@ export class InputTriggerController {
   /** External dismiss (e.g. pointer outside the composer area). */
   dismiss(): void {
     if (this.disposed) return
+    this.rememberDismissed()
     this.stopFetch()
     this.reduce({ type: 'close' })
   }
@@ -527,6 +574,11 @@ export class InputTriggerController {
       span: hit.span,
     })
     this.stopFetch()
+    // A settling pick is the user's decision about this token: the menu stays
+    // closed for it until the text changes, so a settled command that opens a
+    // surface of its own does not bring the menu back when that surface closes
+    // and the caret returns.
+    if (action === 'pick') this.rememberDismissed()
     this.reduce({ type: 'close' })
     // Claimed before the edit, and after the close above so the reducer's own
     // teardown cannot clear it: the input may apply the descent through a
@@ -567,6 +619,14 @@ export class InputTriggerController {
     this.headers.set(next)
   }
 
+  /** Record the open menu's identity as dismissed, so a bare re-track cannot revive it. */
+  private rememberDismissed(): void {
+    const hit = this.hit
+    this.dismissed = hit === null ? null : {
+      trigger: hit.trigger, query: hit.query, quoted: hit.quoted, start: hit.span.start, end: hit.span.end,
+    }
+  }
+
   private clearLauncher(): void {
     if (this.launcher.getSnapshot() !== null) this.launcher.set(null)
   }

+ 77 - 2
packages/client/ui-input-trigger/tests/service.client.spec.ts

@@ -921,10 +921,85 @@ describe('arbitrate', () => {
     expect(controller.menu.getSnapshot().open).toBe(false)
   })
 
-  it('escape closes and consumes', async () => {
-    const { controller } = await menuBench()
+  it('escape and shift+tab leave without picking, and the exit sticks', async () => {
+    const { controller, cmd } = await menuBench()
+    expect(controller.arbitrate('escape', false)).toBe('consumed')
+    expect(cmd.picks).toHaveLength(0)
+    expect(controller.menu.getSnapshot().open).toBe(false)
+
+    // Restoring the caret re-tracks the same hit: the exit holds.
+    controller.track('/g', 2, { tier: 'plain' }, 1)
+    await tick()
+    expect(controller.menu.getSnapshot().open).toBe(false)
+    controller.track('/go', 3, { tier: 'plain' }, 2)
+    await tick()
+    expect(controller.menu.getSnapshot().open).toBe(true)
+
+    // Shift+Tab is the same exit.
+    expect(controller.arbitrate('tabBack', false)).toBe('consumed')
+    expect(cmd.picks).toHaveLength(0)
+    expect(controller.menu.getSnapshot().open).toBe(false)
+  })
+
+  it('escape and shift+tab do not drill a drillable highlight', async () => {
+    const drillable = readySource('/', 'command', [{ name: 'src', drill: true }], () => undefined)
+    const { controller } = controllerBench([drillable.source])
+    controller.track('/s', 2, { tier: 'plain' }, 1)
+    await tick()
     expect(controller.arbitrate('escape', false)).toBe('consumed')
+    controller.track('/sr', 3, { tier: 'plain' }, 1)
+    await tick()
+    expect(controller.arbitrate('tabBack', false)).toBe('consumed')
+    expect(drillable.picks).toHaveLength(0)
+  })
+
+  it('a launcher-opened menu survives the track its own focus produces', async () => {
+    const { controller } = await menuBench()
+    controller.toggleSource('command', {
+      trigger: '/', query: '', quoted: false, position: 'leading',
+      span: { start: 0, end: 0, draftRev: 0 },
+    })
+    expect(controller.menu.getSnapshot().open).toBe(true)
+
+    // Driving the menu with the keyboard focuses the editor, and Lexical's
+    // deferred selection restore re-tracks an empty draft: the menu stays.
+    controller.track('', 0, { tier: 'plain' }, 0)
+    await tick()
+    expect(controller.menu.getSnapshot().open).toBe(true)
+
+    // Typing takes the ordinary path.
+    controller.track('/g', 2, { tier: 'plain' }, 1)
+    await tick()
+    expect(controller.menu.getSnapshot().open).toBe(true)
+    expect(controller.launcher.getSnapshot()).toBeNull()
+  })
+
+  it('a settled pick keeps its menu closed while the same hit re-tracks', async () => {
+    const { controller } = await menuBench()
+    expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
+
+    // A settled command may open a surface of its own; when that closes and the
+    // caret returns, the menu must not come back for the same token.
+    controller.track('/g', 2, { tier: 'plain' }, 1)
+    await tick()
+    expect(controller.menu.getSnapshot().open).toBe(false)
+
+    controller.track('/go', 3, { tier: 'plain' }, 2)
+    await tick()
+    expect(controller.menu.getSnapshot().open).toBe(true)
+  })
+
+  it('a pointer dismissal is sticky the same way, and leaving the token re-arms it', async () => {
+    const { controller } = await menuBench()
+    controller.dismiss()
+    controller.track('/g', 2, { tier: 'plain' }, 1)
+    await tick()
     expect(controller.menu.getSnapshot().open).toBe(false)
+
+    controller.track('plain text', 10, { tier: 'plain' }, 2)
+    controller.track('/g', 2, { tier: 'plain' }, 3)
+    await tick()
+    expect(controller.menu.getSnapshot().open).toBe(true)
   })
 
   it('tab drills into a drillable highlight and picks a plain completion', async () => {

+ 2 - 2
packages/client/ui-model-selection/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md
-README.md: f3c373619fec0482876d8ac540f06e188b5b34d9
-README.zh.md: b8ee9cd73bb3fd5cdf0d0ecd8559b271c5abc933
+README.md: b3c3b25c258dd1fd8088eb7d699806f6ecd2cc1a
+README.zh.md: e705c4f1a309da66099abba6cc3cd8a2e9b26b0e

+ 1 - 1
packages/client/ui-model-selection/README.md

@@ -25,7 +25,7 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`.
+Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`.
 
 ### Model and effort
 

+ 1 - 1
packages/client/ui-model-selection/README.zh.md

@@ -25,7 +25,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
+与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
 
 ### 模型与推理强度
 

+ 82 - 9
packages/client/ui-model-selection/src/client/ModelSelect.tsx

@@ -5,11 +5,17 @@
  * each drilling into its own list — the provider-grouped model list over
  * the shared directory, and the effort levels. The trigger (313:14108's
  * ToggleButton) shows both: model name + effort in the caption tone.
- * Data and submission ride the SAME per-session ModelDirectory as the
- * /model popup; exact-model reasoning metadata and the selected effort come
- * from the Host rather than a client-owned vocabulary. A rejected selection
- * announces through the shared transient Toast anchored to the composer
- * card; the in-menu strip with Retry remains the catalog-load surface.
+ * While open, ↑/↓ move focus across the rows of the shown pane (wrapping; a
+ * step taken while the trigger still holds focus enters at the near end), Tab
+ * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and
+ * otherwise close back to the trigger. A drilled pane hands focus to the row
+ * of the value in use, and returning to the root pane hands it back to the
+ * cell that opened it. Data and submission ride the SAME per-session
+ * ModelDirectory as the /model popup; exact-model reasoning metadata and the
+ * selected effort come from the Host rather than a client-owned vocabulary. A
+ * rejected selection announces through the shared transient Toast anchored to
+ * the composer card; the in-menu strip with Retry remains the catalog-load
+ * surface.
  */
 import {
   useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore,
@@ -123,6 +129,30 @@ export function ModelSelect(
     return () => { document.removeEventListener('mousedown', closeOutside) }
   }, [open])
 
+  // A pane switch unmounts the row that had focus, which drops focus onto the
+  // page body — outside the card's subtree, where its key handling no longer
+  // sees a keystroke. Every switch therefore names where the keyboard lands:
+  // drilling on the pane's current value, coming back on the cell that opened
+  // the pane left.
+  const paneFocus = useRef<'drill' | 'model' | 'effort' | null>(null)
+  useEffect(() => {
+    const intent = paneFocus.current
+    paneFocus.current = null
+    if (!open || intent === null) return
+    if (intent === 'drill') {
+      // The checked row is the value in use; a pane without one opens on its
+      // first row.
+      const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
+      const target = checked ?? itemRefs.current.find(item => item !== null && !item.disabled)
+      // Rows a selection in flight disabled cannot take the keyboard; the
+      // trigger does, so the card's keys still reach the menu.
+      ;(target ?? triggerRef.current)?.focus()
+      return
+    }
+    const cell = itemRefs.current[intent === 'effort' ? 1 : 0]
+    ;(cell !== null && cell !== undefined && !cell.disabled ? cell : triggerRef.current)?.focus()
+  }, [open, pane])
+
   // Portaled placement (the Menu primitive's portal rules: fixed from the
   // anchor rect, measured before paint, clamped inside the viewport): above
   // the trigger, right edges aligned. Depends on pane and directory state
@@ -171,11 +201,27 @@ export function ModelSelect(
     if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
   }
 
+  const drill = (next: Pane): void => {
+    paneFocus.current = 'drill'
+    setPane(next)
+  }
+
+  /** Leave a drilled pane for the root one, handing the keyboard back to its cell. */
+  const back = (from: Exclude<Pane, 'root'>): void => {
+    paneFocus.current = from
+    setPane('root')
+  }
+
   const moveFocus = (offset: number): void => {
     const items = itemRefs.current.filter(item => item !== null)
     if (items.length === 0) return
     const active = items.findIndex(item => item === document.activeElement)
-    const next = (Math.max(active, 0) + offset + items.length) % items.length
+    // Focus outside the rows (the trigger, which keeps it while the menu
+    // opens) enters at the end the step comes from: the first row forward,
+    // the last row backward.
+    const next = active === -1
+      ? (offset > 0 ? 0 : items.length - 1)
+      : (active + offset + items.length) % items.length
     items[next]?.focus()
   }
 
@@ -183,11 +229,38 @@ export function ModelSelect(
     if (event.key === 'Escape' && open) {
       event.preventDefault()
       // Escape backs out of a drilled pane first, then closes.
-      if (pane !== 'root') setPane('root')
+      if (pane !== 'root') back(pane)
       else close(true)
       return
     }
     if (!open) return
+    // Tab settles like Enter and Shift+Tab leaves like Escape, so the menu's
+    // keys mean what they mean in the composer. Both are consumed: the card
+    // keeps the browser's focus traversal out while it is open.
+    if (event.key === 'Tab') {
+      if (event.shiftKey) {
+        event.preventDefault()
+        if (pane !== 'root') back(pane)
+        else close(true)
+        return
+      }
+      // Settling activates the row the keyboard is on; with focus still on the
+      // trigger, Tab enters the menu at the value in use instead. Any other
+      // control inside the card (a retry button) keeps the browser's traversal,
+      // so the keystroke stays unconsumed there.
+      const focused = document.activeElement
+      const rows = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null)
+      if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
+        event.preventDefault()
+        focused.click()
+        return
+      }
+      if (focused !== triggerRef.current) return
+      event.preventDefault()
+      const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
+      ;(checked ?? rows.find(item => !item.disabled))?.focus()
+      return
+    }
     if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
       event.preventDefault()
       moveFocus(event.key === 'ArrowDown' ? 1 : -1)
@@ -299,13 +372,13 @@ export function ModelSelect(
         >
           {pane === 'root' && (
             <>
-              <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
+              <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { drill('model') }}>
                 <span className={css.cellLabel}>{t('menu.model')}</span>
                 <span className={css.cellValue}>{modelLabel}</span>
                 <IconChevronRightOutline14 className={css.cellChevron} />
               </button>
               {reasoning !== undefined && (
-                <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
+                <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { drill('effort') }}>
                   <span className={css.cellLabel}>{t('menu.effort')}</span>
                   <span className={css.cellValue}>{effortLabel}</span>
                   <IconChevronRightOutline14 className={css.cellChevron} />

+ 187 - 0
packages/client/ui-model-selection/tests/model-select.client.spec.tsx

@@ -252,3 +252,190 @@ describe('ModelSelect reasoning effort', () => {
     expect(load).not.toHaveBeenCalled()
   })
 })
+
+describe('ModelSelect keyboard walk', () => {
+  function mountOpen() {
+    const select = vi.fn().mockResolvedValue(true)
+    render(<ModelSelect
+      locked={false}
+      available
+      directory={createSnapshotStore(state())}
+      load={vi.fn()}
+      select={select}
+      t={t}
+    />)
+    fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
+    return select
+  }
+
+  it('↑↓ walk the rows of the shown pane, wrapping, and stay open', () => {
+    mountOpen()
+    // The trigger holds focus while the menu opens: the first forward step
+    // enters at the first cell instead of skipping it. false = preventDefault ran.
+    const cells = screen.getAllByRole('menuitem')
+    expect(fireEvent.keyDown(cells[0]!, { key: 'ArrowDown' })).toBe(false)
+    expect(document.activeElement).toBe(cells[0])
+
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    expect(rows.map(row => row.textContent)).toEqual(['Off', 'High', 'Max'])
+    // The pane opens on its checked row, so walking starts from High.
+    fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(rows[2])
+    fireEvent.keyDown(rows[2]!, { key: 'ArrowDown' }) // wraps to the top
+    expect(document.activeElement).toBe(rows[0])
+    fireEvent.keyDown(rows[0]!, { key: 'ArrowUp' }) // wraps to the bottom
+    expect(document.activeElement).toBe(rows[2])
+    expect(screen.getByRole('menu')).toBeTruthy()
+  })
+
+  it('Tab settles the focused row like Enter and closes the menu', async () => {
+    const select = mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' }) // High → Max
+    expect(fireEvent.keyDown(rows[2]!, { key: 'Tab' })).toBe(false)
+    expect(select).toHaveBeenCalledWith({
+      provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max',
+    })
+    await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
+  })
+
+  it('Shift+Tab leaves a drilled pane and then closes, like Escape', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    expect(fireEvent.keyDown(rows[0]!, { key: 'Tab', shiftKey: true })).toBe(false)
+    // Back on the drilled cell, then closed on the second press.
+    const cells = screen.getAllByRole('menuitem')
+    expect(document.activeElement).toBe(cells[1])
+    expect(screen.getByRole('menu')).toBeTruthy()
+    fireEvent.keyDown(cells[1]!, { key: 'Tab', shiftKey: true })
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
+  it('Tab with the keyboard still on the trigger enters the menu at the value in use', () => {
+    render(<ModelSelect
+      locked={false}
+      available
+      directory={createSnapshotStore(state())}
+      load={vi.fn()}
+      select={vi.fn().mockResolvedValue(true)}
+      t={t}
+    />)
+    const trigger = screen.getByRole('button', { name: /选择模型/ })
+    // A real click focuses the trigger first; jsdom's does not.
+    trigger.focus()
+    fireEvent.click(trigger)
+    expect(fireEvent.keyDown(trigger, { key: 'Tab' })).toBe(false)
+    // The root pane's first cell carries the current selection.
+    const cells = screen.getAllByRole('menuitem')
+    expect(document.activeElement).toBe(cells[0])
+    expect(screen.getByRole('menu')).toBeTruthy()
+  })
+
+  it('a backward step from outside the list enters at the last row, and a closed menu leaves Tab native', () => {
+    mountOpen()
+    const [modelRow, effortRow] = screen.getAllByRole('menuitem')
+    expect(fireEvent.keyDown(modelRow!, { key: 'ArrowUp' })).toBe(false)
+    expect(document.activeElement).toBe(effortRow)
+    const trigger = screen.getByRole('button', { name: /选择模型/ })
+    fireEvent.keyDown(trigger, { key: 'Escape' })
+    expect(screen.queryByRole('menu')).toBeNull()
+    expect(fireEvent.keyDown(trigger, { key: 'Tab' })).toBe(true)
+  })
+
+  it('hands a drilled pane the focus its unmounted cell left behind, on the value in use', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    // The fixture's model defaults to the High effort: the checked row is where
+    // the keyboard lands, not the top of the list.
+    expect(rows[1]!.getAttribute('aria-checked')).toBe('true')
+    expect(document.activeElement).toBe(rows[1])
+    // The walk continues from there.
+    fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(rows[2])
+  })
+
+  it('keeps the card navigable when a pane has no rows, and leaves a retry its Tab', () => {
+    const directory = createSnapshotStore<ModelDirectoryState>(state({
+      groups: [], failures: [], status: 'error', error: 'catalog down',
+    }))
+    render(<ModelSelect
+      locked={false}
+      available
+      directory={directory}
+      load={vi.fn()}
+      select={vi.fn().mockResolvedValue(true)}
+      t={t}
+    />)
+    const trigger = screen.getByRole('button', { name: /选择模型/ })
+    // A real click focuses the trigger first; jsdom's does not.
+    trigger.focus()
+    fireEvent.click(trigger)
+    fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
+    // No rows to hand the keyboard to: the trigger keeps it, so the card's
+    // keys still reach the menu.
+    expect(document.activeElement).toBe(trigger)
+
+    const retry = screen.getByRole('button', { name: '重试' })
+    retry.focus()
+    // A control that is not a row keeps the browser's traversal.
+    expect(fireEvent.keyDown(retry, { key: 'Tab' })).toBe(true)
+    // Escape still backs out of the pane and then closes the card.
+    fireEvent.keyDown(retry, { key: 'Escape' })
+    // Back on the root pane, whose only cell remains (no model means no effort row).
+    const cell = screen.getAllByRole('menuitem')[0]!
+    fireEvent.keyDown(cell, { key: 'Escape' })
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
+  it('drills into the model list on the selected model', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    expect(rows[0]!.getAttribute('aria-checked')).toBe('true')
+    expect(document.activeElement).toBe(rows[0])
+  })
+
+  it('Escape returns to the root pane with the keyboard on the cell that drilled in', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    fireEvent.keyDown(rows[0]!, { key: 'Escape' })
+    // The root pane is back with its two cells.
+    const cells = screen.getAllByRole('menuitem')
+    // Back on the drilled cell, so the next keystroke still reaches the menu.
+    expect(document.activeElement).toBe(cells[1])
+    expect(screen.getByRole('menu')).toBeTruthy()
+    // A second Escape closes back to the trigger.
+    fireEvent.keyDown(cells[1]!, { key: 'Escape' })
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
+  it('Escape from the model list lands back on the model cell', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
+    fireEvent.keyDown(screen.getAllByRole('menuitemradio')[0]!, { key: 'Escape' })
+    const cells = screen.getAllByRole('menuitem')
+    expect(document.activeElement).toBe(cells[0])
+  })
+
+  it('a pane whose rows mark no current value opens on its first row', () => {
+    // The session runs a model the catalog no longer lists: no row is checked.
+    render(<ModelSelect
+      locked={false}
+      available
+      directory={createSnapshotStore(state({ current: { provider: 'gone', model: 'gone' } }))}
+      load={vi.fn()}
+      select={vi.fn().mockResolvedValue(true)}
+      t={t}
+    />)
+    fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
+    fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true)
+    expect(document.activeElement).toBe(rows[0])
+  })
+})

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 631b19de44660c21813bc4f87b7a59dc8dfa6ee3
-README.zh.md: b23c87f7861555625f28a9fd182aa6acb159b21c
+README.md: 5e8b41a3119610ed7ca40f9ca974cc56cfc50cd7
+README.zh.md: 06b56d6dcd629aa1faef36950e6c884a1d863fee

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -39,7 +39,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. |
 | `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. |
 | `Input` | Single-line text entry for search boxes and inline forms. |
-| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. |
+| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
 | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
 | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
 | `StateDot` | Status mark: `done`, `warning`, `ongoing`, `error`, or `idle`. `aria-hidden`, so the render site owns the name. |

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -39,7 +39,7 @@ kind: "package-library"
 | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 |
 | `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 |
 | `Input` | 单行文本输入,用于搜索框与行内表单。 |
-| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。 |
+| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
 | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
 | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
 | `StateDot` | 状态标记:`done`、`warning`、`ongoing`、`error` 或 `idle`。它是 `aria-hidden` 的,名称由渲染点提供。 |

+ 13 - 0
packages/client/ui-primitives/src/Menu.module.css

@@ -116,6 +116,14 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+/* Arrow navigation moves real focus, so the row the keyboard is on carries the
+   same fill the pointer gets: the fill is the row's focus indication, and the
+   browser's default ring would double it. */
+.item:focus-visible:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover);
+  outline: none;
+}
+
 .denseList .item {
   min-height: 34px;
   padding-block: 5px;
@@ -208,6 +216,11 @@
   background: var(--dsw-alias-interactive-bg-hover-danger);
 }
 
+.danger:focus-visible:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover-danger);
+  outline: none;
+}
+
 /* Heading row: non-interactive small grey text, padding aligned with items. */
 .label {
   padding: 8px 10px;

+ 127 - 11
packages/client/ui-primitives/src/Menu.tsx

@@ -47,8 +47,13 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
 const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
 
 /**
- * Render an anchored dropdown menu.
- * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape focuses the anchor's first button.
+ * Render an anchored dropdown menu. While the list is open its keys mirror the
+ * composer's: Tab settles the focused row — from the trigger, Tab enters the
+ * list instead — and Escape or Shift+Tab close it and return focus to the
+ * anchor's first button, and selecting a row does the same — the rows unmount
+ * with the list. Only a keyboard on the trigger or inside the list is
+ * intercepted; Tab presses elsewhere on the page stay the browser's.
+ * @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way.
  * @param props.open - whether the list is showing (owner-controlled).
  * @param props.anchor - the trigger element (rendered in place).
  * @param props.items - selectable rows and optional separators.
@@ -106,6 +111,46 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
 }) {
   const rootRef = useRef<HTMLSpanElement>(null)
   const listRef = useRef<HTMLDivElement>(null)
+  /** Index the arrow walk last focused, the resume point when focus left the rows. */
+  const walkIndex = useRef<number | null>(null)
+  /**
+   * The control that had the keyboard when this menu opened — its own trigger,
+   * which an anchor that wraps several controls (a split button) would not be
+   * able to name by position.
+   */
+  const triggerRef = useRef<HTMLElement | null>(null)
+
+  /**
+   * Hand the keyboard back to the trigger that opened the menu — or, when the
+   * anchor never held it, to the anchor's first button. Focus left on a removed
+   * row otherwise falls to the page body, where the next Tab restarts from the
+   * top of the page.
+   */
+  const refocusAnchor = (): void => {
+    const trigger = triggerRef.current
+    if (trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled) {
+      trigger.focus()
+      return
+    }
+    rootRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus()
+  }
+
+  /**
+   * Post-selection focus, for the paths where the rows unmount with the list.
+   * A selection whose owner keeps the menu open is left alone, and so is an
+   * owner that moved focus itself (a presented file card hands it to its
+   * preview button): only a keyboard left on the closing list (or on the body
+   * its removal produced) comes back to the trigger.
+   */
+  const refocusAfterSelection = (): void => {
+    queueMicrotask(() => {
+      if (openRef.current) return
+      const active = document.activeElement
+      if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor()
+    })
+  }
+  const openRef = useRef(open)
+  openRef.current = open
   const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
   const [fixedPos, setFixedPos] = useState<CSSProperties | null>(null)
   const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose)
@@ -163,13 +208,30 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
     }
   }, [open, portal, align, side, getAnchorRect])
 
+  // Opening remembers where the keyboard was, so closing can hand it back to
+  // that control — an anchor wrapping several (a split button) cannot be asked
+  // for it by position. Declared before the autoFocus effect so the capture
+  // sees the trigger, not the row autoFocus is about to focus.
+  useEffect(() => {
+    if (!open) {
+      triggerRef.current = null
+      return
+    }
+    const active = document.activeElement
+    triggerRef.current = active instanceof HTMLElement && rootRef.current?.contains(active) === true ? active : null
+  }, [open])
+
   useEffect(() => {
-    if (open && autoFocus) listRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus()
+    if (!open || !autoFocus) return
+    const first = listRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')
+    walkIndex.current = first === undefined || first === null ? null : 0
+    first?.focus()
   }, [open, autoFocus])
 
   useEffect(() => {
     if (!open) {
       setOpenSubmenuId(null)
+      walkIndex.current = null
       return
     }
     const onPointerDown = (e: PointerEvent) => {
@@ -180,17 +242,70 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
       onClose()
     }
     const onKeyDown = (e: KeyboardEvent) => {
+      // Where the keyboard is, computed once: the menu owns it when it holds a
+      // row or sits on its anchor region.
+      const focused = document.activeElement
+      const insideList = listRef.current?.contains(focused) === true
+      const anchored = rootRef.current?.contains(focused) === true || insideList
       if (e.key === 'Escape') {
+        // Closing hands the keyboard back when the menu had it — and, as this
+        // primitive always did for autoFocus menus, when it held the keyboard
+        // and lost it again (a row that unmounted under it).
         onClose()
-        if (autoFocus) rootRef.current?.querySelector<HTMLButtonElement>('button')?.focus()
+        if (anchored || autoFocus) refocusAnchor()
       }
-      if (!autoFocus || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
-      const buttons = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])
-      const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
-      if (index < 0) return
-      e.preventDefault()
+      // Tab settles like Enter and Shift+Tab leaves like Escape, so a menu's
+      // keys mean what they mean in the composer. Only a keyboard already on
+      // the trigger or inside the list is intercepted: Tab elsewhere on the
+      // page keeps the browser's traversal even while a menu is open.
+      if (e.key === 'Tab') {
+        const list = listRef.current
+        if (list === null || !anchored) return
+        if (e.shiftKey) {
+          e.preventDefault()
+          onClose()
+          refocusAnchor()
+          return
+        }
+        // Tab settles the row it is on; from anywhere else in the menu region
+        // it enters the list. A focused control that is not a row (a retry
+        // button inside an error strip) and a list with no enabled row keep the
+        // browser's traversal instead of being swallowed.
+        if (insideList) {
+          if (focused instanceof Element && focused.getAttribute('role') === 'menuitem') {
+            e.preventDefault()
+            ;(focused as HTMLElement).click()
+          }
+          return
+        }
+        const row = list.querySelector<HTMLButtonElement>('button:not(:disabled)')
+        if (row === null) return
+        e.preventDefault()
+        row.focus()
+        walkIndex.current = 0
+        return
+      }
+      // Arrows walk the list whether or not the menu focused its first item on
+      // open, so `autoFocus` chooses only that entry behavior. A keyboard still
+      // on the anchor enters at the end the step comes from — unless it already
+      // walked, in which case the walk resumes where it left off. The walk resumes
+      // from where it last put focus, not from `document.activeElement`: a row
+      // that refused focus (a hidden portal frame, a detached node) would
+      // otherwise re-enter at the near end on every press and the walk would
+      // alternate between two rows.
+      if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
+      const list = listRef.current
+      if (list === null || !anchored) return
+      const buttons = Array.from(list.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'))
+      if (buttons.length === 0) return
+      const index = buttons.indexOf(focused as HTMLButtonElement)
+      const from = index >= 0 ? index : walkIndex.current
       const next = e.key === 'Home' ? 0 : e.key === 'End' ? buttons.length - 1
-        : (index + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length
+        : from === null
+          ? (e.key === 'ArrowDown' ? 0 : buttons.length - 1)
+          : (from + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length
+      e.preventDefault()
+      walkIndex.current = next
       buttons[next]?.focus()
     }
     // A pointerdown inside a cross-origin iframe (a sandboxed HTML preview)
@@ -253,6 +368,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
               return
             }
             onSelect(entry.id)
+            refocusAfterSelection()
           }}
         >
           {entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
@@ -269,7 +385,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
                 role="menuitem"
                 className={css.item}
                 disabled={sub.disabled}
-                onClick={() => { onSelect(sub.id) }}
+                onClick={() => { onSelect(sub.id); refocusAfterSelection() }}
               >
                 {sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
                 <span className={css.itemLabel}>{sub.label}</span>

+ 2 - 1
packages/client/ui-primitives/src/Modal.tsx

@@ -24,7 +24,8 @@ type ModalProps = ModalBaseProps & (
 /**
  * Render a centered, body-portaled modal over a blurred page mask.
  * @param props.open - whether the dialog is showing.
- * @param props.onClose - Escape or mask click.
+ * @param props.onClose - Escape or mask click; while a menu is open inside the
+ * dialog, Escape belongs to that menu first.
  * @param props.title - dialog heading (aria-label in every mode).
  * @param props.closeLabel - localized accessible close-button label.
  * @param props.description - optional supporting sentence under the title.

+ 79 - 0
packages/client/ui-primitives/tests/atoms.client.spec.tsx

@@ -177,6 +177,85 @@ describe('Menu', () => {
     expect(screen.getByRole('separator')).toBeDefined()
   })
 
+  it('Tab settles the focused row; Shift+Tab closes back to the anchor', () => {
+    const onSelect = vi.fn()
+    const onClose = vi.fn()
+    render(
+      <Menu open autoFocus anchor={<button type="button">trigger</button>} items={items} onSelect={onSelect} onClose={onClose} />)
+    // autoFocus parks the keyboard on the first row; Tab settles it like Enter.
+    const alpha = screen.getByRole('menuitem', { name: 'Alpha' })
+    expect(document.activeElement).toBe(alpha)
+    expect(fireEvent.keyDown(alpha, { key: 'Tab' })).toBe(false)
+    expect(onSelect).toHaveBeenCalledExactlyOnceWith('a')
+
+    // Shift+Tab leaves like Escape: closed, with the trigger taking the keyboard.
+    fireEvent.keyDown(alpha, { key: 'Tab', shiftKey: true })
+    expect(onClose).toHaveBeenCalledTimes(1)
+    expect(document.activeElement).toBe(screen.getByRole('button', { name: 'trigger' }))
+  })
+
+  it('Tab from the trigger enters the open list, and elsewhere on the page stays native', () => {
+    render(
+      <Menu open anchor={<button type="button">trigger</button>} items={items} onSelect={() => {}} onClose={() => {}} />)
+    const trigger = screen.getByRole('button', { name: 'trigger' })
+    trigger.focus()
+    expect(fireEvent.keyDown(trigger, { key: 'Tab' })).toBe(false)
+    expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' }))
+
+    // A keyboard outside both the anchor and the list keeps the traversal.
+    const outside = document.createElement('button')
+    document.body.append(outside)
+    outside.focus()
+    expect(fireEvent.keyDown(outside, { key: 'Tab' })).toBe(true)
+    outside.remove()
+  })
+
+  it('walks the list with the arrows without autoFocus, wrapping at both ends', () => {
+    const onClose = vi.fn()
+    const rows = [
+      { id: 'a', label: 'Alpha' },
+      { id: 'b', label: 'Beta' },
+      { id: 'c', label: 'Gamma' },
+    ]
+    render(
+      <Menu open anchor={<button type="button">trigger</button>} items={rows} onSelect={() => {}} onClose={onClose} />)
+    const trigger = screen.getByRole('button', { name: 'trigger' })
+    const alpha = screen.getByRole('menuitem', { name: 'Alpha' })
+    const beta = screen.getByRole('menuitem', { name: 'Beta' })
+    const gamma = screen.getByRole('menuitem', { name: 'Gamma' })
+    trigger.focus()
+    // autoFocus is off: the menu opens with the keyboard on the anchor, and the
+    // first step enters at the near end.
+    expect(document.activeElement).toBe(trigger)
+    expect(fireEvent.keyDown(trigger, { key: 'ArrowDown' })).toBe(false)
+    expect(document.activeElement).toBe(alpha)
+    fireEvent.keyDown(alpha, { key: 'ArrowDown' })
+    fireEvent.keyDown(beta, { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(gamma)
+    fireEvent.keyDown(gamma, { key: 'ArrowDown' }) // wraps forwards
+    expect(document.activeElement).toBe(alpha)
+    fireEvent.keyDown(alpha, { key: 'ArrowUp' }) // wraps backwards
+    expect(document.activeElement).toBe(gamma)
+    fireEvent.keyDown(gamma, { key: 'Home' })
+    expect(document.activeElement).toBe(alpha)
+    fireEvent.keyDown(alpha, { key: 'End' })
+    expect(document.activeElement).toBe(gamma)
+    // Escape closes and hands the keyboard back to the anchor.
+    fireEvent.keyDown(gamma, { key: 'Escape' })
+    expect(onClose).toHaveBeenCalledTimes(1)
+    expect(document.activeElement).toBe(trigger)
+  })
+
+  it('enters at the last enabled row on ↑ and steps over a disabled one', () => {
+    render(
+      <Menu open anchor={<button type="button">trigger</button>} items={items} onSelect={() => {}} onClose={() => {}} />)
+    const trigger = screen.getByRole('button', { name: 'trigger' })
+    trigger.focus()
+    // Beta is disabled: it is not a step target, so Alpha is the only row.
+    expect(fireEvent.keyDown(trigger, { key: 'ArrowUp' })).toBe(false)
+    expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' }))
+  })
+
   it('renders a non-interactive heading label and a danger row', () => {
     const onSelect = vi.fn()
     render(

+ 2 - 2
packages/llm/llm-deepseek/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
-README.md: 6121a19003a628204bfb5f8a3d6b5d969bd19104
-README.zh.md: 69e5c103e0edd8d16f606ed11a7aaf602ff5944e
+README.md: f332d59b0fe63e5129e5205fbba1ab1901b34d7f
+README.zh.md: 26ce84e2497c637cbfe42bbbaf72fc07f27c3e82

+ 2 - 2
packages/llm/llm-deepseek/README.md

@@ -45,7 +45,7 @@ Choose this adapter for DeepSeek's official API or a gateway that supports the s
     filesApiTimeoutMs: 60000
 ```
 
-A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` and `deepseek-v4-flash-vision-exp` alongside the text-only `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and unlisted model ids still pass through as text-only routes. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "<id>" systemPromptUpdate must be "in-history" when present`.
+A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and unlisted model ids still pass through as text-only routes. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "<id>" systemPromptUpdate must be "in-history" when present`.
 
 | Field | Default | Meaning |
 |---|---|---|
@@ -56,7 +56,7 @@ A request selects the route with `provider: deepseek-official`; the model id pas
 | `reasoningEffort` | `high` | Default effort: `off`, `low`, `high`, or `max` |
 | `maxTokens` | `256,000` | Per-request output cap; a model's own cap and explicit request values win |
 | `defaultContextWindow` | `1,000,000` | Capacity fallback for models without an exact value |
-| `models` | V41 Flash + V4 Flash + V4 Pro + V4 Flash Vision Exp | Advisory catalog shown by discovery consumers |
+| `models` | V41 Flash + V4 Pro | Advisory catalog shown by discovery consumers |
 | `streamIdleTimeoutMs` | `300,000` | Maximum provider idle time per outstanding stream read |
 | `maxRequestFilesBytes` | `128 MiB` | File-mode request-image byte budget; a request whose retained images exceed it fails with `IMAGE_OFFLOAD_REQUIRED` |
 | `maxInlineRequestImageBytes` | `20 MiB` | Independent base64 fallback high watermark |

+ 2 - 2
packages/llm/llm-deepseek/README.zh.md

@@ -45,7 +45,7 @@ kind: "package-reference"
     filesApiTimeoutMs: 60000
 ```
 
-请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash` 和 `deepseek-v4-flash-vision-exp`,以及仅支持文本的 `deepseek-v4-flash` 和 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,未列出的模型 id 仍作为纯文本路由原样通过。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "<id>" systemPromptUpdate must be "in-history" when present` 失败。
+请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,未列出的模型 id 仍作为纯文本路由原样通过。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "<id>" systemPromptUpdate must be "in-history" when present` 失败。
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
@@ -56,7 +56,7 @@ kind: "package-reference"
 | `reasoningEffort` | `high` | 默认强度:`off`、`low`、`high` 或 `max` |
 | `maxTokens` | `256,000` | 单次请求输出上限;模型自身上限与显式请求值优先 |
 | `defaultContextWindow` | `1,000,000` | 无精确值模型的容量回退 |
-| `models` | V41 Flash + V4 Flash + V4 Pro + V4 Flash Vision Exp | 供发现消费方查看的建议性目录 |
+| `models` | V41 Flash + V4 Pro | 供发现消费方查看的建议性目录 |
 | `streamIdleTimeoutMs` | `300,000` | 单次流读取未完成的最大提供方空闲时间 |
 | `maxRequestFilesBytes` | `128 MiB` | file 模式请求图片字节预算,保留图片超过时请求以 `IMAGE_OFFLOAD_REQUIRED` 失败 |
 | `maxInlineRequestImageBytes` | `20 MiB` | 独立的 base64 回退高水位 |

+ 0 - 12
packages/llm/llm-deepseek/src/common/models.ts

@@ -11,22 +11,10 @@ export const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
     inputModalities: ['text', 'image'],
     systemPromptUpdate: 'in-history',
   },
-  {
-    id: 'deepseek-v4-flash',
-    name: 'DeepSeek-V4-Flash',
-    description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
-    contextWindow: DEFAULT_CONTEXT_WINDOW,
-  },
   {
     id: 'deepseek-v4-pro',
     name: 'DeepSeek-V4-Pro',
     description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
     contextWindow: DEFAULT_CONTEXT_WINDOW,
   },
-  {
-    id: 'deepseek-v4-flash-vision-exp',
-    name: 'DeepSeek-V4-Flash-Vision-Exp',
-    contextWindow: DEFAULT_CONTEXT_WINDOW,
-    inputModalities: ['text', 'image'],
-  },
 ]

+ 1 - 1
packages/llm/llm-deepseek/src/config.ts

@@ -37,7 +37,7 @@ export interface Config {
   maxTokens?: number
   /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
   defaultContextWindow?: number
-  /** Advisory models shown by discovery consumers; defaults to V41 Flash, V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
+  /** Advisory models shown by discovery consumers; defaults to V41 Flash and V4 Pro. */
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number

+ 8 - 28
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -1712,13 +1712,6 @@ describe('plugin registration and config', () => {
     expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
     await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
       { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] },
-      {
-        provider: 'deepseek-official',
-        id: 'deepseek-v4-flash',
-        name: 'DeepSeek-V4-Flash',
-        description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
-        inputModalities: ['text'],
-      },
       {
         provider: 'deepseek-official',
         id: 'deepseek-v4-pro',
@@ -1726,7 +1719,6 @@ describe('plugin registration and config', () => {
         description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
         inputModalities: ['text'],
       },
-      { provider: 'deepseek-official', id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] },
     ])
     await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash'))
       .resolves.toMatchObject({
@@ -1749,18 +1741,14 @@ describe('plugin registration and config', () => {
       })
   })
 
-  it.each([
-    { model: 'deepseek-v4-flash', inputModalities: ['text'] },
-    { model: 'deepseek-v4-pro', inputModalities: ['text'] },
-    { model: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] },
-  ])('keeps $model available with its V4 capabilities', async ({ model, inputModalities }) => {
+  it('keeps deepseek-v4-pro available with its V4 capabilities', async () => {
     const ctx = new Context()
     await ctx.plugin(LlmRuntime)
     await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' })
-    const info = await ctx.llm.resolveModelInfo('deepseek-official', model)
+    const info = await ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro')
     expect(info).toMatchObject({
-      id: model,
-      inputModalities,
+      id: 'deepseek-v4-pro',
+      inputModalities: ['text'],
       context: { contextWindow: 1_000_000 },
       defaultMaxTokens: 256_000,
     })
@@ -1854,13 +1842,6 @@ describe('plugin registration and config', () => {
     LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' })
     await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
       { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] },
-      {
-        provider: 'deepseek-official',
-        id: 'deepseek-v4-flash',
-        name: 'DeepSeek-V4-Flash',
-        description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
-        inputModalities: ['text'],
-      },
       {
         provider: 'deepseek-official',
         id: 'deepseek-v4-pro',
@@ -1868,7 +1849,6 @@ describe('plugin registration and config', () => {
         description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
         inputModalities: ['text'],
       },
-      { provider: 'deepseek-official', id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] },
     ])
   })
 
@@ -2193,11 +2173,11 @@ describe('plugin registration and config', () => {
     // First-boot onboarding: the route registers so models stay discoverable;
     // only the request itself needs a key.
     expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
-    await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(4)
-    const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
+    await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
+    const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] })
     expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
     // The guidance leads with the managed credential store.
-    const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
+    const second = await assemble(ctx, { model: 'deepseek-flash', messages: [] })
     expect(second.finish.kind).toBe('error')
     if (second.finish.kind !== 'error') throw new Error('expected an error finish')
     // The guidance names both places a credential can come from, and nothing
@@ -2281,7 +2261,7 @@ describe('plugin registration and config', () => {
     expect(adapter).toBeInstanceOf(DeepSeekAdapter)
     // Direct embedding shares the plugin's one resolve step, so it advertises
     // the same default catalog instead of a divergent empty one.
-    await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(4)
+    await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2)
   })
 
   it('resolves connection facts and the credential exactly once per stream call', async () => {

+ 2 - 2
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts

@@ -191,7 +191,7 @@ describe('request-level dynamic configuration', () => {
     const dir = await home()
     const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
 
-    await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(4)
+    await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
     await ctx.settings.update(NS, {
       models: [{ id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }],
     })
@@ -276,7 +276,7 @@ describe('request-level dynamic configuration', () => {
     // Schema-valid but resolver-invalid: duplicate catalog ids pass the array
     // schema and fail the explicit resolve step.
     await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] })
-    await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(4)
+    await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
     await ctx.settings.update(NS, { models: [{ id: 'recovered' }] })
     await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
       { provider: 'deepseek-official', id: 'recovered', name: 'recovered', inputModalities: ['text'] },

+ 2 - 2
packages/llm/llm-deepseek/tests/messages/adapter.spec.ts

@@ -82,7 +82,7 @@ describe('direct Messages HTTP', () => {
     }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } })
     expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' })
     expect((await llm.listModels('deepseek-official')).map(model => model.id)).toEqual([
-      'deepseek-flash', 'deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp',
+      'deepseek-flash', 'deepseek-v4-pro',
     ])
     expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({
       name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
@@ -188,7 +188,7 @@ describe('Cordis provider composition', () => {
     vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
     await ctx.plugin(LlmRuntime)
     await ctx.plugin(Messages, { baseURL: http.url })
-    const model = 'deepseek-v4-flash-vision-exp'
+    const model = 'deepseek-flash'
     const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)!
     const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const }
     expect(price().priceImages([{ type: 'image', attachment: dummy }])[0]?.text).toBeDefined()

+ 2 - 2
packages/llm/llm-deepseek/tests/messages/serialize.spec.ts

@@ -251,11 +251,11 @@ describe('Messages images', () => {
   const image: ImageBlock = { type: 'image', attachment: ref }
   const version: RequestImageAttachment = { attachment: ref, variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 3, data: Uint8Array.of(1, 2, 3), width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: false }
   const access = () => ({ readonlyPath: '/workspace/image.png' })
-  const model = 'deepseek-v4-flash-vision-exp'
+  const model = 'deepseek-flash'
   // Only the read operation is consumed by image preparation; the transport is mocked, not durable content.
   const attachments = { readImageRequest: async () => version } as unknown as AttachmentStore
   const signal = new AbortController().signal
-  it.each(['deepseek-flash', model])('keeps image bytes inside tool results and deduplicates normalization for %s', async (model) => {
+  it('keeps image bytes inside tool results and deduplicates normalization', async () => {
     const history = [assistant([call()]), result('a', [image, image])]
     const prepared = await prepareImages(history, connection, model, attachments, access, signal)
     expect(prepared.versions.size).toBe(1)

Неке датотеке нису приказане због велике количине промена