mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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:
@@ -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<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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
totalCount={5}
|
||||||
|
value={null}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(machine.id)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-describedby={hasHealth ? tooltipId : undefined}
|
||||||
|
title={machine.label}
|
||||||
|
className="flex h-7 min-w-0 items-center gap-1.5 rounded-full px-2.5 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
|
||||||
|
>
|
||||||
|
<span className="max-w-32 truncate">{machine.label}</span>
|
||||||
|
<span className="tabular-nums opacity-70">({machine.sessionCount})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasHealth) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(machine.id)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={machine.label}
|
||||||
|
className={cn(chipBaseClass, selected ? chipSelectedClass : chipIdleClass)}
|
||||||
|
>
|
||||||
|
<span className="max-w-32 truncate">{machine.label}</span>
|
||||||
|
<span className="tabular-nums opacity-70">({machine.sessionCount})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
<HoverTooltip
|
||||||
|
id={tooltipId}
|
||||||
|
target={button}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className={cn('shrink-0 rounded-full border transition-colors', selected ? chipSelectedClass : chipIdleClass)}
|
||||||
|
tooltipClassName="pointer-events-auto before:absolute before:inset-x-0 before:-top-1 before:h-1 before:content-[''] px-3 py-2 min-w-[16rem] max-md:hidden"
|
||||||
|
>
|
||||||
|
<MachineHealthTooltipBody presentation={machine.healthPresentation!} />
|
||||||
|
</HoverTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MachineFilterBar(props: {
|
||||||
|
machines: MachineFilterItem[]
|
||||||
|
totalCount: number
|
||||||
|
value: string | null
|
||||||
|
onChange: (id: string | null) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t('sessions.machineFilter.label')}
|
||||||
|
className="flex flex-wrap items-center gap-1.5 px-2 pb-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onChange(null)}
|
||||||
|
aria-pressed={props.value === null}
|
||||||
|
className={cn(chipBaseClass, props.value === null ? chipSelectedClass : chipIdleClass)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{t('sessions.machineFilter.all')}</span>
|
||||||
|
<span className="tabular-nums opacity-70">({props.totalCount})</span>
|
||||||
|
</button>
|
||||||
|
{props.machines.map((machine) => (
|
||||||
|
<MachineFilterChip
|
||||||
|
key={machine.id}
|
||||||
|
machine={machine}
|
||||||
|
selected={props.value === machine.id}
|
||||||
|
onSelect={props.onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(
|
|
||||||
<I18nProvider>
|
|
||||||
<MachineGroupHeader
|
|
||||||
label="Teemo"
|
|
||||||
sessionCount={4}
|
|
||||||
collapsed={false}
|
|
||||||
onToggle={onToggle}
|
|
||||||
machine={machine}
|
|
||||||
healthPresentation={{
|
|
||||||
metrics: [
|
|
||||||
{ id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' },
|
|
||||||
{ id: 'ram', shortLabel: 'RAM', percent: 88, tone: 'warn' },
|
|
||||||
],
|
|
||||||
overallTone: 'warn',
|
|
||||||
status: 'elevated',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</I18nProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
<I18nProvider>
|
|
||||||
<MachineGroupHeader
|
|
||||||
label="proxmox"
|
|
||||||
sessionCount={2}
|
|
||||||
collapsed={false}
|
|
||||||
onToggle={() => {}}
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</I18nProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByTitle('proxmox')).toBeTruthy()
|
|
||||||
expect(screen.getByText('1h 54m')).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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 (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className={props.className}
|
|
||||||
>
|
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
|
||||||
<line x1="8" y1="21" x2="16" y2="21" />
|
|
||||||
<line x1="12" y1="17" x2="12" y2="21" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChevronIcon(props: { className?: string; collapsed?: boolean }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className={cn(
|
|
||||||
props.className,
|
|
||||||
'transition-transform duration-200',
|
|
||||||
props.collapsed ? '' : 'rotate-90'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<polyline points="9 18 15 12 9 6" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'group/machine-row relative flex w-full min-w-0 items-center gap-2 px-1 py-1.5 text-left rounded-lg select-none',
|
|
||||||
'border border-[var(--app-border)] bg-[var(--app-subtle-bg)]/70',
|
|
||||||
'transition-colors hover:bg-[var(--app-subtle-bg)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={props.onToggle}
|
|
||||||
aria-expanded={!props.collapsed}
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
|
|
||||||
>
|
|
||||||
<ChevronIcon className="h-4 w-4 shrink-0 text-[var(--app-hint)]" collapsed={props.collapsed} />
|
|
||||||
<MachineIcon className="h-4 w-4 shrink-0 text-[var(--app-link)]/80" />
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--app-fg)]" title={props.label}>
|
|
||||||
{props.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{hasHealth ? (
|
|
||||||
<MachineHealthIndicator
|
|
||||||
presentation={props.healthPresentation!}
|
|
||||||
layout="inline"
|
|
||||||
compact
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-[var(--app-hint)]">
|
|
||||||
({props.sessionCount})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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(
|
|
||||||
<I18nProvider>
|
|
||||||
<MachineHealthIndicator
|
|
||||||
presentation={{
|
|
||||||
metrics: [
|
|
||||||
{ id: 'cpu', shortLabel: 'CPU', percent: 72, tone: 'ok' },
|
|
||||||
{ id: 'ram', shortLabel: 'RAM', percent: 81, tone: 'warn' }
|
|
||||||
],
|
|
||||||
overallTone: 'warn',
|
|
||||||
status: 'elevated',
|
|
||||||
loadDetail: '2.4/8',
|
|
||||||
cpuCount: 6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</I18nProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
<I18nProvider>
|
|
||||||
<MachineHealthIndicator
|
|
||||||
layout="inline"
|
|
||||||
presentation={{
|
|
||||||
metrics: [
|
|
||||||
{ id: 'cpu', shortLabel: 'CPU', percent: 34, tone: 'ok' },
|
|
||||||
{ id: 'ram', shortLabel: 'RAM', percent: 56, tone: 'warn' }
|
|
||||||
],
|
|
||||||
overallTone: 'warn',
|
|
||||||
status: 'elevated',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</I18nProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/CPU 34 percent; RAM 56 percent/i)).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -2,49 +2,12 @@ import { useEffect, useId, useRef, useState } from 'react'
|
|||||||
import { HoverTooltip } from '@/components/HoverTooltip'
|
import { HoverTooltip } from '@/components/HoverTooltip'
|
||||||
import {
|
import {
|
||||||
MACHINE_HEALTH_BAR_FILL_CLASS,
|
MACHINE_HEALTH_BAR_FILL_CLASS,
|
||||||
MACHINE_HEALTH_CHIP_CLASS,
|
|
||||||
type MachineHealthMetricPresentation,
|
type MachineHealthMetricPresentation,
|
||||||
type MachineHealthPresentation
|
type MachineHealthPresentation
|
||||||
} from '@/lib/machineHealth'
|
} from '@/lib/machineHealth'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useTranslation } from '@/lib/use-translation'
|
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 (
|
|
||||||
<div className="flex items-center gap-0.5 min-w-0">
|
|
||||||
<span className={cn('shrink-0 font-semibold uppercase tracking-wide text-[var(--app-hint)]', labelWidthClass)}>
|
|
||||||
{props.label}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'relative h-1.5 shrink-0 overflow-hidden rounded-full bg-[var(--app-border)]/80',
|
|
||||||
barWidthClass
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn('h-full rounded-full transition-[width]', MACHINE_HEALTH_BAR_FILL_CLASS[props.tone])}
|
|
||||||
style={{ width: `${Math.max(4, Math.min(100, props.percent))}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{props.layout === 'inline' && !props.compact ? (
|
|
||||||
<span className="w-7 shrink-0 text-[10px] tabular-nums text-[var(--app-fg)]/80">
|
|
||||||
{props.percent}%
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TooltipMetricStat(props: {
|
function TooltipMetricStat(props: {
|
||||||
metric: MachineHealthMetricPresentation
|
metric: MachineHealthMetricPresentation
|
||||||
label: string
|
label: string
|
||||||
@@ -131,7 +94,7 @@ function MachineHealthHint() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MachineHealthTooltipBody(props: {
|
export function MachineHealthTooltipBody(props: {
|
||||||
presentation: MachineHealthPresentation
|
presentation: MachineHealthPresentation
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -175,94 +138,3 @@ function MachineHealthTooltipBody(props: {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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<HTMLSpanElement>(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 = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'inline-flex rounded-md border',
|
|
||||||
compact ? 'flex-row flex-nowrap items-center gap-x-1.5 px-1 py-0.5' : layout === 'inline'
|
|
||||||
? 'flex-row flex-wrap items-center gap-x-3 gap-y-1 px-1.5 py-1'
|
|
||||||
: 'flex-col gap-0.5 px-1.5 py-1',
|
|
||||||
MACHINE_HEALTH_CHIP_CLASS[presentation.overallTone],
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-describedby={tooltipId}
|
|
||||||
aria-expanded={clickOpen}
|
|
||||||
aria-controls={tooltipId}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (clickOpen) {
|
|
||||||
event.currentTarget.blur()
|
|
||||||
}
|
|
||||||
setClickOpen((open) => !open)
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setClickOpen(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{presentation.metrics.map((metric) => (
|
|
||||||
<HealthMeterBar
|
|
||||||
key={metric.id}
|
|
||||||
label={metric.shortLabel}
|
|
||||||
percent={metric.percent}
|
|
||||||
tone={metric.tone}
|
|
||||||
layout={layout}
|
|
||||||
compact={compact}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverTooltip
|
|
||||||
id={tooltipId}
|
|
||||||
target={chip}
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
className="shrink-0"
|
|
||||||
tooltipClassName="pointer-events-auto before:absolute before:inset-x-0 before:-top-1 before:h-1 before:content-[''] px-3 py-2 min-w-[16rem]"
|
|
||||||
revealOnParentFocusClass={props.revealOnParentFocusClass}
|
|
||||||
open={clickOpen}
|
|
||||||
containerRef={containerRef}
|
|
||||||
>
|
|
||||||
<MachineHealthTooltipBody presentation={presentation} />
|
|
||||||
</HoverTooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<SessionSummary> & { 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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ToastProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
{children}
|
||||||
|
</I18nProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionList(sessions: SessionSummary[]) {
|
||||||
|
return renderWithProviders(
|
||||||
|
<SessionList
|
||||||
|
sessions={sessions}
|
||||||
|
selectedSessionId={null}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onNewSession={vi.fn()}
|
||||||
|
onRefresh={vi.fn()}
|
||||||
|
isLoading={false}
|
||||||
|
renderHeader={false}
|
||||||
|
api={null}
|
||||||
|
machineLabelsById={{ 'machine-1': 'Mint', 'machine-2': 'Teemo' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -429,29 +429,25 @@ describe('getNextSessionVisibleCount', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('expandSelectedSessionCollapseOverrides', () => {
|
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<string, boolean>([
|
const overrides = new Map<string, boolean>([
|
||||||
['machine-1::/work/hapi', true],
|
['machine-1::/work/hapi', true],
|
||||||
['sessions::machine-1::/work/hapi', true],
|
['sessions::machine-1::/work/hapi', true]
|
||||||
['machine::machine-1', true]
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const result = expandSelectedSessionCollapseOverrides(overrides, {
|
const result = expandSelectedSessionCollapseOverrides(overrides, {
|
||||||
key: 'machine-1::/work/hapi',
|
key: 'machine-1::/work/hapi'
|
||||||
machineId: 'machine-1'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.get('machine-1::/work/hapi')).toBe(false)
|
expect(result.get('machine-1::/work/hapi')).toBe(false)
|
||||||
expect(result.get('sessions::machine-1::/work/hapi')).toBe(true)
|
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', () => {
|
it('leaves missing session preview override unset', () => {
|
||||||
const overrides = new Map<string, boolean>()
|
const overrides = new Map<string, boolean>()
|
||||||
|
|
||||||
const result = expandSelectedSessionCollapseOverrides(overrides, {
|
const result = expandSelectedSessionCollapseOverrides(overrides, {
|
||||||
key: 'machine-1::/work/hapi',
|
key: 'machine-1::/work/hapi'
|
||||||
machineId: 'machine-1'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.has('sessions::machine-1::/work/hapi')).toBe(false)
|
expect(result.has('sessions::machine-1::/work/hapi')).toBe(false)
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { formatReopenError } from '@/lib/reopenError'
|
|||||||
import { getSessionTitle } from '@/lib/sessionTitle'
|
import { getSessionTitle } from '@/lib/sessionTitle'
|
||||||
import type { Machine } from '@/types/api'
|
import type { Machine } from '@/types/api'
|
||||||
import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth'
|
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'
|
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
|
||||||
|
|
||||||
type SessionGroup = {
|
type SessionGroup = {
|
||||||
@@ -278,25 +279,17 @@ function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] {
|
|||||||
|
|
||||||
export function expandSelectedSessionCollapseOverrides(
|
export function expandSelectedSessionCollapseOverrides(
|
||||||
overrides: Map<string, boolean>,
|
overrides: Map<string, boolean>,
|
||||||
group: { key: string; machineId: string | null }
|
group: { key: string }
|
||||||
): Map<string, boolean> {
|
): Map<string, boolean> {
|
||||||
const next = new Map(overrides)
|
|
||||||
let changed = false
|
|
||||||
|
|
||||||
// Keep auto-expanded paths open after selection moves so content above the
|
// Keep auto-expanded paths open after selection moves so content above the
|
||||||
// clicked row does not collapse and displace the sidebar viewport.
|
// clicked row does not collapse and displace the sidebar viewport.
|
||||||
if (overrides.get(group.key) !== false) {
|
if (overrides.get(group.key) === false) {
|
||||||
|
return overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Map(overrides)
|
||||||
next.set(group.key, false)
|
next.set(group.key, false)
|
||||||
changed = true
|
return next
|
||||||
}
|
|
||||||
|
|
||||||
const machineKey = `machine::${group.machineId ?? UNKNOWN_MACHINE_ID}`
|
|
||||||
if (overrides.get(machineKey) !== false) {
|
|
||||||
next.set(machineKey, false)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? next : overrides
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByMachine(
|
function groupByMachine(
|
||||||
@@ -1027,6 +1020,7 @@ export function SessionList(props: {
|
|||||||
const { sessionPreviewLimit } = useSessionPreviewLimit()
|
const { sessionPreviewLimit } = useSessionPreviewLimit()
|
||||||
const { sessionListStatusMode } = useSessionListStatusMode()
|
const { sessionListStatusMode } = useSessionListStatusMode()
|
||||||
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
|
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
|
||||||
|
const { machineFilter, setMachineFilter } = useSessionListMachineFilter()
|
||||||
const showDetailedStatus = sessionListStatusMode === 'detailed'
|
const showDetailedStatus = sessionListStatusMode === 'detailed'
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [customStart, setCustomStart] = useState('')
|
const [customStart, setCustomStart] = useState('')
|
||||||
@@ -1081,9 +1075,26 @@ export function SessionList(props: {
|
|||||||
() => groupSessionsByDirectory(allSessions),
|
() => groupSessionsByDirectory(allSessions),
|
||||||
[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(
|
const groups = useMemo(
|
||||||
() => groupSessionsByDirectory(visibleSessions),
|
() => groupSessionsByDirectory(machineFilteredSessions),
|
||||||
[visibleSessions]
|
[machineFilteredSessions]
|
||||||
)
|
)
|
||||||
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
|
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
|
||||||
() => new Map()
|
() => new Map()
|
||||||
@@ -1146,33 +1157,7 @@ export function SessionList(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const machineGroups = useMemo(
|
// Auto-expand group containing the selected session only when
|
||||||
() => 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
|
|
||||||
// the selected-session/group pair changes. Without this guard, every live
|
// the selected-session/group pair changes. Without this guard, every live
|
||||||
// session-list refresh (for example tool-call updates from a running selected
|
// session-list refresh (for example tool-call updates from a running selected
|
||||||
// session) reopens a path the user just collapsed.
|
// session) reopens a path the user just collapsed.
|
||||||
@@ -1203,7 +1188,6 @@ export function SessionList(props: {
|
|||||||
for (const g of allGroups) {
|
for (const g of allGroups) {
|
||||||
knownKeys.add(g.key)
|
knownKeys.add(g.key)
|
||||||
knownKeys.add(`sessions::${g.key}`)
|
knownKeys.add(`sessions::${g.key}`)
|
||||||
knownKeys.add(`machine::${g.machineId ?? UNKNOWN_MACHINE_ID}`)
|
|
||||||
}
|
}
|
||||||
let changed = false
|
let changed = false
|
||||||
for (const key of next.keys()) {
|
for (const key of next.keys()) {
|
||||||
@@ -1236,12 +1220,7 @@ export function SessionList(props: {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-content flex flex-col">
|
<div className="mx-auto w-full max-w-content flex flex-col">
|
||||||
{renderHeader ? (
|
{renderHeader ? (
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<div className="flex items-center justify-end px-3 py-1">
|
||||||
<div className="text-xs text-[var(--app-hint)]">
|
|
||||||
{isFiltering
|
|
||||||
? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length })
|
|
||||||
: t('sessions.count', { n: allSessions.length, m: allGroups.length })}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={props.onNewSession}
|
onClick={props.onNewSession}
|
||||||
@@ -1274,42 +1253,45 @@ export function SessionList(props: {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{props.sessions.length > 0 && isFiltering && visibleSessions.length === 0 ? (
|
{props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null) && groups.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
|
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
|
||||||
{t('sessions.search.noResults')}
|
{t('sessions.search.noResults')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 px-2 pt-1 pb-2">
|
{showMachineFilterBar ? (
|
||||||
{machineGroups.map((mg) => {
|
<MachineFilterBar
|
||||||
const machineCollapsed = isMachineCollapsed(mg)
|
machines={machineFilters.map((mg) => {
|
||||||
const machine = mg.machineId ? machinesById[mg.machineId] : undefined
|
const machine = mg.machineId ? machinesById[mg.machineId] : undefined
|
||||||
const healthPresentation = presentMachineHealth(
|
return {
|
||||||
|
id: mg.machineId ?? UNKNOWN_MACHINE_ID,
|
||||||
|
label: mg.label,
|
||||||
|
sessionCount: mg.totalSessions,
|
||||||
|
healthPresentation: presentMachineHealth(
|
||||||
machine?.health,
|
machine?.health,
|
||||||
getMachinePlatform(machine)
|
getMachinePlatform(machine)
|
||||||
)
|
)
|
||||||
return (
|
}
|
||||||
<div key={mg.machineId ?? UNKNOWN_MACHINE_ID}>
|
})}
|
||||||
<MachineGroupHeader
|
totalCount={allSessions.length}
|
||||||
label={mg.label}
|
value={activeMachineFilter}
|
||||||
sessionCount={mg.totalSessions}
|
onChange={setMachineFilter}
|
||||||
collapsed={machineCollapsed}
|
|
||||||
onToggle={() => toggleMachine(mg)}
|
|
||||||
machine={machine}
|
|
||||||
healthPresentation={healthPresentation}
|
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Level 2: Projects */}
|
<div className="flex flex-col gap-1 px-2 pt-1 pb-2">
|
||||||
<div className="collapsible-panel" data-open={!machineCollapsed || undefined}>
|
{groups.map((group) => {
|
||||||
<div className="collapsible-inner">
|
|
||||||
<div className="flex flex-col ml-3.5 pl-1 mt-0.5">
|
|
||||||
{mg.projectGroups.map((group) => {
|
|
||||||
const isCollapsed = isGroupCollapsed(group)
|
const isCollapsed = isGroupCollapsed(group)
|
||||||
const visibleGroupSessions = getVisibleGroupSessions(group)
|
const visibleGroupSessions = getVisibleGroupSessions(group)
|
||||||
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length
|
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length
|
||||||
const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit
|
const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit
|
||||||
const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount)
|
const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount)
|
||||||
const canStartInGroupDirectory = group.directory !== 'Other'
|
const canStartInGroupDirectory = group.directory !== 'Other'
|
||||||
|
// With multiple machines in the unfiltered view, disambiguate
|
||||||
|
// same-named directories by suffixing the machine label.
|
||||||
|
const groupTitle = showMachineFilterBar && activeMachineFilter === null
|
||||||
|
? `${group.displayName} · ${resolveMachineLabel(group.machineId)}`
|
||||||
|
: group.displayName
|
||||||
return (
|
return (
|
||||||
<div key={group.key}>
|
<div key={group.key}>
|
||||||
<div
|
<div
|
||||||
@@ -1319,7 +1301,7 @@ export function SessionList(props: {
|
|||||||
>
|
>
|
||||||
<ChevronIcon className="h-3.5 w-3.5 text-[var(--app-hint)] shrink-0" collapsed={isCollapsed} />
|
<ChevronIcon className="h-3.5 w-3.5 text-[var(--app-hint)] shrink-0" collapsed={isCollapsed} />
|
||||||
<span className="font-medium text-sm truncate flex-1">
|
<span className="font-medium text-sm truncate flex-1">
|
||||||
{group.displayName}
|
{groupTitle}
|
||||||
</span>
|
</span>
|
||||||
<CopyPathButton path={group.directory} className="opacity-0 group-hover/project:opacity-100 transition-opacity duration-150" />
|
<CopyPathButton path={group.directory} className="opacity-0 group-hover/project:opacity-100 transition-opacity duration-150" />
|
||||||
{onNewSessionInDirectory && canStartInGroupDirectory ? (
|
{onNewSessionInDirectory && canStartInGroupDirectory ? (
|
||||||
@@ -1344,7 +1326,7 @@ export function SessionList(props: {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Level 3: Sessions */}
|
{/* Sessions */}
|
||||||
<div className="collapsible-panel" data-open={!isCollapsed || undefined}>
|
<div className="collapsible-panel" data-open={!isCollapsed || undefined}>
|
||||||
<div className="collapsible-inner">
|
<div className="collapsible-inner">
|
||||||
<div className="flex flex-col gap-0.5 ml-3 pl-1 py-1">
|
<div className="flex flex-col gap-0.5 ml-3 pl-1 py-1">
|
||||||
@@ -1383,11 +1365,5 @@ export function SessionList(props: {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -43,7 +43,6 @@ export default {
|
|||||||
'login.footer.copyright': '©',
|
'login.footer.copyright': '©',
|
||||||
|
|
||||||
// Sessions page
|
// Sessions page
|
||||||
'sessions.count': '{n} sessions in {m} projects',
|
|
||||||
'sessions.new': 'New Session',
|
'sessions.new': 'New Session',
|
||||||
'sessions.empty.title': 'No sessions yet',
|
'sessions.empty.title': 'No sessions yet',
|
||||||
'sessions.empty.hint': 'Start a coding session in any folder under your workspace, or browse the tree first.',
|
'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.empty.browse': 'Browse workspace',
|
||||||
'sessions.search.placeholder': 'Search sessions…',
|
'sessions.search.placeholder': 'Search sessions…',
|
||||||
'sessions.search.clear': 'Clear search',
|
'sessions.search.clear': 'Clear search',
|
||||||
'sessions.search.count': '{n} of {total} sessions',
|
|
||||||
'sessions.search.noResults': 'No sessions match your filters.',
|
'sessions.search.noResults': 'No sessions match your filters.',
|
||||||
'sessions.timeFilter.label': 'Filter sessions by last activity',
|
'sessions.timeFilter.label': 'Filter sessions by last activity',
|
||||||
'sessions.timeFilter.pickStart': 'Select start date',
|
'sessions.timeFilter.pickStart': 'Select start date',
|
||||||
@@ -64,6 +62,8 @@ export default {
|
|||||||
'sessions.group.showMore': 'Show {n} more',
|
'sessions.group.showMore': 'Show {n} more',
|
||||||
'sessions.group.showLess': 'Show less',
|
'sessions.group.showLess': 'Show less',
|
||||||
'sessions.group.new': 'New session in this directory',
|
'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.tooltip': 'Import sessions from Codex into Hapi',
|
||||||
'codexSync.newSessionAction': 'Import Codex history',
|
'codexSync.newSessionAction': 'Import Codex history',
|
||||||
'codexSync.confirm.title': 'Import Codex sessions',
|
'codexSync.confirm.title': 'Import Codex sessions',
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export default {
|
|||||||
'login.footer.copyright': '©',
|
'login.footer.copyright': '©',
|
||||||
|
|
||||||
// Sessions page
|
// Sessions page
|
||||||
'sessions.count': '{n} 个会话,{m} 个项目',
|
|
||||||
'sessions.new': '新建会话',
|
'sessions.new': '新建会话',
|
||||||
'sessions.empty.title': '还没有会话',
|
'sessions.empty.title': '还没有会话',
|
||||||
'sessions.empty.hint': '在 workspace 下任意目录启动一个会话,或先浏览目录树看看。',
|
'sessions.empty.hint': '在 workspace 下任意目录启动一个会话,或先浏览目录树看看。',
|
||||||
@@ -51,7 +50,6 @@ export default {
|
|||||||
'sessions.empty.browse': '浏览 workspace',
|
'sessions.empty.browse': '浏览 workspace',
|
||||||
'sessions.search.placeholder': '搜索会话…',
|
'sessions.search.placeholder': '搜索会话…',
|
||||||
'sessions.search.clear': '清除搜索',
|
'sessions.search.clear': '清除搜索',
|
||||||
'sessions.search.count': '{n} / {total} 个会话',
|
|
||||||
'sessions.search.noResults': '没有符合筛选条件的会话。',
|
'sessions.search.noResults': '没有符合筛选条件的会话。',
|
||||||
'sessions.timeFilter.label': '按最后活动时间筛选会话',
|
'sessions.timeFilter.label': '按最后活动时间筛选会话',
|
||||||
'sessions.timeFilter.pickStart': '选择开始日期',
|
'sessions.timeFilter.pickStart': '选择开始日期',
|
||||||
@@ -64,6 +62,8 @@ export default {
|
|||||||
'sessions.group.showMore': '再显示 {n} 个',
|
'sessions.group.showMore': '再显示 {n} 个',
|
||||||
'sessions.group.showLess': '收起',
|
'sessions.group.showLess': '收起',
|
||||||
'sessions.group.new': '在此目录新建会话',
|
'sessions.group.new': '在此目录新建会话',
|
||||||
|
'sessions.machineFilter.label': '按机器筛选会话',
|
||||||
|
'sessions.machineFilter.all': '全部',
|
||||||
'codexSync.tooltip': '从 Codex 导入会话到 Hapi',
|
'codexSync.tooltip': '从 Codex 导入会话到 Hapi',
|
||||||
'codexSync.newSessionAction': '导入 Codex 历史',
|
'codexSync.newSessionAction': '导入 Codex 历史',
|
||||||
'codexSync.confirm.title': '导入 Codex 会话',
|
'codexSync.confirm.title': '导入 Codex 会话',
|
||||||
|
|||||||
+1
-7
@@ -232,9 +232,6 @@ function SessionsPage() {
|
|||||||
})()
|
})()
|
||||||
}, [addToast, refetch, t])
|
}, [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 machineLabelsById = useMemo(() => {
|
||||||
const labels: Record<string, string> = {}
|
const labels: Record<string, string> = {}
|
||||||
for (const machine of machines) {
|
for (const machine of machines) {
|
||||||
@@ -550,10 +547,7 @@ function SessionsPage() {
|
|||||||
style={{ '--sidebar-w': `${sidebar.width}px` } as React.CSSProperties}
|
style={{ '--sidebar-w': `${sidebar.width}px` } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
|
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
|
||||||
<div className="mx-auto w-full max-w-content flex items-center justify-between px-3 py-2">
|
<div className="mx-auto w-full max-w-content flex items-center justify-end px-3 py-2">
|
||||||
<div className="text-xs text-[var(--app-hint)]">
|
|
||||||
{t('sessions.count', { n: sessions.length, m: projectCount })}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user