Pārlūkot izejas kodu

feat(client): give the shared menu family its keyboard model

Arrow navigation was bound to `autoFocus`, so the dropdowns that open with the
keyboard on their trigger — the composer's permission seat, the settings
pickers, the workspace and preset chips — had no walk at all, and a walk that
read its position from `document.activeElement` alternated between the first
and last row as soon as a row refused focus. The walk now works either way and
resumes from the index it last focused.

The keyboard returns to the anchor on Escape and on a selection — unless the
owner moved it itself, as a presented file card does for its preview button —
and the focused row wears the hover fill instead of the browser's ring. An open
menu takes the first Escape from the dialog it sits in.

The composer's trigger menu keeps its own Tab, which settles or drills the
highlighted candidate; Escape and Shift+Tab leave it without settling, and a
dismissed menu stays dismissed until its query changes. Opening it from the
launcher button returns the keyboard to the editor first, since the menu is a
combobox over that editor.
liukx0205 2 nedēļas atpakaļ
vecāks
revīzija
f46b025fd5
25 mainītis faili ar 525 papildinājumiem un 33 dzēšanām
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.i18n.yaml
  2. 29 0
      .agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.md
  3. 29 0
      .agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.zh.md
  4. 1 1
      packages/client/ui-conversation/src/client/contract/draft-editor.ts
  5. 6 0
      packages/client/ui-conversation/src/client/contract/input.ts
  6. 8 3
      packages/client/ui-conversation/src/client/input/editor/keymap.ts
  7. 10 0
      packages/client/ui-conversation/src/client/input/facade.ts
  8. 7 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  9. 29 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  10. 4 0
      packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx
  11. 2 2
      packages/client/ui-input-trigger/README.i18n.yaml
  12. 1 1
      packages/client/ui-input-trigger/README.md
  13. 1 1
      packages/client/ui-input-trigger/README.zh.md
  14. 49 1
      packages/client/ui-input-trigger/src/client/controller.ts
  15. 56 2
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  16. 4 1
      packages/client/ui-permission-presets/src/client/PermissionSelect.module.css
  17. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  18. 2 2
      packages/client/ui-primitives/README.md
  19. 2 2
      packages/client/ui-primitives/README.zh.md
  20. 14 0
      packages/client/ui-primitives/src/Menu.module.css
  21. 87 11
      packages/client/ui-primitives/src/Menu.tsx
  22. 6 2
      packages/client/ui-primitives/src/Modal.tsx
  23. 149 0
      packages/client/ui-primitives/tests/atoms.client.spec.tsx
  24. 4 1
      packages/client/ui-settings-general/src/client/SettingsRoot.tsx
  25. 17 0
      packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-overlay-escape-layering.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/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

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

@@ -0,0 +1,29 @@
+# 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.

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

@@ -0,0 +1,29 @@
+# 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。

+ 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>
 }

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

@@ -100,9 +100,14 @@ 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; Shift+Tab leaves the menu like
+    // Escape, so the two Tab gestures never disagree about consuming the draft.
+    // Without a highlight both pass, keeping native focus traversal.
+    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

@@ -449,6 +449,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.
+    editor?.getRootElement()?.focus({ preventScroll: true })
+    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

@@ -268,6 +268,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()
@@ -1599,6 +1616,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`、来源缺失或控制器已释放时,保留编辑器原有的手势处理。
 

+ 49 - 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
@@ -108,11 +130,19 @@ export class InputTriggerController {
     const raw = detectTrigger(draft, caret, guard)
     if (raw === null) {
       this.hit = null
+      this.dismissed = null
       this.stopFetch()
       this.reduce({ type: 'close' })
       return
     }
     const hit: TriggerHit = { ...raw, span: { ...raw.span, draftRev } }
+    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 +263,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 +415,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 +562,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 +607,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)
   }

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

@@ -921,12 +921,66 @@ 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 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 () => {
     const drillable = readySource('/', 'command', [{ name: 'src', drill: true }, { name: 'plan' }], () => undefined)
     const { controller } = controllerBench([drillable.source])

+ 4 - 1
packages/client/ui-permission-presets/src/client/PermissionSelect.module.css

@@ -23,7 +23,10 @@
 }
 
 .trigger:focus-visible {
-  box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
+  /* The same fill a hovered trigger gets: the trigger is one of the row-like
+     controls of the composer, and the ring its neighbours never drew read as a
+     different kind of state. */
+  background: var(--dsw-alias-interactive-bg-hover);
 }
 
 .trigger:disabled {

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

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

@@ -39,13 +39,13 @@ 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. |
 | `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. |
+| `Modal` | Centered dialog over a page mask; Escape closes it unless a menu open inside it takes that Escape first. |
 | `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. |

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

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

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

@@ -116,6 +116,15 @@
   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: without it nothing shows which row is current.
+   The fill replaces the browser's default ring — the composer's other triggers
+   suppress theirs the same way — so no blue outline frames the row. */
+.item:focus-visible:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover);
+  outline: none;
+}
+
 .denseList .item {
   min-height: 34px;
   padding-block: 5px;
@@ -208,6 +217,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;

+ 87 - 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,28 @@ 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)
+
+  /**
+   * 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.
+   */
+  const refocusAnchor = (): void => {
+    rootRef.current?.querySelector<HTMLButtonElement>('button')?.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.
+   */
+  const refocusAfterSelection = (): void => {
+    const active = document.activeElement
+    if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor()
+  }
   const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
   const [fixedPos, setFixedPos] = useState<CSSProperties | null>(null)
   const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose)
@@ -164,12 +191,16 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
   }, [open, portal, align, side, getAnchorRect])
 
   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) => {
@@ -181,16 +212,60 @@ 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.
+        const inMenu = rootRef.current?.contains(document.activeElement) === true
+          || listRef.current?.contains(document.activeElement) === true
         onClose()
-        if (autoFocus) rootRef.current?.querySelector<HTMLButtonElement>('button')?.focus()
+        if (inMenu) 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
+        const focused = document.activeElement
+        const anchored = rootRef.current?.contains(focused) === true || list?.contains(focused) === true
+        if (list === null || !anchored) return
+        e.preventDefault()
+        if (e.shiftKey) {
+          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()
+          return
+        }
+        list.querySelector<HTMLButtonElement>('button:not(:disabled)')?.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. 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
+      const focused = document.activeElement
+      const anchored = rootRef.current?.contains(focused) === true || list?.contains(focused) === true
+      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 +328,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 +345,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>

+ 6 - 2
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.
@@ -41,7 +42,10 @@ export function Modal({
   useEffect(() => {
     if (!open) return
     const onKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose()
+      // 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()
     }
     document.addEventListener('keydown', onKeyDown)
     return () => { document.removeEventListener('keydown', onKeyDown) }

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

@@ -1,4 +1,5 @@
 // @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'
@@ -177,6 +178,154 @@ 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('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(

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

@@ -57,7 +57,10 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
 
   useEffect(() => {
     const onKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose()
+      // 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()
     }
     document.addEventListener('keydown', onKeyDown)
     return () => { document.removeEventListener('keydown', onKeyDown) }

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

@@ -268,6 +268,23 @@ 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()