mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user