diff --git a/.agents/docs/components-package.md b/.agents/docs/components-package.md index f09dc60a59..4d4ebfcf13 100644 --- a/.agents/docs/components-package.md +++ b/.agents/docs/components-package.md @@ -262,6 +262,12 @@ does not name a control. The Preferences switches use this association with the existing translated copy. See the [decision and browser regression](../notes/implemented/bug-fix/2026-10-01-preferences-switch-names.md). +Machine-list filters compose `Menu.Content width="compact"` and +`Menu.CheckboxItem indicatorSide="end"`. The trigger names this two-option menu, +so the popup contains only the independent filters. The shared checkable row +keeps keyboard and multi-select behavior; its existing start-aligned indicator +remains the default for other menus. + `--input` is the theme's raw `input.background` and doubles as a muted chip/composer slab, so in a light theme it can sit below the page color and read as a disabled field. `--input-field` is derived in `lib/vscode-theme/vscode-theme-css.ts` as the lighter of diff --git a/.agents/notes/assets/machine-filter-menu-density/checked.png b/.agents/notes/assets/machine-filter-menu-density/checked.png new file mode 100644 index 0000000000..94d2f525a1 Binary files /dev/null and b/.agents/notes/assets/machine-filter-menu-density/checked.png differ diff --git a/.agents/notes/assets/machine-filter-menu-density/unchecked.png b/.agents/notes/assets/machine-filter-menu-density/unchecked.png new file mode 100644 index 0000000000..6ee3a04afe Binary files /dev/null and b/.agents/notes/assets/machine-filter-menu-density/unchecked.png differ diff --git a/.agents/notes/implemented/bug-fix/2026-10-20-machine-filter-menu-density.md b/.agents/notes/implemented/bug-fix/2026-10-20-machine-filter-menu-density.md new file mode 100644 index 0000000000..40ef52d9a9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-20-machine-filter-menu-density.md @@ -0,0 +1,49 @@ +# Compact machine filter menu + +Status: implemented +Translation: current + +PR: [#1743](https://github.com/LodyAI/Lody/pull/1743) + +[中文](2026-10-20-machine-filter-menu-density.zh.md) + +## Abstract + +The machine filter popup reserved a leading tick column even when both filters +were off, leaving the short labels indented inside an oversized menu. It now +uses the existing compact width and puts selection ticks after the labels. +Removing the redundant heading and separator leaves just the two filter rows. +Online and My machines remain independent checkboxes, with keyboard navigation +and an open menu while changing either option. + +## Decision + +The [menu family](../feature/2026-09-11-ui-menu-primitives.md) keeps the mark box +reserved so labels cannot shift when a tick appears. `Menu.CheckboxItem` gains +an optional `indicatorSide="end"`, matching the radio row's existing placement +option. It moves that box after the label; the default remains at the start. +The product uses this option with `width="compact"` (180px minimum), without +changing shared menu dimensions or row styles. Longer labels can still expand +beyond the minimum. The trigger retains its translated accessible name. + +The UI gallery's compact example demonstrates trailing ticks. The owning menu +suite exercises both placements with pointer and keyboard activation, and the +settings browser suite checks the real machine filter and resulting list. + +## Verification + +The menu and gallery suites pass all 52 tests. English and Chinese browser cases +pass: the menu is 180px wide and 64px tall, labels begin within 14px of its edge, +and checking does not shift them. The real list changes from four machines to +two online machines, then one online owned machine; keyboard toggling and +reopening preserve the selected filters. Both package type checks and targeted +typed lint pass. Before submission, `pnpm format` and the full `pnpm check` +also passed, including workspace type checks, lint, tests, and boundary checks. +Documentation checking retains the existing six missing Kimi/Pi submodule links +in the bilingual subagent-events note; no new documentation errors were added. + +## Screenshots + +![No filters selected](../../assets/machine-filter-menu-density/unchecked.png) + +![Online selected](../../assets/machine-filter-menu-density/checked.png) diff --git a/.agents/notes/implemented/bug-fix/2026-10-20-machine-filter-menu-density.zh.md b/.agents/notes/implemented/bug-fix/2026-10-20-machine-filter-menu-density.zh.md new file mode 100644 index 0000000000..b52eeba412 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-20-machine-filter-menu-density.zh.md @@ -0,0 +1,42 @@ +# 紧凑的机器过滤菜单 + +Status: implemented +Translation: current + +PR: [#1743](https://github.com/LodyAI/Lody/pull/1743) + +[English](2026-10-20-machine-filter-menu-density.md) + +## 摘要 + +机器过滤菜单在两个条件都未选中时仍保留前方勾选列,短标签因此缩进,菜单也显得过大。 +现在使用现有的紧凑宽度,将勾选标记放到文字后方。 +去掉重复标题和分隔线后,菜单只保留两行筛选条件。 +“在线”和“我的机器”仍可独立选择,保留键盘导航,切换条件时菜单保持打开。 + +## 决定 + +[菜单组件族](../feature/2026-09-11-ui-menu-primitives.md) 保留标记的空间,避免勾选出现时 +文字移动。`Menu.CheckboxItem` 新增可选的 `indicatorSide="end"`,与已有单选行的 +位置选项保持一致,将标记区域移到文字后方;默认仍在前方。 +产品组合该选项与 `width="compact"`(最小宽度 180px),没有修改共享菜单的尺寸 +或行样式。更长标签仍可撑大菜单。触发按钮保留已有翻译的可访问名称。 + +UI 图库中的紧凑示例展示右侧勾选。所属菜单测试覆盖两种位置的鼠标和键盘激活, +设置页面浏览器测试检查真实机器过滤菜单及过滤后的列表。 + +## 验证 + +菜单与图库测试共 52 项全部通过。中英文浏览器案例通过:菜单宽 180px、高 64px, +标签起点距离菜单边缘小于 14px,勾选不会使文字移动。真实列表从四台机器变为两台 +在线机器,再变为一台在线且属于自己的机器;键盘切换和重新打开菜单均保留筛选状态。 +两个包的类型检查与针对修改文件的类型感知 lint 均通过。提交前的 `pnpm format` +和完整 `pnpm check` 也已通过,包含工作区类型检查、lint、测试与边界检查。 +文档检查仍有 subagent-events 双语笔记中原有的六个 Kimi/Pi 子模块缺失链接, +没有新增文档错误。 + +## 截图 + +![未选择筛选条件](../../assets/machine-filter-menu-density/unchecked.png) + +![已选择“在线”](../../assets/machine-filter-menu-density/checked.png) diff --git a/packages/components/src/components/settings/machine-tab-list.tsx b/packages/components/src/components/settings/machine-tab-list.tsx index 8e714d9fe9..6a7b02986a 100644 --- a/packages/components/src/components/settings/machine-tab-list.tsx +++ b/packages/components/src/components/settings/machine-tab-list.tsx @@ -163,11 +163,7 @@ export function MachineListFilterButton({ } /> - - - {t('settings.agent.machineTabs.filter.label', 'Filter machines')} - - + void; }) { return ( - onSelect()}> + onSelect()} + > {label} ); diff --git a/packages/components/tests/e2e/desktop-settings-layout.spec.ts b/packages/components/tests/e2e/desktop-settings-layout.spec.ts index 9b2fbed16c..8b6dcad789 100644 --- a/packages/components/tests/e2e/desktop-settings-layout.spec.ts +++ b/packages/components/tests/e2e/desktop-settings-layout.spec.ts @@ -381,3 +381,53 @@ for (const locale of ['en', 'zh_CN']) { ).toHaveCount(0); }); } + +for (const { locale, triggerName, onlineName, mineName } of [ + { locale: 'en', triggerName: 'Filter machines', onlineName: 'Online', mineName: 'My machines' }, + { locale: 'zh_CN', triggerName: '过滤机器', onlineName: '在线', mineName: '我的机器' }, +]) { + test(`machine filters keep labels unindented in a compact menu (${locale})`, async ({ page }) => { + await page.setViewportSize({ width: 900, height: 600 }); + await page.goto( + `/iframe.html?id=settings-machinetablist--default&viewMode=story&globals=theme:dark;locale:${locale}` + ); + const trigger = page.getByRole('button', { name: triggerName, exact: true }); + const machines = page.locator('button[data-settings-machine-id]'); + await expect(machines).toHaveCount(4); + await trigger.click(); + const menu = page.getByRole('menu'); + const online = menu.getByRole('menuitemcheckbox', { name: onlineName, exact: true }); + const mine = menu.getByRole('menuitemcheckbox', { name: mineName, exact: true }); + await expect(online).toBeVisible(); + await expect(mine).toHaveAttribute('aria-checked', 'false'); + const box = await menu.boundingBox(); + expect(box!.width).toBeLessThanOrEqual(182); + expect(box!.height).toBeLessThanOrEqual(72); + const labelLeft = await online.evaluate( + (row) => row.firstElementChild!.getBoundingClientRect().left + ); + expect(labelLeft - box!.x).toBeLessThanOrEqual(14); + await online.click(); + await expect(online).toHaveAttribute('aria-checked', 'true'); + await expect(machines).toHaveCount(2); + await expect(online.locator(':scope > span:last-child svg')).toBeVisible(); + expect( + await online.evaluate((row) => row.firstElementChild!.getBoundingClientRect().left) + ).toBe(labelLeft); + await mine.click(); + await expect(mine).toHaveAttribute('aria-checked', 'true'); + await expect(machines).toHaveCount(1); + await page.keyboard.press('ArrowUp'); + await expect(online).toBeFocused(); + await page.keyboard.press('Space'); + await expect(online).toHaveAttribute('aria-checked', 'false'); + await expect(machines).toHaveCount(2); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + await trigger.click(); + await expect(mine).toHaveAttribute('aria-checked', 'true'); + await mine.click(); + await expect(machines).toHaveCount(4); + await expect(menu).toBeVisible(); + }); +} diff --git a/packages/ui/README.md b/packages/ui/README.md index 545ed7081a..5ab54c269b 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -204,6 +204,10 @@ had there, through Base UI's `render`: `width="compact"` for a short two-choice menu; the regular width remains the default. Width is a primitive variant, not a caller class override. +`Menu.CheckboxItem indicatorSide="end"` places the tick after the label for +compact filters. It reserves the trailing mark box in both states, keeping the +label aligned without a leading indent. The default remains `start`. + `ContextMenu` and `Menubar` restate only the way in — a right click or a long press, and a bar of names — and re-export `Menu`'s rows rather than rebuilding them, so a command looks and behaves the same wherever a person meets it. A row diff --git a/packages/ui/src/gallery/gallery.tsx b/packages/ui/src/gallery/gallery.tsx index 8fc3dbca59..c5bc279ef5 100644 --- a/packages/ui/src/gallery/gallery.tsx +++ b/packages/ui/src/gallery/gallery.tsx @@ -1844,8 +1844,10 @@ function CompactMenu() { }>Two choices - Local - Worktree + + Online + + My machines ); diff --git a/packages/ui/src/menu/menu.tsx b/packages/ui/src/menu/menu.tsx index ba161e9a71..9f3748a3c8 100644 --- a/packages/ui/src/menu/menu.tsx +++ b/packages/ui/src/menu/menu.tsx @@ -93,7 +93,10 @@ export interface MenuLinkItemProps } export interface MenuCheckboxItemProps - extends Omit, Omit {} + extends Omit, Omit { + /** Place the tick after the label to leave the start of short filter rows unindented. */ + indicatorSide?: 'start' | 'end'; +} export interface MenuRadioItemProps extends Omit, MenuRowProps { @@ -342,10 +345,24 @@ export const MenuLinkItem = forwardRef(fun * Base UI unmounts the tick while the row is unticked, so an icon sharing that * box would slide sideways every time the row was toggled. A row that needs a * glyph as well puts it in `endContent`. The box itself is always reserved, so - * the list does not reflow as the tick appears. + * the list does not reflow as the tick appears. Short filters can move this box + * to the end with `indicatorSide="end"`, leaving the label unindented. */ export const MenuCheckboxItem = forwardRef( - function MenuCheckboxItem({ className, children, shortcut, endContent, ...rest }, ref) { + function MenuCheckboxItem( + { className, children, shortcut, endContent, indicatorSide = 'start', ...rest }, + ref + ) { + const mark = ( + + } + > + + + + ); return ( rowClassName(state, 'neutral', className)} > - } - > - - - - } + leading={indicatorSide === 'start' ? mark : undefined} shortcut={shortcut} - endContent={endContent} + endContent={ + indicatorSide === 'end' ? ( + <> + {endContent} + {mark} + + ) : ( + endContent + ) + } > {children} diff --git a/packages/ui/test/menu.test.tsx b/packages/ui/test/menu.test.tsx index 7f11f13d22..1e66a70146 100644 --- a/packages/ui/test/menu.test.tsx +++ b/packages/ui/test/menu.test.tsx @@ -275,29 +275,39 @@ describe('Menu row', () => { }); describe('Menu checkbox and radio rows', () => { - test('a checkbox row announces its state and toggles without closing the menu', async () => { - function Toggles() { - const [notify, setNotify] = useState(false); - return ( - - - Notify me - - - ); + test.each(['start', 'end'] as const)( + 'a checkbox row with its mark at %s toggles by pointer and keyboard without closing', + async (indicatorSide) => { + function Toggles() { + const [notify, setNotify] = useState(false); + return ( + + + Notify me + + + ); + } + mounted = await mount(); + await click(trigger()); + const row = () => rows('menuitemcheckbox')[0]; + expect(row().getAttribute('aria-checked')).toBe('false'); + + await click(row()); + + expect(row().getAttribute('aria-checked')).toBe('true'); + // A setting is toggled, not chosen: the menu stays up so a second one can + // be toggled without reopening it. + expect(trigger().getAttribute('aria-expanded')).toBe('true'); + await press('Enter'); + expect(row().getAttribute('aria-checked')).toBe('false'); + expect(trigger().getAttribute('aria-expanded')).toBe('true'); } - mounted = await mount(); - await click(trigger()); - const row = () => rows('menuitemcheckbox')[0]; - expect(row().getAttribute('aria-checked')).toBe('false'); - - await click(row()); - - expect(row().getAttribute('aria-checked')).toBe('true'); - // A setting is toggled, not chosen: the menu stays up so a second one can - // be toggled without reopening it. - expect(trigger().getAttribute('aria-expanded')).toBe('true'); - }); + ); test('a radio group holds one value and marks the row that has it', async () => { function Sort() {