feat(web): collapse session machine filter into header menu on mobile

Replace the always-visible wrapping chip row below the md breakpoint with a
filter icon button in the session list header (right side, next to the new
session button). The button opens a radio menu with per-machine counts and
an inline health summary, shows an active-filter dot, clamps to the
remaining viewport/safe-area space, and supports Escape/Arrow-key
navigation with focus restore. Desktop keeps the one-tap chip bar.
This commit is contained in:
weishu
2026-08-01 13:26:15 +08:00
parent b1b0d590a7
commit 51d8c04e0a
4 changed files with 404 additions and 32 deletions
+166 -17
View File
@@ -1,28 +1,44 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MachineFilterBar } from './MachineFilterBar'
import { MachineFilterBar, MachineFilterMenu, getMachineFilterMenuClampStyle } from './MachineFilterBar'
import { I18nProvider } from '@/lib/i18n-context'
const defaultMachines: Parameters<typeof MachineFilterBar>[0]['machines'] = [
{ id: 'machine-1', label: 'Mint', sessionCount: 3, healthPresentation: null },
{
id: 'machine-2',
label: 'Teemo',
sessionCount: 2,
healthPresentation: {
metrics: [
{ id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' },
{ id: 'ram', shortLabel: 'RAM', percent: 88, tone: 'warn' },
],
overallTone: 'warn',
status: 'elevated',
},
},
]
function renderBar(props: Partial<Parameters<typeof MachineFilterBar>[0]> = {}) {
return render(
<I18nProvider>
<MachineFilterBar
machines={[
{ id: 'machine-1', label: 'Mint', sessionCount: 3, healthPresentation: null },
{
id: 'machine-2',
label: 'Teemo',
sessionCount: 2,
healthPresentation: {
metrics: [
{ id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' },
{ id: 'ram', shortLabel: 'RAM', percent: 88, tone: 'warn' },
],
overallTone: 'warn',
status: 'elevated',
},
},
]}
machines={defaultMachines}
totalCount={5}
value={null}
onChange={vi.fn()}
{...props}
/>
</I18nProvider>
)
}
function renderMenu(props: Partial<Parameters<typeof MachineFilterMenu>[0]> = {}) {
return render(
<I18nProvider>
<MachineFilterMenu
machines={defaultMachines}
totalCount={5}
value={null}
onChange={vi.fn()}
@@ -96,4 +112,137 @@ describe('MachineFilterBar', () => {
expect(mint.className).toContain('rounded-full')
expect(mint.className).toContain('border')
})
it('is hidden below the md breakpoint (mobile uses MachineFilterMenu)', () => {
renderBar()
expect(screen.getByRole('group', { name: 'Filter sessions by machine' }).className).toContain('max-md:hidden')
})
})
describe('MachineFilterMenu', () => {
it('renders a compact icon button only below the md breakpoint', () => {
const { container } = renderMenu()
const button = screen.getByRole('button', { name: 'Filter sessions by machine' })
expect(button.getAttribute('aria-haspopup')).toBe('menu')
expect(button.getAttribute('aria-expanded')).toBe('false')
expect(container.firstElementChild!.className).toContain('md:hidden')
// Menu stays closed until the button is pressed
expect(screen.queryByRole('menu')).toBeNull()
})
it('shows an active-filter dot only when a machine is selected', () => {
const { unmount } = renderMenu()
const button = screen.getByRole('button', { name: 'Filter sessions by machine' })
expect(button.querySelector('span')).toBeNull()
unmount()
renderMenu({ value: 'machine-1' })
expect(screen.getByRole('button', { name: 'Filter sessions by machine' }).querySelector('span')).toBeTruthy()
})
it('opens a radio menu listing All plus every machine with counts', () => {
renderMenu({ value: 'machine-1' })
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by machine' }))
expect(screen.getByRole('button', { name: 'Filter sessions by machine' }).getAttribute('aria-expanded')).toBe('true')
const all = screen.getByRole('menuitemradio', { name: /All \(5\)/ })
const mint = screen.getByRole('menuitemradio', { name: /Mint \(3\)/ })
const teemo = screen.getByRole('menuitemradio', { name: /Teemo \(2\)/ })
expect(all.getAttribute('aria-checked')).toBe('false')
expect(mint.getAttribute('aria-checked')).toBe('true')
expect(teemo.getAttribute('aria-checked')).toBe('false')
})
it('reports machine selection and closes the menu', () => {
const onChange = vi.fn()
renderMenu({ onChange })
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by machine' }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /Teemo \(2\)/ }))
expect(onChange).toHaveBeenCalledWith('machine-2')
expect(screen.queryByRole('menu')).toBeNull()
})
it('reports reset to All and closes via the backdrop', () => {
const onChange = vi.fn()
renderMenu({ value: 'machine-1', onChange })
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by machine' }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /All \(5\)/ }))
expect(onChange).toHaveBeenCalledWith(null)
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by machine' }))
const backdrop = screen.getByRole('button', { name: 'Close' })
// The invisible full-screen backdrop must not be a Tab stop
expect(backdrop.getAttribute('tabindex')).toBe('-1')
fireEvent.click(backdrop)
expect(screen.queryByRole('menu')).toBeNull()
})
it('shows a compact inline health summary (touch devices have no hover tooltip)', () => {
renderMenu()
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by machine' }))
const teemo = screen.getByRole('menuitemradio', { name: /Teemo \(2\)/ })
expect(teemo.textContent).toContain('CPU 12%')
expect(teemo.textContent).toContain('RAM 88%')
})
it('clamps the menu to the viewport space remaining around the trigger', () => {
const style = getMachineFilterMenuClampStyle({ right: 280, bottom: 100 })
// Right-anchored menu: width is limited by the space left of the trigger
expect(style.maxWidth).toContain('min(16rem, calc(280px')
expect(style.maxWidth).toContain('env(safe-area-inset-left)')
expect(style.maxHeight).toContain('min(20rem, calc(')
// mt-1 gap (4px) below the trigger is part of the clamp
expect(style.maxHeight).toContain('- 104px')
expect(style.maxHeight).toContain('--app-viewport-height')
expect(style.maxHeight).toContain('env(safe-area-inset-bottom)')
})
it('focuses the selected row when the menu opens', async () => {
renderMenu({ value: 'machine-1' })
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by machine' }))
await vi.waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: /Mint \(3\)/ }))
})
})
it('moves focus with Arrow keys, wrapping at both ends', async () => {
renderMenu()
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by machine' }))
const all = screen.getByRole('menuitemradio', { name: /All \(5\)/ })
await vi.waitFor(() => expect(document.activeElement).toBe(all))
fireEvent.keyDown(document, { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: /Teemo \(2\)/ }))
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(document.activeElement).toBe(all)
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: /Mint \(3\)/ }))
})
it('closes on Escape and restores focus to the trigger', async () => {
renderMenu()
const trigger = screen.getByRole('button', { name: 'Filter sessions by machine' })
fireEvent.click(trigger)
await vi.waitFor(() => expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: /All \(5\)/ })))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(document.activeElement).toBe(trigger)
})
})
+210 -2
View File
@@ -1,7 +1,9 @@
import { useId } from 'react'
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import type { MachineHealthPresentation } from '@/lib/machineHealth'
import { MachineHealthTooltipBody } from '@/components/MachineHealthIndicator'
import { HoverTooltip } from '@/components/HoverTooltip'
import { CheckIcon } from '@/components/icons'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
@@ -16,6 +18,25 @@ const chipBaseClass = 'flex h-7 shrink-0 items-center gap-1.5 rounded-full borde
const chipSelectedClass = 'border-[var(--app-link)] bg-[var(--app-subtle-bg)] text-[var(--app-link)] font-medium'
const chipIdleClass = 'border-[var(--app-border)] text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]'
function FilterIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
)
}
function MachineFilterChip(props: {
machine: MachineFilterItem
selected: boolean
@@ -84,7 +105,7 @@ export function MachineFilterBar(props: {
<div
role="group"
aria-label={t('sessions.machineFilter.label')}
className="flex flex-wrap items-center gap-1.5 px-2 pb-2"
className="flex flex-wrap items-center gap-1.5 px-2 pb-2 max-md:hidden"
>
<button
type="button"
@@ -106,3 +127,190 @@ export function MachineFilterBar(props: {
</div>
)
}
function MachineFilterMenuRow(props: {
label: string
count: number
selected: boolean
healthPresentation: MachineHealthPresentation | null
onSelect: () => void
}) {
const hasHealth = props.healthPresentation && props.healthPresentation.metrics.length > 0
return (
<button
type="button"
role="menuitemradio"
aria-checked={props.selected}
onClick={props.onSelect}
className="flex w-full items-start gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
>
<span className="flex h-5 w-4 shrink-0 items-center justify-center text-[var(--app-link)]">
{props.selected ? <CheckIcon className="h-4 w-4" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="truncate text-[var(--app-fg)]">{props.label}</span>
<span className="shrink-0 tabular-nums text-xs text-[var(--app-hint)]">({props.count})</span>
</span>
{hasHealth ? (
<span className="mt-0.5 block truncate text-xs tabular-nums text-[var(--app-hint)]">
{props.healthPresentation!.metrics.map((metric) => `${metric.shortLabel} ${metric.percent}%`).join(' · ')}
</span>
) : null}
</span>
</button>
)
}
// Clamp the menu to the space actually remaining left of/below the trigger
// (the menu is right-anchored to the trigger): the rem-based design caps
// (w-64 / max-h-80) grow with the font-scale setting, and safe-area insets
// shrink usable space on notched devices. The height chain mirrors the body
// sizing in index.css.
const MENU_VIEWPORT_MARGIN_PX = 8
const MENU_TOP_GAP_PX = 4 // mt-1
export function getMachineFilterMenuClampStyle(anchor: { right: number; bottom: number }): CSSProperties {
return {
maxWidth: `min(16rem, calc(${anchor.right}px - ${MENU_VIEWPORT_MARGIN_PX}px - env(safe-area-inset-left)))`,
maxHeight: `min(20rem, calc(var(--tg-viewport-stable-height, var(--app-viewport-height, 100dvh)) - ${anchor.bottom + MENU_TOP_GAP_PX}px - ${MENU_VIEWPORT_MARGIN_PX}px - env(safe-area-inset-bottom)))`
}
}
// Mobile (below md) counterpart of MachineFilterBar: collapses the machine
// filter into a single header icon button with a dropdown, so the chip row
// does not consume vertical space on small screens. A blue dot mirrors the
// search/date picker's active-filter indicator; health metrics render inline
// because hover tooltips are unavailable on touch devices.
export function MachineFilterMenu(props: {
machines: MachineFilterItem[]
totalCount: number
value: string | null
onChange: (id: string | null) => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [anchor, setAnchor] = useState<{ right: number; bottom: number } | null>(null)
const close = useCallback(() => {
setOpen(false)
triggerRef.current?.focus()
}, [])
const select = (id: string | null) => {
props.onChange(id)
close()
}
useLayoutEffect(() => {
if (!open) {
setAnchor(null)
return
}
const updateAnchor = () => {
const rect = wrapperRef.current?.getBoundingClientRect()
if (!rect) return
setAnchor({ right: rect.right, bottom: rect.bottom })
}
updateAnchor()
window.addEventListener('resize', updateAnchor)
return () => window.removeEventListener('resize', updateAnchor)
}, [open])
// Focus the selected (or first) row on open; Escape closes and Arrow keys
// move between rows, matching SessionActionMenu's keyboard behavior.
useEffect(() => {
if (!open) return
const frame = window.requestAnimationFrame(() => {
const selected = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]')
const first = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"]')
;(selected ?? first)?.focus()
})
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
close()
return
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []
)
if (items.length === 0) return
event.preventDefault()
const delta = event.key === 'ArrowDown' ? 1 : -1
const currentIndex = items.indexOf(document.activeElement as HTMLElement)
const nextIndex = currentIndex === -1
? (delta === 1 ? 0 : items.length - 1)
: (currentIndex + delta + items.length) % items.length
items[nextIndex]?.focus()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
window.cancelAnimationFrame(frame)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open, close])
return (
<div ref={wrapperRef} className="relative shrink-0 md:hidden">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(value => !value)}
aria-label={t('sessions.machineFilter.label')}
title={t('sessions.machineFilter.label')}
aria-haspopup="menu"
aria-expanded={open}
className="relative flex rounded-full p-1.5 text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
>
<FilterIcon className="h-5 w-5" />
{props.value !== null ? (
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-[var(--app-link)]" />
) : null}
</button>
{open ? (
<>
<button
type="button"
aria-label={t('button.close')}
tabIndex={-1}
className="fixed inset-0 z-20 cursor-default"
onClick={close}
/>
<div
ref={menuRef}
role="menu"
aria-label={t('sessions.machineFilter.label')}
style={anchor ? getMachineFilterMenuClampStyle(anchor) : undefined}
className="absolute right-0 top-full z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] p-1 shadow-xl"
>
<MachineFilterMenuRow
label={t('sessions.machineFilter.all')}
count={props.totalCount}
selected={props.value === null}
healthPresentation={null}
onSelect={() => select(null)}
/>
{props.machines.map((machine) => (
<MachineFilterMenuRow
key={machine.id}
label={machine.label}
count={machine.sessionCount}
selected={props.value === machine.id}
healthPresentation={machine.healthPresentation}
onSelect={() => select(machine.id)}
/>
))}
</div>
</>
) : null}
</div>
)
}
@@ -92,6 +92,7 @@ describe('SessionList machine filter', () => {
])
expect(screen.queryByRole('group', { name: 'Filter sessions by machine' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Filter sessions by machine' })).toBeNull()
expect(screen.getByTitle('/work/hapi')).toBeTruthy()
})
@@ -99,6 +100,8 @@ describe('SessionList machine filter', () => {
renderSessionList(multiMachineSessions)
expect(screen.getByRole('group', { name: 'Filter sessions by machine' })).toBeTruthy()
// Mobile (below md) counterpart: a compact filter icon button in the header
expect(screen.getByRole('button', { name: 'Filter sessions by machine' })).toBeTruthy()
expect(screen.getByRole('button', { name: /All \(2\)/ })).toBeTruthy()
expect(screen.getByText('work/hapi · Mint')).toBeTruthy()
expect(screen.getByText('work/docs · Teemo')).toBeTruthy()
+25 -13
View File
@@ -23,7 +23,7 @@ import { getSessionTitle } from '@/lib/sessionTitle'
import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel'
import type { Machine } from '@/types/api'
import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth'
import { MachineFilterBar } from '@/components/MachineFilterBar'
import { MachineFilterBar, MachineFilterMenu } from '@/components/MachineFilterBar'
import { useSessionListMachineFilter } from '@/hooks/useSessionListMachineFilter'
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
import { SessionRowSummary } from '@/components/SessionRowSummary'
@@ -1038,6 +1038,21 @@ export function SessionList(props: {
() => groupByMachine(allGroups, resolveMachineLabel),
[allGroups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
)
const machineFilterItems = useMemo(
() => machineFilters.map((mg) => {
const machine = mg.machineId ? machinesById[mg.machineId] : undefined
return {
id: mg.machineId ?? UNKNOWN_MACHINE_ID,
label: mg.label,
sessionCount: mg.totalSessions,
healthPresentation: presentMachineHealth(
machine?.health,
getMachinePlatform(machine)
)
}
}),
[machineFilters, machinesById]
)
const showMachineFilterBar = machineFilters.length >= 2
// A persisted filter whose machine no longer has sessions falls back to
// "All"; with at most one machine the bar is hidden and never filters.
@@ -1316,6 +1331,14 @@ export function SessionList(props: {
{!(showSearch && searchExpanded) ? (
<>
<div className="flex-1" />
{showMachineFilterBar ? (
<MachineFilterMenu
machines={machineFilterItems}
totalCount={allSessions.length}
value={activeMachineFilter}
onChange={setMachineFilter}
/>
) : null}
{renderHeader ? (
<button
type="button"
@@ -1334,18 +1357,7 @@ export function SessionList(props: {
{showMachineFilterBar ? (
<MachineFilterBar
machines={machineFilters.map((mg) => {
const machine = mg.machineId ? machinesById[mg.machineId] : undefined
return {
id: mg.machineId ?? UNKNOWN_MACHINE_ID,
label: mg.label,
sessionCount: mg.totalSessions,
healthPresentation: presentMachineHealth(
machine?.health,
getMachinePlatform(machine)
)
}
})}
machines={machineFilterItems}
totalCount={allSessions.length}
value={activeMachineFilter}
onChange={setMachineFilter}