From 2e54d9fdff8cd47a9b46a2d9c07ddff9ff84d372 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 27 Jul 2026 11:55:43 +0800 Subject: [PATCH] feat(web): replace machine tree level with filter chips in session list Single-machine users no longer expand a redundant machine layer; with multiple machines a chip filter bar (persisted, with hover health popup) replaces the collapsible machine headers. Directory groups now render top-level with machine-name suffixes when unfiltered. Also removes the redundant session/project count header text. --- web/src/components/MachineFilterBar.test.tsx | 99 +++++++ web/src/components/MachineFilterBar.tsx | 108 +++++++ .../components/MachineGroupHeader.test.tsx | 90 ------ web/src/components/MachineGroupHeader.tsx | 95 ------ .../MachineHealthIndicator.test.tsx | 70 ----- web/src/components/MachineHealthIndicator.tsx | 130 +-------- .../SessionList.machine-filter.test.tsx | 149 ++++++++++ web/src/components/SessionList.test.ts | 12 +- web/src/components/SessionList.tsx | 276 ++++++++---------- .../hooks/useSessionListMachineFilter.test.ts | 28 ++ web/src/hooks/useSessionListMachineFilter.ts | 91 ++++++ web/src/lib/locales/en.ts | 4 +- web/src/lib/locales/zh-CN.ts | 4 +- web/src/router.tsx | 8 +- 14 files changed, 611 insertions(+), 553 deletions(-) create mode 100644 web/src/components/MachineFilterBar.test.tsx create mode 100644 web/src/components/MachineFilterBar.tsx delete mode 100644 web/src/components/MachineGroupHeader.test.tsx delete mode 100644 web/src/components/MachineGroupHeader.tsx delete mode 100644 web/src/components/MachineHealthIndicator.test.tsx create mode 100644 web/src/components/SessionList.machine-filter.test.tsx create mode 100644 web/src/hooks/useSessionListMachineFilter.test.ts create mode 100644 web/src/hooks/useSessionListMachineFilter.ts diff --git a/web/src/components/MachineFilterBar.test.tsx b/web/src/components/MachineFilterBar.test.tsx new file mode 100644 index 00000000..c01d03fe --- /dev/null +++ b/web/src/components/MachineFilterBar.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { MachineFilterBar } from './MachineFilterBar' +import { I18nProvider } from '@/lib/i18n-context' + +function renderBar(props: Partial[0]> = {}) { + return render( + + + + ) +} + +describe('MachineFilterBar', () => { + it('renders an "All" chip plus one chip per machine with counts', () => { + renderBar() + + expect(screen.getByRole('button', { name: /All \(5\)/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /Mint \(3\)/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /Teemo \(2\)/ })).toBeTruthy() + }) + + it('marks the selected chip as pressed', () => { + renderBar({ value: 'machine-1' }) + + expect(screen.getByRole('button', { name: /Mint \(3\)/ }).getAttribute('aria-pressed')).toBe('true') + expect(screen.getByRole('button', { name: /All \(5\)/ }).getAttribute('aria-pressed')).toBe('false') + }) + + it('reports machine selection and reset to All', () => { + const onChange = vi.fn() + renderBar({ value: 'machine-1', onChange }) + + fireEvent.click(screen.getByRole('button', { name: /Teemo \(2\)/ })) + expect(onChange).toHaveBeenCalledWith('machine-2') + + fireEvent.click(screen.getByRole('button', { name: /All \(5\)/ })) + expect(onChange).toHaveBeenCalledWith(null) + }) + + it('shows machine health in a hover popup instead of reserving chip width', () => { + renderBar() + + const chip = screen.getByRole('button', { name: /Teemo \(2\)/ }) + const describedBy = chip.getAttribute('aria-describedby') + expect(describedBy).toBeTruthy() + + const tooltip = document.getElementById(describedBy!) + expect(tooltip).toBeTruthy() + expect(tooltip!.getAttribute('role')).toBe('tooltip') + expect(tooltip!.textContent).toContain('Machine capacity') + expect(tooltip!.textContent).toContain('CPU') + expect(tooltip!.textContent).toContain('12%') + // Popup is hidden below the md breakpoint (mobile shows nothing) + expect(tooltip!.className).toContain('max-md:hidden') + // A pseudo-element bridges the mt-1 gap so the popup stays open while entered + expect(tooltip!.className).toContain('before:-top-1') + }) + + it('keeps the entire visible chip clickable', () => { + const onChange = vi.fn() + renderBar({ onChange }) + + // Chip with health popup: the button carries the pill padding, the + // bordered wrapper adds no inert padding around it. + const teemo = screen.getByRole('button', { name: /Teemo \(2\)/ }) + expect(teemo.className).toContain('px-2.5') + const pill = teemo.parentElement!.parentElement! + expect(pill.className).toContain('rounded-full') + expect(pill.className).toContain('border') + expect(pill.className).not.toContain('px-2.5') + + // Chip without health: the button is the pill itself. + const mint = screen.getByRole('button', { name: /Mint \(3\)/ }) + expect(mint.className).toContain('rounded-full') + expect(mint.className).toContain('border') + }) +}) diff --git a/web/src/components/MachineFilterBar.tsx b/web/src/components/MachineFilterBar.tsx new file mode 100644 index 00000000..ccf6606e --- /dev/null +++ b/web/src/components/MachineFilterBar.tsx @@ -0,0 +1,108 @@ +import { useId } from 'react' +import type { MachineHealthPresentation } from '@/lib/machineHealth' +import { MachineHealthTooltipBody } from '@/components/MachineHealthIndicator' +import { HoverTooltip } from '@/components/HoverTooltip' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' + +export type MachineFilterItem = { + id: string + label: string + sessionCount: number + healthPresentation: MachineHealthPresentation | null +} + +const chipBaseClass = 'flex h-7 shrink-0 items-center gap-1.5 rounded-full border px-2.5 text-xs transition-colors' +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 MachineFilterChip(props: { + machine: MachineFilterItem + selected: boolean + onSelect: (id: string) => void +}) { + const { machine, selected, onSelect } = props + const tooltipId = useId() + const hasHealth = machine.healthPresentation && machine.healthPresentation.metrics.length > 0 + + // The button carries the pill's padding so the entire visible chip is + // clickable; when a health popup wraps it, the wrapper only draws the border. + const button = ( + + ) + + if (!hasHealth) { + return ( + + ) + } + + return ( + // CPU/RAM details live in a hover popup so the chip stays compact; + // hidden below the md breakpoint (touch devices). The `before:` bridge + // spans the mt-1 gap so the popup stays open while the pointer enters it. + + + + ) +} + +export function MachineFilterBar(props: { + machines: MachineFilterItem[] + totalCount: number + value: string | null + onChange: (id: string | null) => void +}) { + const { t } = useTranslation() + return ( +
+ + {props.machines.map((machine) => ( + + ))} +
+ ) +} diff --git a/web/src/components/MachineGroupHeader.test.tsx b/web/src/components/MachineGroupHeader.test.tsx deleted file mode 100644 index c917da71..00000000 --- a/web/src/components/MachineGroupHeader.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import type { Machine } from '@/types/api' -import { MachineGroupHeader } from './MachineGroupHeader' -import { I18nProvider } from '@/lib/i18n-context' - -const machine: Machine = { - id: 'Teemo', - namespace: 'default', - seq: 1, - createdAt: 0, - updatedAt: 0, - active: true, - activeAt: 0, - metadata: { - host: 'Teemo', - platform: 'win32', - happyCliVersion: '0.20.2', - }, - metadataVersion: 1, - runnerState: null, - runnerStateVersion: 0, -} - -describe('MachineGroupHeader', () => { - it('renders a single-row machine tile with machine name and compact health', () => { - const onToggle = vi.fn() - render( - - - - ) - - const machineButton = screen.getByRole('button', { name: /Teemo/i }) - expect(machineButton.getAttribute('aria-expanded')).toBe('true') - fireEvent.click(machineButton) - expect(onToggle).toHaveBeenCalledTimes(1) - expect(screen.queryByText('Windows')).toBeNull() - expect(screen.getByText('(4)')).toBeTruthy() - expect(screen.getByLabelText(/CPU 12 percent; RAM 88 percent/i)).toBeTruthy() - - const healthButton = screen.getByRole('button', { name: /CPU 12 percent; RAM 88 percent/i }) - fireEvent.click(healthButton) - expect(healthButton.getAttribute('aria-expanded')).toBe('true') - expect(onToggle).toHaveBeenCalledTimes(1) - }) - - it('keeps uptime in the health tooltip instead of replacing the machine name', () => { - render( - - {}} - machine={{ - ...machine, - metadata: { ...machine.metadata!, host: 'proxmox', platform: 'linux' }, - }} - healthPresentation={{ - metrics: [ - { id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' }, - { id: 'ram', shortLabel: 'RAM', percent: 40, tone: 'ok' }, - ], - overallTone: 'ok', - status: 'healthy', - uptimeDetail: '1h 54m', - }} - /> - - ) - - expect(screen.getByTitle('proxmox')).toBeTruthy() - expect(screen.getByText('1h 54m')).toBeTruthy() - }) -}) diff --git a/web/src/components/MachineGroupHeader.tsx b/web/src/components/MachineGroupHeader.tsx deleted file mode 100644 index 8301fbe0..00000000 --- a/web/src/components/MachineGroupHeader.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { Machine } from '@/types/api' -import { MachineHealthIndicator } from '@/components/MachineHealthIndicator' -import { - type MachineHealthPresentation, -} from '@/lib/machineHealth' -import { cn } from '@/lib/utils' - -function MachineIcon(props: { className?: string }) { - return ( - - - - - - ) -} - -function ChevronIcon(props: { className?: string; collapsed?: boolean }) { - return ( - - - - ) -} - -export function MachineGroupHeader(props: { - label: string - sessionCount: number - collapsed: boolean - onToggle: () => void - machine?: Machine - healthPresentation: MachineHealthPresentation | null -}) { - const hasHealth = props.healthPresentation && props.healthPresentation.metrics.length > 0 - - return ( -
- - {hasHealth ? ( - - ) : null} - - ({props.sessionCount}) - -
- ) -} diff --git a/web/src/components/MachineHealthIndicator.test.tsx b/web/src/components/MachineHealthIndicator.test.tsx deleted file mode 100644 index b29fd2bd..00000000 --- a/web/src/components/MachineHealthIndicator.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { MachineHealthIndicator } from './MachineHealthIndicator' -import { I18nProvider } from '@/lib/i18n-context' - -describe('MachineHealthIndicator', () => { - it('renders labeled cpu and ram meter bars', () => { - render( - - - - ) - - expect(screen.getAllByText('CPU')).toHaveLength(2) - expect(screen.getAllByText('RAM')).toHaveLength(2) - expect(screen.getByLabelText('Updated every ~20s from the runner on this machine')).toBeTruthy() - const healthButton = screen.getByRole('button', { name: /CPU 72/i }) - - fireEvent.click(healthButton) - expect(healthButton.getAttribute('aria-expanded')).toBe('true') - - fireEvent.click(healthButton) - expect(healthButton.getAttribute('aria-expanded')).toBe('false') - - fireEvent.click(healthButton) - fireEvent.pointerDown(document.body) - expect(healthButton.getAttribute('aria-expanded')).toBe('false') - - fireEvent.click(healthButton) - fireEvent.keyDown(healthButton, { key: 'Escape' }) - expect(healthButton.getAttribute('aria-expanded')).toBe('false') - - const helpButton = screen.getByRole('button', { name: 'Updated every ~20s from the runner on this machine' }) - fireEvent.click(helpButton) - expect(helpButton.getAttribute('aria-expanded')).toBe('true') - fireEvent.click(helpButton) - expect(helpButton.getAttribute('aria-expanded')).toBe('false') - }) - - it('renders inline percent labels', () => { - render( - - - - ) - - expect(screen.getByLabelText(/CPU 34 percent; RAM 56 percent/i)).toBeTruthy() - }) -}) diff --git a/web/src/components/MachineHealthIndicator.tsx b/web/src/components/MachineHealthIndicator.tsx index 1e435a0c..e5290010 100644 --- a/web/src/components/MachineHealthIndicator.tsx +++ b/web/src/components/MachineHealthIndicator.tsx @@ -2,49 +2,12 @@ import { useEffect, useId, useRef, useState } from 'react' import { HoverTooltip } from '@/components/HoverTooltip' import { MACHINE_HEALTH_BAR_FILL_CLASS, - MACHINE_HEALTH_CHIP_CLASS, type MachineHealthMetricPresentation, type MachineHealthPresentation } from '@/lib/machineHealth' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' -function HealthMeterBar(props: { - label: string - percent: number - tone: MachineHealthPresentation['overallTone'] - layout: 'stack' | 'inline' - compact?: boolean -}) { - const barWidthClass = props.compact ? 'w-8' : props.layout === 'inline' ? 'w-14' : 'w-11' - const labelWidthClass = props.compact ? 'w-5 text-[8px]' : 'w-6 text-[9px]' - - return ( -
- - {props.label} - - - ) -} - function TooltipMetricStat(props: { metric: MachineHealthMetricPresentation label: string @@ -131,7 +94,7 @@ function MachineHealthHint() { ) } -function MachineHealthTooltipBody(props: { +export function MachineHealthTooltipBody(props: { presentation: MachineHealthPresentation }) { const { t } = useTranslation() @@ -175,94 +138,3 @@ function MachineHealthTooltipBody(props: { ) } - -export function MachineHealthIndicator(props: { - presentation: MachineHealthPresentation - className?: string - layout?: 'stack' | 'inline' - compact?: boolean - tooltipId?: string - revealOnParentFocusClass?: string -}) { - const { t } = useTranslation() - const generatedTooltipId = useId() - const tooltipId = props.tooltipId ?? generatedTooltipId - const { presentation, layout = 'stack', compact = false } = props - const [clickOpen, setClickOpen] = useState(false) - const containerRef = useRef(null) - - useEffect(() => { - if (!clickOpen) return - - const closeOnOutsidePointer = (event: PointerEvent) => { - if (!containerRef.current?.contains(event.target as Node)) { - setClickOpen(false) - } - } - document.addEventListener('pointerdown', closeOnOutsidePointer) - return () => document.removeEventListener('pointerdown', closeOnOutsidePointer) - }, [clickOpen]) - - const ariaLabel = presentation.metrics.length > 0 - ? presentation.metrics - .map((metric) => t(`machine.health.aria.${metric.id}`, { n: metric.percent })) - .join('; ') - : t('machine.health.aria.unknown') - - const chip = ( - - ) - - return ( - - - - ) -} diff --git a/web/src/components/SessionList.machine-filter.test.tsx b/web/src/components/SessionList.machine-filter.test.tsx new file mode 100644 index 00000000..4ab63ec1 --- /dev/null +++ b/web/src/components/SessionList.machine-filter.test.tsx @@ -0,0 +1,149 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReactNode } from 'react' +import type { SessionSummary } from '@/types/api' +import { I18nProvider } from '@/lib/i18n-context' +import { ToastProvider } from '@/lib/toast-context' +import { SessionList } from './SessionList' + +afterEach(() => cleanup()) + +function makeSession(overrides: Partial & { id: string }): SessionSummary { + return { + active: false, + thinking: false, + activeAt: 0, + updatedAt: 0, + metadata: null, + todoProgress: null, + pendingRequestsCount: 0, + pendingRequestKinds: [], + pendingRequests: [], + backgroundTaskCount: 0, + futureScheduledMessageCount: 0, + nextScheduledAt: null, + model: null, + effort: null, + ...overrides + } +} + +function renderWithProviders(children: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + } + }) + + return render( + + + + {children} + + + + ) +} + +function renderSessionList(sessions: SessionSummary[]) { + return renderWithProviders( + + ) +} + +const multiMachineSessions = [ + makeSession({ + id: 'session-m1', + updatedAt: 100, + metadata: { path: '/work/hapi', machineId: 'machine-1', agentSessionId: 'thread-1' } + }), + makeSession({ + id: 'session-m2', + updatedAt: 90, + metadata: { path: '/work/docs', machineId: 'machine-2', agentSessionId: 'thread-2' } + }) +] + +describe('SessionList machine filter', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('hides the filter bar when all sessions are on a single machine', () => { + renderSessionList([ + makeSession({ + id: 'session-1', + updatedAt: 100, + metadata: { path: '/work/hapi', machineId: 'machine-1', agentSessionId: 'thread-1' } + }) + ]) + + expect(screen.queryByRole('group', { name: 'Filter sessions by machine' })).toBeNull() + expect(screen.getByTitle('/work/hapi')).toBeTruthy() + }) + + it('shows the filter bar and machine-suffixed group titles with multiple machines', () => { + renderSessionList(multiMachineSessions) + + expect(screen.getByRole('group', { 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() + }) + + it('filters directory groups when a machine chip is selected', () => { + renderSessionList(multiMachineSessions) + + fireEvent.click(screen.getByRole('button', { name: /Teemo \(1\)/ })) + + expect(screen.queryByTitle('/work/hapi')).toBeNull() + expect(screen.getByTitle('/work/docs')).toBeTruthy() + // Suffix disappears once a single machine is selected + expect(screen.getByText('work/docs')).toBeTruthy() + expect(window.localStorage.getItem('hapi-session-list-machine-filter')).toBe('machine-2') + }) + + it('falls back to All when the persisted machine no longer has sessions', () => { + window.localStorage.setItem('hapi-session-list-machine-filter', 'gone-machine') + renderSessionList(multiMachineSessions) + + expect(screen.getByTitle('/work/hapi')).toBeTruthy() + expect(screen.getByTitle('/work/docs')).toBeTruthy() + expect(screen.getByRole('button', { name: /All \(2\)/ }).getAttribute('aria-pressed')).toBe('true') + }) + + it('shows an empty state when the search only matches sessions on another machine', () => { + renderSessionList([ + makeSession({ + id: 'session-alpha', + updatedAt: 100, + metadata: { path: '/work/hapi', machineId: 'machine-1', agentSessionId: 'thread-1', name: 'Alpha task' } + }), + makeSession({ + id: 'session-beta', + updatedAt: 90, + metadata: { path: '/work/docs', machineId: 'machine-2', agentSessionId: 'thread-2', name: 'Beta task' } + }) + ]) + + fireEvent.change(screen.getByPlaceholderText('Search sessions…'), { target: { value: 'alpha' } }) + fireEvent.click(screen.getByRole('button', { name: /Teemo \(1\)/ })) + + expect(screen.getByText('No sessions match your filters.')).toBeTruthy() + expect(screen.queryByTitle('/work/hapi')).toBeNull() + expect(screen.queryByTitle('/work/docs')).toBeNull() + }) +}) diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index dea40225..28da88ee 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -429,29 +429,25 @@ describe('getNextSessionVisibleCount', () => { }) describe('expandSelectedSessionCollapseOverrides', () => { - it('expands collapsed project and machine, but preserves session preview folding', () => { + it('expands the collapsed project group, but preserves session preview folding', () => { const overrides = new Map([ ['machine-1::/work/hapi', true], - ['sessions::machine-1::/work/hapi', true], - ['machine::machine-1', true] + ['sessions::machine-1::/work/hapi', true] ]) const result = expandSelectedSessionCollapseOverrides(overrides, { - key: 'machine-1::/work/hapi', - machineId: 'machine-1' + key: 'machine-1::/work/hapi' }) expect(result.get('machine-1::/work/hapi')).toBe(false) expect(result.get('sessions::machine-1::/work/hapi')).toBe(true) - expect(result.get('machine::machine-1')).toBe(false) }) it('leaves missing session preview override unset', () => { const overrides = new Map() const result = expandSelectedSessionCollapseOverrides(overrides, { - key: 'machine-1::/work/hapi', - machineId: 'machine-1' + key: 'machine-1::/work/hapi' }) expect(result.has('sessions::machine-1::/work/hapi')).toBe(false) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 1c2c9ee3..f13c92fb 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -26,7 +26,8 @@ import { formatReopenError } from '@/lib/reopenError' import { getSessionTitle } from '@/lib/sessionTitle' import type { Machine } from '@/types/api' import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth' -import { MachineGroupHeader } from '@/components/MachineGroupHeader' +import { MachineFilterBar } from '@/components/MachineFilterBar' +import { useSessionListMachineFilter } from '@/hooks/useSessionListMachineFilter' import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus' type SessionGroup = { @@ -278,25 +279,17 @@ function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] { export function expandSelectedSessionCollapseOverrides( overrides: Map, - group: { key: string; machineId: string | null } + group: { key: string } ): Map { - const next = new Map(overrides) - let changed = false - // Keep auto-expanded paths open after selection moves so content above the // clicked row does not collapse and displace the sidebar viewport. - if (overrides.get(group.key) !== false) { - next.set(group.key, false) - changed = true + if (overrides.get(group.key) === false) { + return overrides } - const machineKey = `machine::${group.machineId ?? UNKNOWN_MACHINE_ID}` - if (overrides.get(machineKey) !== false) { - next.set(machineKey, false) - changed = true - } - - return changed ? next : overrides + const next = new Map(overrides) + next.set(group.key, false) + return next } function groupByMachine( @@ -1027,6 +1020,7 @@ export function SessionList(props: { const { sessionPreviewLimit } = useSessionPreviewLimit() const { sessionListStatusMode } = useSessionListStatusMode() const { showActiveSessionsOnly } = useShowActiveSessionsOnly() + const { machineFilter, setMachineFilter } = useSessionListMachineFilter() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') const [customStart, setCustomStart] = useState('') @@ -1081,9 +1075,26 @@ export function SessionList(props: { () => groupSessionsByDirectory(allSessions), [allSessions] ) + const machineFilters = useMemo( + () => groupByMachine(allGroups, resolveMachineLabel), + [allGroups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps + ) + 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. + const activeMachineFilter = showMachineFilterBar && machineFilter !== null + && machineFilters.some(mg => (mg.machineId ?? UNKNOWN_MACHINE_ID) === machineFilter) + ? machineFilter + : null + const machineFilteredSessions = useMemo( + () => activeMachineFilter === null + ? visibleSessions + : visibleSessions.filter(session => (session.metadata?.machineId ?? UNKNOWN_MACHINE_ID) === activeMachineFilter), + [visibleSessions, activeMachineFilter] + ) const groups = useMemo( - () => groupSessionsByDirectory(visibleSessions), - [visibleSessions] + () => groupSessionsByDirectory(machineFilteredSessions), + [machineFilteredSessions] ) const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() @@ -1146,33 +1157,7 @@ export function SessionList(props: { ) } - const machineGroups = useMemo( - () => groupByMachine(groups, resolveMachineLabel), - [groups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps - ) - - const isMachineCollapsed = (mg: MachineGroup): boolean => { - if (isFiltering) return false - const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}` - const override = collapseOverrides.get(key) - if (override !== undefined) return override - const hasSelected = selectedSessionId - ? mg.projectGroups.some(pg => pg.sessions.some(s => s.id === selectedSessionId)) - : false - return !mg.hasActiveSession && !hasSelected - } - - const toggleMachine = (mg: MachineGroup) => { - const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}` - const current = isMachineCollapsed(mg) - setCollapseOverrides(prev => { - const next = new Map(prev) - next.set(key, !current) - return next - }) - } - - // Auto-expand group (and machine) containing the selected session only when + // Auto-expand group containing the selected session only when // the selected-session/group pair changes. Without this guard, every live // session-list refresh (for example tool-call updates from a running selected // session) reopens a path the user just collapsed. @@ -1203,7 +1188,6 @@ export function SessionList(props: { for (const g of allGroups) { knownKeys.add(g.key) knownKeys.add(`sessions::${g.key}`) - knownKeys.add(`machine::${g.machineId ?? UNKNOWN_MACHINE_ID}`) } let changed = false for (const key of next.keys()) { @@ -1236,12 +1220,7 @@ export function SessionList(props: { return (
{renderHeader ? ( -
-
- {isFiltering - ? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length }) - : t('sessions.count', { n: allSessions.length, m: allGroups.length })} -
+
+ ) : null} + + ({group.sessions.length}) + +
- {/* Level 2: Projects */} -
+ {/* Sessions */} +
-
- {mg.projectGroups.map((group) => { - const isCollapsed = isGroupCollapsed(group) - const visibleGroupSessions = getVisibleGroupSessions(group) - const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length - const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit - const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount) - const canStartInGroupDirectory = group.directory !== 'Other' - return ( -
-
toggleGroup(group.key, isCollapsed)} - title={group.directory} - > - - - {group.displayName} - - - {onNewSessionInDirectory && canStartInGroupDirectory ? ( - - ) : null} - - ({group.sessions.length}) - -
- - {/* Level 3: Sessions */} -
-
-
- {visibleGroupSessions.map((s) => ( - - ))} - {!isFiltering && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? ( - - ) : null} -
-
-
-
- ) - })} +
+ {visibleGroupSessions.map((s) => ( + + ))} + {!isFiltering && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? ( + + ) : null}
diff --git a/web/src/hooks/useSessionListMachineFilter.test.ts b/web/src/hooks/useSessionListMachineFilter.test.ts new file mode 100644 index 00000000..277b82e8 --- /dev/null +++ b/web/src/hooks/useSessionListMachineFilter.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + DEFAULT_SESSION_LIST_MACHINE_FILTER, + getInitialSessionListMachineFilter, +} from './useSessionListMachineFilter' + +describe('useSessionListMachineFilter helpers', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('defaults to null (all machines) for missing or blank storage values', () => { + expect(getInitialSessionListMachineFilter()).toBe(DEFAULT_SESSION_LIST_MACHINE_FILTER) + expect(getInitialSessionListMachineFilter()).toBeNull() + + window.localStorage.setItem('hapi-session-list-machine-filter', '') + expect(getInitialSessionListMachineFilter()).toBeNull() + + window.localStorage.setItem('hapi-session-list-machine-filter', ' ') + expect(getInitialSessionListMachineFilter()).toBeNull() + }) + + it('reads a stored machine id', () => { + window.localStorage.setItem('hapi-session-list-machine-filter', 'machine-1') + + expect(getInitialSessionListMachineFilter()).toBe('machine-1') + }) +}) diff --git a/web/src/hooks/useSessionListMachineFilter.ts b/web/src/hooks/useSessionListMachineFilter.ts new file mode 100644 index 00000000..0e472550 --- /dev/null +++ b/web/src/hooks/useSessionListMachineFilter.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react' + +// null = "All machines" (no filtering). A string is a machine id, or +// UNKNOWN_MACHINE_ID ('__unknown__') for sessions without machine metadata. +export type SessionListMachineFilter = string | null + +export const DEFAULT_SESSION_LIST_MACHINE_FILTER: SessionListMachineFilter = null + +function getSessionListMachineFilterStorageKey(): string { + return 'hapi-session-list-machine-filter' +} + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(key: string): string | null { + if (!isBrowser()) { + return null + } + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +function parseSessionListMachineFilter(raw: string | null): SessionListMachineFilter { + return raw && raw.trim().length > 0 ? raw : DEFAULT_SESSION_LIST_MACHINE_FILTER +} + +export function getInitialSessionListMachineFilter(): SessionListMachineFilter { + return parseSessionListMachineFilter(safeGetItem(getSessionListMachineFilterStorageKey())) +} + +export function useSessionListMachineFilter(): { + machineFilter: SessionListMachineFilter + setMachineFilter: (filter: SessionListMachineFilter) => void +} { + const [machineFilter, setMachineFilterState] = useState(getInitialSessionListMachineFilter) + + useEffect(() => { + if (!isBrowser()) { + return + } + + const onStorage = (event: StorageEvent) => { + if (event.key !== getSessionListMachineFilterStorageKey()) { + return + } + setMachineFilterState(parseSessionListMachineFilter(event.newValue)) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setMachineFilter = useCallback((filter: SessionListMachineFilter) => { + setMachineFilterState(filter) + + if (filter === null) { + safeRemoveItem(getSessionListMachineFilterStorageKey()) + } else { + safeSetItem(getSessionListMachineFilterStorageKey(), filter) + } + }, []) + + return { machineFilter, setMachineFilter } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index ee6401cf..b2062164 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -43,7 +43,6 @@ export default { 'login.footer.copyright': '©', // Sessions page - 'sessions.count': '{n} sessions in {m} projects', 'sessions.new': 'New Session', 'sessions.empty.title': 'No sessions yet', 'sessions.empty.hint': 'Start a coding session in any folder under your workspace, or browse the tree first.', @@ -51,7 +50,6 @@ export default { 'sessions.empty.browse': 'Browse workspace', 'sessions.search.placeholder': 'Search sessions…', 'sessions.search.clear': 'Clear search', - 'sessions.search.count': '{n} of {total} sessions', 'sessions.search.noResults': 'No sessions match your filters.', 'sessions.timeFilter.label': 'Filter sessions by last activity', 'sessions.timeFilter.pickStart': 'Select start date', @@ -64,6 +62,8 @@ export default { 'sessions.group.showMore': 'Show {n} more', 'sessions.group.showLess': 'Show less', 'sessions.group.new': 'New session in this directory', + 'sessions.machineFilter.label': 'Filter sessions by machine', + 'sessions.machineFilter.all': 'All', 'codexSync.tooltip': 'Import sessions from Codex into Hapi', 'codexSync.newSessionAction': 'Import Codex history', 'codexSync.confirm.title': 'Import Codex sessions', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 1c62cfa2..44c0d958 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -43,7 +43,6 @@ export default { 'login.footer.copyright': '©', // Sessions page - 'sessions.count': '{n} 个会话,{m} 个项目', 'sessions.new': '新建会话', 'sessions.empty.title': '还没有会话', 'sessions.empty.hint': '在 workspace 下任意目录启动一个会话,或先浏览目录树看看。', @@ -51,7 +50,6 @@ export default { 'sessions.empty.browse': '浏览 workspace', 'sessions.search.placeholder': '搜索会话…', 'sessions.search.clear': '清除搜索', - 'sessions.search.count': '{n} / {total} 个会话', 'sessions.search.noResults': '没有符合筛选条件的会话。', 'sessions.timeFilter.label': '按最后活动时间筛选会话', 'sessions.timeFilter.pickStart': '选择开始日期', @@ -64,6 +62,8 @@ export default { 'sessions.group.showMore': '再显示 {n} 个', 'sessions.group.showLess': '收起', 'sessions.group.new': '在此目录新建会话', + 'sessions.machineFilter.label': '按机器筛选会话', + 'sessions.machineFilter.all': '全部', 'codexSync.tooltip': '从 Codex 导入会话到 Hapi', 'codexSync.newSessionAction': '导入 Codex 历史', 'codexSync.confirm.title': '导入 Codex 会话', diff --git a/web/src/router.tsx b/web/src/router.tsx index 5c09f6d4..99375d06 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -232,9 +232,6 @@ function SessionsPage() { })() }, [addToast, refetch, t]) - const projectCount = useMemo(() => new Set(sessions.map(s => - s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other' - )).size, [sessions]) const machineLabelsById = useMemo(() => { const labels: Record = {} for (const machine of machines) { @@ -550,10 +547,7 @@ function SessionsPage() { style={{ '--sidebar-w': `${sidebar.width}px` } as React.CSSProperties} >
-
-
- {t('sessions.count', { n: sessions.length, m: projectCount })} -
+