Kaynağa Gözat

fix(client): tighten the keyboard hand-offs, and drop the dialog Escape layering

Review follow-ups on the hand-offs this branch added:

- Menu gives the keyboard back to the control that opened it (an anchor may
  wrap several, as a split button does), falls back to a disabled-free anchor
  button, leaves a Tab it cannot use to the browser, and no longer moves focus
  while the owner keeps the list open.
- The model seat settles only real rows with Tab and keeps the keyboard on its
  trigger when a pane's rows are disabled or absent.
- The popup shell leaves Tab to the browser when nothing can be settled (the
  error strip's retry button stays reachable), and a dismissal caused by a
  stale catalog returns the keyboard like the other paths.
- A frozen-tier track is the submit gesture's own bookkeeping and no longer
  re-arms a menu the user dismissed.
- The launcher reuses the shared caret-preserving focus helper; the option
  contract records that `active` is the row an accept gesture confirms; the
  conversation README documents the composer's key and focus behavior.

The dialog/menu Escape layering this branch also carried is out of scope here
and has been removed again.
liukx0205 3 hafta önce
ebeveyn
işleme
5c3081eeaa
23 değiştirilmiş dosya ile 117 ekleme ve 199 silme
  1. 0 6
      .agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.i18n.yaml
  2. 0 29
      .agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.md
  3. 0 29
      .agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.zh.md
  4. 4 0
      packages/client/ui-commands/src/client/PopupSelectView.tsx
  5. 6 0
      packages/client/ui-commands/src/client/contract.ts
  6. 4 1
      packages/client/ui-commands/src/client/service.ts
  7. 3 2
      packages/client/ui-commands/tests/popup-view.client.spec.tsx
  8. 2 1
      packages/client/ui-commands/tests/service.client.spec.ts
  9. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  10. 1 1
      packages/client/ui-conversation/README.md
  11. 1 1
      packages/client/ui-conversation/README.zh.md
  12. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  13. 4 1
      packages/client/ui-input-trigger/src/client/controller.ts
  14. 12 6
      packages/client/ui-model-selection/src/client/ModelSelect.tsx
  15. 11 1
      packages/client/ui-model-selection/tests/model-select.client.spec.tsx
  16. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  17. 1 1
      packages/client/ui-primitives/README.md
  18. 1 1
      packages/client/ui-primitives/README.zh.md
  19. 60 19
      packages/client/ui-primitives/src/Menu.tsx
  20. 1 4
      packages/client/ui-primitives/src/Modal.tsx
  21. 0 70
      packages/client/ui-primitives/tests/atoms.client.spec.tsx
  22. 1 4
      packages/client/ui-settings-general/src/client/SettingsRoot.tsx
  23. 0 17
      packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

+ 0 - 6
.agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.i18n.yaml

@@ -1,6 +0,0 @@
-# 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/bug-fix/2026-09-15-overlay-escape-layering.md
-2026-09-15-overlay-escape-layering.md: 6a8e2affca2f1150fd0746bd63b242472d88e2cf
-2026-09-15-overlay-escape-layering.zh.md: 78e395b62bc2e69f394117b0a5b96aadf94950e9

+ 0 - 29
.agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.md

@@ -1,29 +0,0 @@
-# Agent Note: An open menu takes the first Escape from its dialog
-
-Status: implemented
-
-English | [中文](2026-09-15-overlay-escape-layering.zh.md)
-
-## Problem
-
-`Modal` and `Menu` each close on Escape through their own document-level keydown listener. A menu open inside a dialog is therefore closed by the same keystroke that closes the dialog: one Escape discards two layers of the user's context and leaves the keyboard wherever it was before the dialog opened. Every settings row, the workspace browser, and the terminal body render a menu inside a dialog.
-
-## Decision
-
-The dialog yields the first Escape to an open menu. `Modal`'s keydown listener returns while `document.querySelector('[role="menu"]')` matches, so the menu's own handler closes the menu and returns the keyboard to its anchor; the next Escape reaches the dialog. Menus that render no `[role="menu"]` — the composer's trigger menu and popup select panel are listboxes — keep their own layering and are unaffected.
-
-## Alternatives considered
-
-**Order the two listeners.** Both sit on `document`, so which runs first is registration order: a dialog mounts before the menu it opens, so the dialog always wins. Making that order carry the rule would be fragile.
-
-**Track an overlay stack in a service.** Two nested layers in one place do not justify the machinery, and nothing else needs the ordering.
-
-**Let one Escape close both.** It is the behavior this decision removes: a single keystroke must not discard both the menu and the dialog the user opened it from.
-
-## Verification
-
-[Dialog tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) render a `Menu` inside an open `Modal`: the first Escape closes the menu and leaves the dialog standing with the keyboard on the menu's anchor, and the second Escape reaches the dialog.
-
-## Consequences
-
-Escape now walks the overlay layers one at a time: menu, then dialog. The dialog's own Escape keeps closing it directly when no menu is open inside it, and a menu that is not a `[role="menu"]` never intercepts the dialog's Escape.

+ 0 - 29
.agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.zh.md

@@ -1,29 +0,0 @@
-# Agent Note: 打开的菜单从所在对话框手中接管第一次 Escape
-
-Status: implemented
-
-[English](2026-09-15-overlay-escape-layering.md) | 中文
-
-## 问题
-
-`Modal` 与 `Menu` 各自在 document 上挂 keydown 监听来响应 Escape。因此对话框内打开的菜单会被关闭对话框的同一次按键一并关掉:一次 Escape 丢掉用户上下文中的两层,并把键盘留在对话框打开前的位置。设置页的每一行、工作区浏览器与终端正文都会在对话框内渲染菜单。
-
-## 决策
-
-对话框把第一次 Escape 让给已打开的菜单。当 `document.querySelector('[role="menu"]')` 命中时,`Modal` 的 keydown 监听直接返回,于是菜单自己的处理关闭菜单并把焦点还给锚点;第二次 Escape 才到达对话框。不渲染 `[role="menu"]` 的菜单——composer 的触发菜单与 popupSelect 面板都是 listbox——保留各自的分层,不受影响。
-
-## 备选方案
-
-**给两个监听排顺序。** 两者都挂在 `document` 上,谁先执行取决于注册顺序:对话框先于它所打开的菜单挂载,因此永远是对话框先跑。把规则寄托在这个顺序上并不可靠。
-
-**用服务维护一个浮层栈。** 只有一处两层嵌套,不值得为此引入机制,也没有其他调用方需要这个顺序。
-
-**让一次 Escape 关掉两层。** 这正是本决策要消除的行为:一次按键不该丢掉用户打开的菜单与对话框两者。
-
-## 验证
-
-[对话框测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 在打开的 `Modal` 内渲染一个 `Menu`:第一次 Escape 关闭菜单、对话框保持打开且键盘落在菜单锚点上,第二次 Escape 才到达对话框。
-
-## 影响
-
-Escape 现在逐层退出:先菜单,后对话框。对话框内没有菜单时,它自身的 Escape 仍直接关闭;不渲染 `[role="menu"]` 的菜单也永远不会拦下对话框的 Escape。

+ 4 - 0
packages/client/ui-commands/src/client/PopupSelectView.tsx

@@ -101,6 +101,10 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
       // 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)

+ 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

+ 4 - 1
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 })
     }
   }
 

