diff --git a/web/src/components/MachineFilterBar.test.tsx b/web/src/components/MachineFilterBar.test.tsx index c01d03fe..df338b1a 100644 --- a/web/src/components/MachineFilterBar.test.tsx +++ b/web/src/components/MachineFilterBar.test.tsx @@ -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[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[0]> = {}) { return render( + + ) +} + +function renderMenu(props: Partial[0]> = {}) { + return render( + + { 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) + }) }) diff --git a/web/src/components/MachineFilterBar.tsx b/web/src/components/MachineFilterBar.tsx index ccf6606e..0ebbcd94 100644 --- a/web/src/components/MachineFilterBar.tsx +++ b/web/src/components/MachineFilterBar.tsx @@ -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 ( + + + + ) +} + function MachineFilterChip(props: { machine: MachineFilterItem selected: boolean @@ -84,7 +105,7 @@ export function MachineFilterBar(props: {
+ ) +} + +// 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(null) + const wrapperRef = useRef(null) + const menuRef = useRef(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('[role="menuitemradio"][aria-checked="true"]') + const first = menuRef.current?.querySelector('[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('[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 ( +
+ + {open ? ( + <> +
+ ) +} diff --git a/web/src/components/SessionList.machine-filter.test.tsx b/web/src/components/SessionList.machine-filter.test.tsx index da2bccb8..615a388a 100644 --- a/web/src/components/SessionList.machine-filter.test.tsx +++ b/web/src/components/SessionList.machine-filter.test.tsx @@ -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() diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index c05095d1..d27ed862 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -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) ? ( <>
+ {showMachineFilterBar ? ( + + ) : null} {renderHeader ? (