Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions packages/components/src/components/sessions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,9 @@ export function SessionHeaderMenu({
icon={MoveHorizontal}
endContent={
<Switch
// The menu row owns keyboard focus. A tabbable nested switch
// otherwise receives initial focus even when opened by mouse.
tabIndex={-1}
checked={conversationWide}
onCheckedChange={(checked) => setConversationWide(checked === true)}
onClick={(event) => event.stopPropagation()}
Expand Down
63 changes: 62 additions & 1 deletion packages/components/tests/session-header-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -75,6 +75,7 @@ describe('SessionHeaderMenu', () => {
new TestPointerEvent('mousedown', {
bubbles: true,
button: 0,
detail: 1,
pointerType: 'mouse',
})
);
Expand Down Expand Up @@ -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(
<Provider store={store}>
<ForkMenuHarness />
</Provider>
);
});
await openMenu();

const menu = document.querySelector('[role="menu"]');
const fullWidth = menuItem('Full width');
const toggle = fullWidth.querySelector<HTMLButtonElement>('[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(<ForkMenuHarness />));
const trigger = container!.querySelector<HTMLButtonElement>(
'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(<ForkMenuHarness />));
await openForkMenu();
Expand Down
Loading