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
6 changes: 6 additions & 0 deletions .agents/docs/components-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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,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)
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 7 additions & 6 deletions packages/components/src/components/settings/machine-tab-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,7 @@ export function MachineListFilterButton({
</Button>
}
/>
<Menu.Content align="end">
<Menu.GroupLabel>
{t('settings.agent.machineTabs.filter.label', 'Filter machines')}
</Menu.GroupLabel>
<Menu.Separator />
<Menu.Content align="end" width="compact">
<FilterItem
label={t('settings.agent.machineTabs.filter.online', 'Online')}
checked={filter.onlineOnly}
Expand Down Expand Up @@ -382,7 +378,12 @@ function FilterItem({
onSelect: () => void;
}) {
return (
<Menu.CheckboxItem checked={checked} closeOnClick={false} onCheckedChange={() => onSelect()}>
<Menu.CheckboxItem
checked={checked}
indicatorSide="end"
closeOnClick={false}
onCheckedChange={() => onSelect()}
>
{label}
</Menu.CheckboxItem>
);
Expand Down
50 changes: 50 additions & 0 deletions packages/components/tests/e2e/desktop-settings-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
4 changes: 4 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/gallery/gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1844,8 +1844,10 @@ function CompactMenu() {
<Menu.Root>
<Menu.Trigger render={<Button variant="secondary" />}>Two choices</Menu.Trigger>
<Menu.Content width="compact">
<Menu.Item>Local</Menu.Item>
<Menu.Item>Worktree</Menu.Item>
<Menu.CheckboxItem indicatorSide="end" defaultChecked>
Online
</Menu.CheckboxItem>
<Menu.CheckboxItem indicatorSide="end">My machines</Menu.CheckboxItem>
</Menu.Content>
</Menu.Root>
);
Expand Down
45 changes: 31 additions & 14 deletions packages/ui/src/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ export interface MenuLinkItemProps
}

export interface MenuCheckboxItemProps
extends Omit<CheckboxItemBaseProps, 'className' | 'children'>, Omit<MenuRowProps, 'icon'> {}
extends Omit<CheckboxItemBaseProps, 'className' | 'children'>, Omit<MenuRowProps, 'icon'> {
/** Place the tick after the label to leave the start of short filter rows unindented. */
indicatorSide?: 'start' | 'end';
}

export interface MenuRadioItemProps
extends Omit<RadioItemBaseProps, 'className' | 'children'>, MenuRowProps {
Expand Down Expand Up @@ -342,29 +345,43 @@ export const MenuLinkItem = forwardRef<HTMLAnchorElement, MenuLinkItemProps>(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<HTMLDivElement, MenuCheckboxItemProps>(
function MenuCheckboxItem({ className, children, shortcut, endContent, ...rest }, ref) {
function MenuCheckboxItem(
{ className, children, shortcut, endContent, indicatorSide = 'start', ...rest },
ref
) {
const mark = (
<span {...stylex.props(surface.indicator)}>
<BaseMenu.CheckboxItemIndicator
className={stylex.props(surface.indicatorGlyph).className}
render={<span />}
>
<TickGlyph />
</BaseMenu.CheckboxItemIndicator>
</span>
);
return (
<BaseMenu.CheckboxItem
ref={ref}
{...rest}
className={(state) => rowClassName(state, 'neutral', className)}
>
<Row
leading={
<span {...stylex.props(surface.indicator)}>
<BaseMenu.CheckboxItemIndicator
className={stylex.props(surface.indicatorGlyph).className}
render={<span />}
>
<TickGlyph />
</BaseMenu.CheckboxItemIndicator>
</span>
}
leading={indicatorSide === 'start' ? mark : undefined}
shortcut={shortcut}
endContent={endContent}
endContent={
indicatorSide === 'end' ? (
<>
{endContent}
{mark}
</>
) : (
endContent
)
}
>
{children}
</Row>
Expand Down
54 changes: 32 additions & 22 deletions packages/ui/test/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Actions>
<Menu.CheckboxItem checked={notify} onCheckedChange={setNotify}>
Notify me
</Menu.CheckboxItem>
</Actions>
);
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 (
<Actions>
<Menu.CheckboxItem
checked={notify}
onCheckedChange={setNotify}
indicatorSide={indicatorSide}
>
Notify me
</Menu.CheckboxItem>
</Actions>
);
}
mounted = await mount(<Toggles />);
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(<Toggles />);
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() {
Expand Down
Loading