+ 3 - 2
packages/client/ui-commands/tests/popup-view.client.spec.tsx

@@ -146,12 +146,13 @@ describe('PopupSelectView', () => {
     expect(settling.view.container.childElementCount).toBe(0)
   })
 
-  it('Tab is consumed while the rows are still loading: no pick, no focus escape', async () => {
+  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: '筛选选项' })
-    expect(fireEvent.keyDown(search, { key: 'Tab' })).toBe(false)
+    // 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()
   })

+ 2 - 1
packages/client/ui-commands/tests/service.client.spec.ts

@@ -991,7 +991,8 @@ describe('popupFor', () => {
     expect(second.state.getSnapshot()).toMatchObject({ open: false, options: [] })
     expect(onSelect).not.toHaveBeenCalled()
     expect(consume).not.toHaveBeenCalled()
-    expect(focuses).toEqual([])
+    // 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 () => {

+ 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: 5b94862d830d27ae02e60832294c7595537b47c0
-README.zh.md: 6d7bb72ba582f90356f4bc08a78b2cdfdf211db7
+README.md: a82b40c5879e37eb4737b9d896787ba4bfdbc10c
+README.zh.md: 342121dd45edc7412d27b94c8db837604f2b191f

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

@@ -50,7 +50,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis
 
 The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
 
-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. 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. 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

@@ -50,7 +50,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
 
 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
 
-常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。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。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/skeleton/InputBar.tsx

@@ -263,7 +263,7 @@ export const InputBar = memo(function InputBar({
     // 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.
-    editor?.getRootElement()?.focus({ preventScroll: true })
+    if (editor !== null) focusDraftEditor(editor, revealSelection)
     toggleCommandMenu?.(keyboard.caretSpan())
   }
 

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

@@ -130,7 +130,10 @@ export class InputTriggerController {
     const raw = detectTrigger(draft, caret, guard)
     if (raw === null) {
       this.hit = null
-      this.dismissed = 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

+ 12 - 6
packages/client/ui-model-selection/src/client/ModelSelect.tsx

@@ -142,12 +142,15 @@ export function ModelSelect(
     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"]')
-      const target = checked ?? itemRefs.current.find(item => item !== null)
-      target?.focus()
+      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
     }
-    itemRefs.current[intent === 'effort' ? 1 : 0]?.focus()
+    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
@@ -242,12 +245,15 @@ export function ModelSelect(
         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.
+      // 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.
       const focused = document.activeElement
-      if (focused instanceof HTMLElement && menuRef.current?.contains(focused) === true) {
+      const rows = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null)
+      if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
         focused.click()
         return
       }
+      if (focused !== triggerRef.current) return
       const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]')
       ;(checked ?? itemRefs.current.find(item => item !== null))?.focus()
       return

+ 11 - 1
packages/client/ui-model-selection/tests/model-select.client.spec.tsx

@@ -315,8 +315,18 @@ describe('ModelSelect keyboard walk', () => {
   })
 
   it('Tab with the keyboard still on the trigger enters the menu at the value in use', () => {
-    mountOpen()
+    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')

+ 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: cbfda6b0f7a9ac17040c039babfe40db8be3ab42
-README.zh.md: cbe34e697c99e58ea40c6ae4d650883d1aa82e92
+README.md: 5e8b41a3119610ed7ca40f9ca974cc56cfc50cd7
+README.zh.md: 06b56d6dcd629aa1faef36950e6c884a1d863fee

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

@@ -45,7 +45,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `StateDot` | Status mark: `done`, `warning`, `ongoing`, `error`, or `idle`. `aria-hidden`, so the render site owns the name. |
 | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
 | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. |
-| `Modal` | Centered dialog over a page mask; Escape closes it unless a menu open inside it takes that Escape first. |
+| `Modal` | Centered dialog over a page mask. |
 | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
 | `OnboardingSurface` | First-run stage that holds the application root inert. |
 | `Tooltip` | Hover text on a cloned anchor, placed right, bottom, or top. |

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

@@ -45,7 +45,7 @@ kind: "package-library"
 | `StateDot` | 状态标记:`done`、`warning`、`ongoing`、`error` 或 `idle`。它是 `aria-hidden` 的,名称由渲染点提供。 |
 | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
 | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 |
-| `Modal` | 页面遮罩之上的居中对话框;Escape 关闭它,除非对话框内有打开的菜单先接管这次 Escape。 |
+| `Modal` | 页面遮罩之上的居中对话框。 |
 | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
 | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 |
 | `Tooltip` | 克隆锚点上的悬停文本,可置于右、下、上三个方向。 |

+ 60 - 19
packages/client/ui-primitives/src/Menu.tsx

@@ -113,26 +113,44 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
   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 anchor's first button after a close that
-   * unmounts the rows: focus left on a removed row falls to the page body,
-   * where the next Tab restarts from the top of the page.
+   * 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 => {
-    rootRef.current?.querySelector<HTMLButtonElement>('button')?.focus()
+    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.
-   * An owner that moved focus itself (a card handing it to its preview button)
-   * keeps its choice: only a keyboard still on the closing list comes back to
-   * the anchor.
+   * 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 => {
-    const active = document.activeElement
-    if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor()
+    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)
@@ -190,6 +208,19 @@ 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) return
     const first = listRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')
@@ -212,12 +243,13 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
     }
     const onKeyDown = (e: KeyboardEvent) => {
       if (e.key === 'Escape') {
-        // Closing hands the keyboard back to the anchor whenever the menu had
-        // it, so Escape leaves the user where it found them.
+        // 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).
         const inMenu = rootRef.current?.contains(document.activeElement) === true
           || listRef.current?.contains(document.activeElement) === true
         onClose()
-        if (inMenu) refocusAnchor()
+        if (inMenu || autoFocus) refocusAnchor()
       }
       // 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
@@ -226,21 +258,30 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
       if (e.key === 'Tab') {
         const list = listRef.current
         const focused = document.activeElement
-        const anchored = rootRef.current?.contains(focused) === true || list?.contains(focused) === true
+        const insideList = list?.contains(focused) === true
+        const anchored = rootRef.current?.contains(focused) === true || insideList
         if (list === null || !anchored) return
-        e.preventDefault()
         if (e.shiftKey) {
+          e.preventDefault()
           onClose()
           refocusAnchor()
           return
         }
-        // Settling the row the keyboard is on; from the trigger, Tab enters the
-        // list instead (its own foreground meaning).
-        if (list.contains(focused)) {
-          (focused as HTMLElement).click()
+        // 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
         }
-        list.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus()
+        const row = list.querySelector<HTMLButtonElement>('button:not(:disabled)')
+        if (row === null) return
+        e.preventDefault()
+        row.focus()
         walkIndex.current = 0
         return
       }

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

@@ -42,10 +42,7 @@ export function Modal({
   useEffect(() => {
     if (!open) return
     const onKeyDown = (e: KeyboardEvent) => {
-      // An open menu is an inner layer: its own Escape closes it and hands the
-      // keyboard back to its anchor, so the dialog waits for the next one.
-      if (e.key !== 'Escape' || document.querySelector('[role="menu"]') !== null) return
-      onClose()
+      if (e.key === 'Escape') onClose()
     }
     document.addEventListener('keydown', onKeyDown)
     return () => { document.removeEventListener('keydown', onKeyDown) }

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

@@ -1,5 +1,4 @@
 // @vitest-environment jsdom
-import { useState } from 'react'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Button, ConnectionIndicator, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -257,75 +256,6 @@ describe('Menu', () => {
     expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' }))
   })
 
-  it('leaves Escape to a menu open inside the dialog, then closes on the next one', () => {
-    const onClose = vi.fn()
-    const onSelect = vi.fn()
-    function Host() {
-      const [menuOpen, setMenuOpen] = useState(false)
-      return (
-        <Modal open onClose={onClose} title="Settings" closeLabel="Close">
-          <Menu
-            open={menuOpen}
-            anchor={<button type="button" onClick={() => { setMenuOpen(true) }}>permissions</button>}
-            items={[{ id: 'a', label: 'Alpha' }]}
-            onSelect={onSelect}
-            onClose={() => { setMenuOpen(false) }}
-          />
-        </Modal>
-      )
-    }
-    render(<Host />)
-    fireEvent.click(screen.getByRole('button', { name: 'permissions' }))
-    expect(screen.getByRole('menu')).toBeTruthy()
-
-    // The menu is the inner layer: its Escape closes it and keeps the dialog.
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.queryByRole('menu')).toBeNull()
-    expect(onClose).not.toHaveBeenCalled()
-    expect(screen.getByRole('dialog', { name: 'Settings' })).toBeTruthy()
-
-    // With no menu open, the next Escape reaches the dialog.
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(onClose).toHaveBeenCalledTimes(1)
-  })
-
-  it('resumes the arrow walk after focus left the rows instead of re-entering', () => {
-    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={() => {}} />)
-    const trigger = screen.getByRole('button', { name: 'trigger' })
-    const alpha = screen.getByRole('menuitem', { name: 'Alpha' })
-    const beta = screen.getByRole('menuitem', { name: 'Beta' })
-    trigger.focus()
-    fireEvent.keyDown(trigger, { key: 'ArrowDown' })
-    fireEvent.keyDown(alpha, { key: 'ArrowDown' })
-    expect(document.activeElement).toBe(beta)
-
-    // Focus leaves the rows — a portal frame that refused focus, a detached
-    // node. The next step must resume from Beta, not re-enter at the far end
-    // (which would alternate between the first and last row forever).
-    trigger.focus()
-    fireEvent.keyDown(trigger, { key: 'ArrowUp' })
-    expect(document.activeElement).toBe(alpha)
-  })
-
-  it('returns the keyboard to the anchor after a row is selected', () => {
-    const onSelect = vi.fn()
-    render(
-      <Menu open autoFocus anchor={<button type="button">trigger</button>} items={items} onSelect={onSelect} onClose={() => {}} />)
-    const alpha = screen.getByRole('menuitem', { name: 'Alpha' })
-    // autoFocus parked the keyboard on the first row; selecting unmounts the
-    // rows with the list in the real consumers, so the anchor takes it back.
-    expect(document.activeElement).toBe(alpha)
-    fireEvent.click(alpha)
-    expect(onSelect).toHaveBeenCalledWith('a')
-    expect(document.activeElement).toBe(screen.getByRole('button', { name: 'trigger' }))
-  })
-
   it('renders a non-interactive heading label and a danger row', () => {
     const onSelect = vi.fn()
     render(

+ 1 - 4
packages/client/ui-settings-general/src/client/SettingsRoot.tsx

@@ -57,10 +57,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
 
   useEffect(() => {
     const onKeyDown = (e: KeyboardEvent) => {
-      // A row's open menu is an inner layer: its own Escape closes it, and the
-      // panel waits for the next one.
-      if (e.key !== 'Escape' || document.querySelector('[role="menu"]') !== null) return
-      onClose()
+      if (e.key === 'Escape') onClose()
     }
     document.addEventListener('keydown', onKeyDown)
     return () => { document.removeEventListener('keydown', onKeyDown) }

+ 0 - 17
packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

@@ -268,23 +268,6 @@ describe('SettingsPanel close paths', () => {
     await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
   })
 
-  it('leaves Escape to a menu open inside the panel, then closes on the next one', async () => {
-    mount()
-    const trigger = openPanel()
-    // The guard's condition is the open menu's marker, which a row's dropdown
-    // renders inside the panel.
-    const menu = document.createElement('div')
-    menu.setAttribute('role', 'menu')
-    screen.getByRole('dialog').append(menu)
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.getByRole('dialog')).toBeTruthy()
-
-    menu.remove()
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.queryByRole('dialog')).toBeNull()
-    await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
-  })
-
   it('closes via document-level Escape, restores trigger focus, and unhooks the listener', async () => {
     mount()
     const trigger = openPanel()