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.
This commit is contained in:
weishu
2026-07-27 11:55:43 +08:00
parent 8297383dc0
commit 2e54d9fdff
14 changed files with 611 additions and 553 deletions
@@ -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')
})
})
@@ -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<SessionListMachineFilter>(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 }
}