diff --git a/.agents/notes/assets/session-menu-switch-focus/after.png b/.agents/notes/assets/session-menu-switch-focus/after.png new file mode 100644 index 0000000000..91b8f59cc3 Binary files /dev/null and b/.agents/notes/assets/session-menu-switch-focus/after.png differ diff --git a/.agents/notes/implemented/bug-fix/2026-10-20-session-menu-switch-focus.md b/.agents/notes/implemented/bug-fix/2026-10-20-session-menu-switch-focus.md new file mode 100644 index 0000000000..dcafa7d895 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-20-session-menu-switch-focus.md @@ -0,0 +1,51 @@ +# Session menu switch focus + +Status: implemented +Translation: current + +[中文](2026-10-20-session-menu-switch-focus.zh.md) + +PR: [#1741](https://github.com/LodyAI/Lody/pull/1741) + +## Abstract + +Opening the session actions menu with the pointer highlighted Full width while +the pointer remained on the trigger. Its nested switch was tabbable, so the +menu's initial focus chose it and highlighted its containing row. The switch +now stays outside the tab order, leaving keyboard navigation with the menu row. +The switch remains directly clickable, and keyboard opening still focuses an +action; opening alone does not change the preference. + +## Decision and evidence + +This corrects the focus behavior of the trailing switch introduced by +[conversation full-width mode](../feature/2026-09-28-conversation-wide-mode.md). +Base UI 1.7's menu popup focuses its first tabbable descendant on pointer open; +menu rows initially have `tabIndex=-1`, but the nested switch did not. Setting +`tabIndex=-1` on that switch allows the popup surface to receive pointer-open +focus, while Base UI's existing keyboard navigation still reaches the row. +There is no shared menu autofocus override or hidden highlight styling. + +The containing row keeps its keyboard activation and `closeOnClick=false`; +the switch retains its own click handler and propagation stop. This preserves +both direct pointer activation and the visible switch design without giving +the nested control a second keyboard stop. + +## Verification + +The owning session-header and full-width suites pass all 15 tests. Regression +coverage checks pointer opening, arrow navigation, keyboard opening, dismissal, +and preference changes through both row activation and direct switch clicks. +A real SessionHeaderMenu Storybook browser run also verifies pointer opening, +hover, keyboard opening, row activation, direct switch clicks, and Escape dismissal. +The full `pnpm check` passes, including workspace type checking, typed and Effect +lint, all configured workspace tests, i18n validation, and boundary guards. +`pnpm format` also passes. +The documentation check still reports six existing missing local Kimi/Pi +submodule links in the subagent-events note; this change adds no broken links. + +The synthetic Storybook fixture omits optional Share and owner-transfer callbacks; +those production entries retain their existing conditions. The pointer remains +on the trigger when this screenshot is captured. + +![Pointer-opened menu](../../assets/session-menu-switch-focus/after.png) diff --git a/.agents/notes/implemented/bug-fix/2026-10-20-session-menu-switch-focus.zh.md b/.agents/notes/implemented/bug-fix/2026-10-20-session-menu-switch-focus.zh.md new file mode 100644 index 0000000000..a90415a7c5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-20-session-menu-switch-focus.zh.md @@ -0,0 +1,43 @@ +# 会话菜单开关焦点 + +Status: implemented +Translation: current + +[English](2026-10-20-session-menu-switch-focus.md) + +PR: [#1741](https://github.com/LodyAI/Lody/pull/1741) + +## 摘要 + +用鼠标打开会话操作菜单时,指针还停留在触发按钮上,“全宽显示”就出现了高亮。 +原因是行内开关可以接收 Tab 焦点,菜单的初始焦点选择了它,并使所在行高亮。 +现在将开关移出 Tab 顺序,键盘导航由菜单行统一承接。 +开关仍然可以直接点击,键盘打开时仍会聚焦操作项;仅打开菜单不会改变偏好。 + +## 决定与证据 + +本次修正[会话全宽模式](../feature/2026-09-28-conversation-wide-mode.md) +引入的行尾开关的焦点行为。Base UI 1.7 的菜单弹层在鼠标打开时会聚焦第一个 +可以通过 Tab 到达的后代;菜单行最初为 `tabIndex=-1`,但嵌套开关没有该限制。 +为开关设置 `tabIndex=-1` 后,鼠标打开时焦点留在菜单表面,原有键盘导航仍会 +进入菜单行。无需覆盖共享菜单的自动聚焦逻辑,也没有用样式隐藏高亮。 + +外层菜单行保留键盘激活与 `closeOnClick=false`;开关保留自己的点击处理与 +阻止冒泡。因此用户仍可直接点击开关,视觉设计保持不变,并且不会多出第二个 +键盘停靠点。 + +## 验证 + +所属的会话菜单与全宽模式测试共 15 项全部通过。回归测试覆盖鼠标打开、方向键导航、 +键盘打开、关闭菜单,以及通过菜单行和直接点击开关改变偏好。 +浏览器中也通过真实 SessionHeaderMenu Storybook 组件验证了鼠标打开、悬停、键盘打开、 +菜单行激活、直接点击开关,以及 Escape 关闭。 +完整的 `pnpm check` 通过,包括工作区类型检查、类型感知与 Effect lint、全部配置的 +工作区测试、国际化验证和边界检查;`pnpm format` 也通过。 +文档检查仍报告 subagent-events 笔记中原有的六个本地 Kimi/Pi 子模块缺失链接; +本次修改没有新增失效链接。 + +截图来自合成的 Storybook 示例,未配置可选的分享和所有者转移回调;生产页面 +仍保留这些入口原有的显示条件。截图时指针停留在触发按钮上。 + +![Pointer-opened menu](../../assets/session-menu-switch-focus/after.png) diff --git a/packages/components/src/components/sessions/README.md b/packages/components/src/components/sessions/README.md index 2c1e4d475e..703f20f9e7 100644 --- a/packages/components/src/components/sessions/README.md +++ b/packages/components/src/components/sessions/README.md @@ -14,6 +14,11 @@ The navigation-sidebar, terminal-dock and right-panel toggles reuse rotated -90 degrees and the right-panel glyph 180 degrees so the filled strip marks the panel's edge; their buttons and toggle behavior stay shared. +The header's Full width row owns keyboard navigation; its trailing switch stays +outside the tab order. Pointer opening focuses the menu surface without +highlighting an action, while keyboard opening and arrow navigation focus rows. +Both the row and the switch toggle the preference without dismissing the menu. + `ask-user-question-card.tsx` owns interactive and read-only question presentation, including independent optional notes and secret masking. Its dedicated `AskUserQuestionCard` stories cover editing, replay, multiple questions and legacy diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index b175aa9519..9c5214e408 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -1677,6 +1677,9 @@ export function SessionHeaderMenu({ icon={MoveHorizontal} endContent={ setConversationWide(checked === true)} onClick={(event) => event.stopPropagation()} diff --git a/packages/components/tests/session-header-menu.test.tsx b/packages/components/tests/session-header-menu.test.tsx index 668ad17d5b..6271a659db 100644 --- a/packages/components/tests/session-header-menu.test.tsx +++ b/packages/components/tests/session-header-menu.test.tsx @@ -6,7 +6,7 @@ import { createStore, Provider } from 'jotai'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SessionId, SessionMeta } from '@lody/shared'; -import { experimentalFeaturesEnabledAtom } from '../src/atoms/settings'; +import { conversationWideModeAtom, experimentalFeaturesEnabledAtom } from '../src/atoms/settings'; import { SessionHeaderMenu } from '../src/components/sessions/session-chat-interface'; import { Tooltip } from '@lody/ui/tooltip'; @@ -75,6 +75,7 @@ describe('SessionHeaderMenu', () => { new TestPointerEvent('mousedown', { bubbles: true, button: 0, + detail: 1, pointerType: 'mouse', }) ); @@ -121,6 +122,66 @@ describe('SessionHeaderMenu', () => { expect(container?.querySelector('[data-testid="fork-result"]')?.textContent).toBe('none'); } + it('leaves pointer-opened actions unhighlighted and toggles full width only on activation', async () => { + const store = createStore(); + store.set(conversationWideModeAtom, false); + await act(async () => { + root?.render( + + + + ); + }); + await openMenu(); + + const menu = document.querySelector('[role="menu"]'); + const fullWidth = menuItem('Full width'); + const toggle = fullWidth.querySelector('[role="switch"]')!; + expect(document.activeElement).toBe(menu); + expect(menu?.querySelector('[data-highlighted]')).toBeNull(); + expect(store.get(conversationWideModeAtom)).toBe(false); + + await act(async () => { + menu?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await vi.advanceTimersByTimeAsync(40); + }); + expect(document.activeElement).toBe(fullWidth); + expect(fullWidth.hasAttribute('data-highlighted')).toBe(true); + + await act(async () => { + fullWidth.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + expect(store.get(conversationWideModeAtom)).toBe(true); + expect(document.querySelector('[role="menu"]')).toBe(menu); + + await act(async () => toggle.click()); + expect(store.get(conversationWideModeAtom)).toBe(false); + expect(document.querySelector('[role="menu"]')).toBe(menu); + }); + + it('focuses the first action when the menu is opened with the keyboard', async () => { + await act(async () => root?.render()); + const trigger = container!.querySelector( + 'button[aria-label="More actions"]' + )!; + await act(async () => { + trigger.focus(); + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await vi.advanceTimersByTimeAsync(40); + }); + const fullWidth = menuItem('Full width'); + expect(document.activeElement).toBe(fullWidth); + expect(fullWidth.hasAttribute('data-highlighted')).toBe(true); + + await act(async () => { + fullWidth.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(40); + }); + expect(document.querySelector('[role="menu"]')).toBeNull(); + }); + it('opens the submenu and forks to a new tab', async () => { await act(async () => root?.render()); await openForkMenu();