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,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')
})
})
+108
View File
@@ -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()
})
})
-95
View File
@@ -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()
})
})
+1 -129
View File
@@ -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()
})
})
+4 -8
View File
@@ -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)
+126 -150
View File
@@ -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) {
next.set(group.key, false) return overrides
changed = true
} }
const machineKey = `machine::${group.machineId ?? UNKNOWN_MACHINE_ID}` const next = new Map(overrides)
if (overrides.get(machineKey) !== false) { next.set(group.key, false)
next.set(machineKey, false) return next
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,113 +1253,110 @@ 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 {
machine?.health, id: mg.machineId ?? UNKNOWN_MACHINE_ID,
getMachinePlatform(machine) label: mg.label,
) sessionCount: mg.totalSessions,
healthPresentation: presentMachineHealth(
machine?.health,
getMachinePlatform(machine)
)
}
})}
totalCount={allSessions.length}
value={activeMachineFilter}
onChange={setMachineFilter}
/>
) : null}
<div className="flex flex-col gap-1 px-2 pt-1 pb-2">
{groups.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'
// 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={mg.machineId ?? UNKNOWN_MACHINE_ID}> <div key={group.key}>
<MachineGroupHeader <div
label={mg.label} className="group/project sticky top-0 z-10 flex items-center gap-2 px-1 py-1.5 text-left rounded-lg transition-colors hover:bg-[var(--app-subtle-bg)] cursor-pointer min-w-0 w-full select-none"
sessionCount={mg.totalSessions} onClick={() => toggleGroup(group.key, isCollapsed)}
collapsed={machineCollapsed} title={group.directory}
onToggle={() => toggleMachine(mg)} >
machine={machine} <ChevronIcon className="h-3.5 w-3.5 text-[var(--app-hint)] shrink-0" collapsed={isCollapsed} />
healthPresentation={healthPresentation} <span className="font-medium text-sm truncate flex-1">
/> {groupTitle}
</span>
<CopyPathButton path={group.directory} className="opacity-0 group-hover/project:opacity-100 transition-opacity duration-150" />
{onNewSessionInDirectory && canStartInGroupDirectory ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onNewSessionInDirectory({
machineId: group.machineId,
directory: group.directory
})
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] opacity-70 transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-link)] hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
title={t('sessions.group.new')}
aria-label={t('sessions.group.new')}
>
<PlusIcon className="h-3.5 w-3.5" />
</button>
) : null}
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">
({group.sessions.length})
</span>
</div>
{/* Level 2: Projects */} {/* Sessions */}
<div className="collapsible-panel" data-open={!machineCollapsed || undefined}> <div className="collapsible-panel" data-open={!isCollapsed || undefined}>
<div className="collapsible-inner"> <div className="collapsible-inner">
<div className="flex flex-col ml-3.5 pl-1 mt-0.5"> <div className="flex flex-col gap-0.5 ml-3 pl-1 py-1">
{mg.projectGroups.map((group) => { {visibleGroupSessions.map((s) => (
const isCollapsed = isGroupCollapsed(group) <SessionItem
const visibleGroupSessions = getVisibleGroupSessions(group) key={s.id}
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length session={s}
const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit onSelect={props.onSelect}
const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount) showPath={false}
const canStartInGroupDirectory = group.directory !== 'Other' api={api}
return ( selected={s.id === selectedSessionId}
<div key={group.key}> showDetailedStatus={showDetailedStatus}
<div />
className="group/project sticky top-0 z-10 flex items-center gap-2 px-1 py-1.5 text-left rounded-lg transition-colors hover:bg-[var(--app-subtle-bg)] cursor-pointer min-w-0 w-full select-none" ))}
onClick={() => toggleGroup(group.key, isCollapsed)} {!isFiltering && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (
title={group.directory} <button
> type="button"
<ChevronIcon className="h-3.5 w-3.5 text-[var(--app-hint)] shrink-0" collapsed={isCollapsed} /> onClick={() => hiddenSessionCount > 0
<span className="font-medium text-sm truncate flex-1"> ? showMoreSessions(group)
{group.displayName} : collapseSessionGroup(group)}
</span> className={cn(
<CopyPathButton path={group.directory} className="opacity-0 group-hover/project:opacity-100 transition-opacity duration-150" /> 'mx-2 my-1 rounded-md px-2 py-1 text-center text-xs text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]',
{onNewSessionInDirectory && canStartInGroupDirectory ? ( hiddenSessionCount > 0 && 'border border-dashed border-[var(--app-border)]'
<button )}
type="button" >
onClick={(event) => { {hiddenSessionCount > 0
event.stopPropagation() ? t('sessions.group.showMore', { n: showMoreCount })
onNewSessionInDirectory({ : t('sessions.group.showLess')}
machineId: group.machineId, </button>
directory: group.directory ) : null}
})
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] opacity-70 transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-link)] hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
title={t('sessions.group.new')}
aria-label={t('sessions.group.new')}
>
<PlusIcon className="h-3.5 w-3.5" />
</button>
) : null}
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">
({group.sessions.length})
</span>
</div>
{/* Level 3: Sessions */}
<div className="collapsible-panel" data-open={!isCollapsed || undefined}>
<div className="collapsible-inner">
<div className="flex flex-col gap-0.5 ml-3 pl-1 py-1">
{visibleGroupSessions.map((s) => (
<SessionItem
key={s.id}
session={s}
onSelect={props.onSelect}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
showDetailedStatus={showDetailedStatus}
/>
))}
{!isFiltering && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? (
<button
type="button"
onClick={() => hiddenSessionCount > 0
? showMoreSessions(group)
: collapseSessionGroup(group)}
className={cn(
'mx-2 my-1 rounded-md px-2 py-1 text-center text-xs text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]',
hiddenSessionCount > 0 && 'border border-dashed border-[var(--app-border)]'
)}
>
{hiddenSessionCount > 0
? t('sessions.group.showMore', { n: showMoreCount })
: t('sessions.group.showLess')}
</button>
) : null}
</div>
</div>
</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 }
}
+2 -2
View File
@@ -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',
+2 -2
View File
@@ -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
View File
@@ -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"