Files
hapi/web/src/components/SessionList.tsx
T

1393 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import type { SessionSummary } from '@/types/api'
import type { ApiClient } from '@/api/client'
import { useLongPress } from '@/hooks/useLongPress'
import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { SessionActionMenu } from '@/components/SessionActionMenu'
import { SessionExportDialog } from '@/components/SessionExportDialog'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { CopyIcon, CheckIcon, ScheduleIcon } from '@/components/icons'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip'
import { formatRelativeTime } from '@/lib/relativeTime'
import { formatScheduledTooltipDetail } from '@/lib/scheduledTime'
import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
import { formatReopenError } from '@/lib/reopenError'
import { getSessionTitle } from '@/lib/sessionTitle'
import type { Machine } from '@/types/api'
import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth'
import { MachineGroupHeader } from '@/components/MachineGroupHeader'
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
type SessionGroup = {
key: string
directory: string
displayName: string
machineId: string | null
sessions: SessionSummary[]
latestUpdatedAt: number
hasActiveSession: boolean
}
export type SessionTimeRange = {
start: number | null
end: number | null
}
function parseLocalDate(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null
return date
}
export function getSessionTimeRange(start: string, end: string): SessionTimeRange | null {
const startDate = parseLocalDate(start)
const endDate = parseLocalDate(end)
if (!startDate || !endDate) return null
if (endDate) endDate.setDate(endDate.getDate() + 1)
return { start: startDate.getTime(), end: endDate.getTime() }
}
export function sessionMatchesTimeRange(session: SessionSummary, range: SessionTimeRange | null): boolean {
if (!range) return true
if (range.start !== null && session.updatedAt < range.start) return false
if (range.end !== null && session.updatedAt >= range.end) return false
return true
}
function SessionsEmptyState(props: {
onNewSession: () => void
onBrowse?: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="44"
height="44"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--app-hint)] opacity-60"
>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 9h18" />
<path d="M8 14h8" />
<path d="M8 17h5" />
</svg>
<div className="text-base font-medium text-[var(--app-fg)]">
{t('sessions.empty.title')}
</div>
<div className="max-w-sm text-sm text-[var(--app-hint)]">
{t('sessions.empty.hint')}
</div>
<div className="flex items-center gap-2 mt-2">
<button
type="button"
onClick={props.onNewSession}
className="px-4 py-1.5 text-sm rounded-lg bg-[var(--app-button)] text-[var(--app-button-text)] font-medium hover:opacity-90 transition-opacity"
>
{t('sessions.empty.startSession')}
</button>
{props.onBrowse && (
<button
type="button"
onClick={props.onBrowse}
className="px-4 py-1.5 text-sm rounded-lg border border-[var(--app-border)] text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors"
>
{t('sessions.empty.browse')}
</button>
)}
</div>
</div>
)
}
type MachineGroup = {
machineId: string | null
label: string
projectGroups: SessionGroup[]
totalSessions: number
hasActiveSession: boolean
latestUpdatedAt: number
}
function getGroupDisplayName(directory: string): string {
if (directory === 'Other') return directory
const parts = directory.split(/[\\/]+/).filter(Boolean)
if (parts.length === 0) return directory
if (parts.length === 1) return parts[0]
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`
}
export const UNKNOWN_MACHINE_ID = '__unknown__'
export const GROUP_SESSION_PREVIEW_LIMIT = DEFAULT_SESSION_PREVIEW_LIMIT
export function getSessionDedupKey(session: SessionSummary): string | null {
const agentId = session.metadata?.agentSessionId?.trim()
if (!agentId) return null
// Scope by flavor: agentSessionId is flattened from native ids and can retain a
// stale cross-flavor value (codexSessionId ?? claudeSessionId ?? ...).
return `${session.metadata?.flavor ?? 'unknown'}:${agentId}`
}
export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
const byAgentId = new Map<string, SessionSummary[]>()
const result: SessionSummary[] = []
for (const session of sessions) {
const dedupKey = getSessionDedupKey(session)
if (!dedupKey) {
result.push(session)
continue
}
const group = byAgentId.get(dedupKey)
if (group) {
group.push(session)
} else {
byAgentId.set(dedupKey, [session])
}
}
for (const group of byAgentId.values()) {
group.sort((a, b) => {
// Active session always wins — it's the live connection
if (a.active !== b.active) return a.active ? -1 : 1
// Among inactive duplicates, keep the selected one visible
if (a.id === selectedSessionId) return -1
if (b.id === selectedSessionId) return 1
return b.updatedAt - a.updatedAt
})
result.push(group[0])
}
return result
}
function hasSidebarTitleSignal(session: SessionSummary): boolean {
const meta = session.metadata
if (!meta) return false
if (meta.name?.trim()) return true
if (meta.summary?.text?.trim()) return true
return false
}
export function isSidebarEmptySessionStub(session: SessionSummary): boolean {
if (session.active) return false
const meta = session.metadata
if (!meta) return true
if (meta.agentSessionId?.trim()) return false
if (hasSidebarTitleSignal(session)) return false
return true
}
export function shouldShowSessionInSidebar(session: SessionSummary, selectedSessionId?: string | null): boolean {
if (session.id === selectedSessionId) return true
if (session.active) return true
return !isSidebarEmptySessionStub(session)
}
export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
return deduplicateSessionsByAgentId(sessions, selectedSessionId)
.filter(session => shouldShowSessionInSidebar(session, selectedSessionId))
}
// "Active sessions only" view: hide inactive sessions, but never hide the one the
// operator currently has open — otherwise toggling the filter would yank the
// selected session out from under them.
export function filterActiveSessionsOnly(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
return sessions.filter(session => session.active || session.id === selectedSessionId)
}
// Paginated "Show N more": reveal one batch (step) at a time instead of expanding
// every hidden session at once. Always advances by at least one and never exceeds
// the total so the button reliably reaches a fully-expanded state.
export function getNextSessionVisibleCount(current: number, step: number, total: number): number {
return Math.min(current + Math.max(1, step), total)
}
function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] {
const groups = new Map<string, { directory: string; machineId: string | null; sessions: SessionSummary[] }>()
sessions.forEach(session => {
const path = session.metadata?.worktree?.basePath ?? session.metadata?.path ?? 'Other'
const machineId = session.metadata?.machineId ?? null
const key = `${machineId ?? UNKNOWN_MACHINE_ID}::${path}`
if (!groups.has(key)) {
groups.set(key, {
directory: path,
machineId,
sessions: []
})
}
groups.get(key)!.sessions.push(session)
})
return Array.from(groups.entries())
.map(([key, group]) => {
const sortedSessions = [...group.sessions].sort((a, b) => {
const rankA = a.active ? (a.pendingRequestsCount > 0 ? 0 : 1) : 2
const rankB = b.active ? (b.pendingRequestsCount > 0 ? 0 : 1) : 2
if (rankA !== rankB) return rankA - rankB
return b.updatedAt - a.updatedAt
})
const latestUpdatedAt = group.sessions.reduce(
(max, s) => (s.updatedAt > max ? s.updatedAt : max),
-Infinity
)
const hasActiveSession = group.sessions.some(s => s.active)
const displayName = getGroupDisplayName(group.directory)
return {
key,
directory: group.directory,
displayName,
machineId: group.machineId,
sessions: sortedSessions,
latestUpdatedAt,
hasActiveSession
}
})
.sort((a, b) => {
if (a.hasActiveSession !== b.hasActiveSession) {
return a.hasActiveSession ? -1 : 1
}
return b.latestUpdatedAt - a.latestUpdatedAt
})
}
export function expandSelectedSessionCollapseOverrides(
overrides: Map<string, boolean>,
group: { key: string; machineId: string | null }
): Map<string, boolean> {
const next = new Map(overrides)
let changed = false
// Expand project group if collapsed. Project and machine keys use true = collapsed.
if (overrides.has(group.key) && overrides.get(group.key)) {
next.delete(group.key)
changed = true
}
const machineKey = `machine::${group.machineId ?? UNKNOWN_MACHINE_ID}`
if (overrides.has(machineKey) && overrides.get(machineKey)) {
next.delete(machineKey)
changed = true
}
return changed ? next : overrides
}
function groupByMachine(
groups: SessionGroup[],
resolveMachineLabel: (id: string | null) => string
): MachineGroup[] {
const map = new Map<string, MachineGroup>()
for (const g of groups) {
const key = g.machineId ?? UNKNOWN_MACHINE_ID
let mg = map.get(key)
if (!mg) {
mg = {
machineId: g.machineId,
label: resolveMachineLabel(g.machineId),
projectGroups: [],
totalSessions: 0,
hasActiveSession: false,
latestUpdatedAt: 0,
}
map.set(key, mg)
}
mg.projectGroups.push(g)
mg.totalSessions += g.sessions.length
if (g.hasActiveSession) mg.hasActiveSession = true
if (g.latestUpdatedAt > mg.latestUpdatedAt) mg.latestUpdatedAt = g.latestUpdatedAt
}
return [...map.values()].sort((a, b) => {
if (a.hasActiveSession !== b.hasActiveSession) return a.hasActiveSession ? -1 : 1
return b.latestUpdatedAt - a.latestUpdatedAt
})
}
function CopyPathButton({ path, className }: { path: string; className?: string }) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(path)
setCopied(true)
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 1500)
}
useEffect(() => () => clearTimeout(timerRef.current), [])
return (
<button
type="button"
className={`shrink-0 p-0.5 rounded transition-colors ${copied ? 'text-[var(--app-badge-success-text)]' : 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'} ${className ?? ''}`}
title={copied ? 'Copied!' : `Copy: ${path}`}
onClick={handleClick}
>
{copied
? <CheckIcon className="h-3.5 w-3.5" />
: <CopyIcon className="h-3.5 w-3.5" />
}
</button>
)
}
function SearchIcon(props: { className?: string }) {
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={props.className}
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
)
}
function XIcon(props: { className?: string }) {
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={props.className}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
)
}
function PlusIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
}
function LoaderIcon(props: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
</svg>
)
}
function BulbIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12c.6.6 1 1.2 1 2h6c0-.8.4-1.4 1-2a7 7 0 0 0-4-12Z" />
</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={`${props.className ?? ''} transition-transform duration-200 ${props.collapsed ? '' : 'rotate-90'}`}
>
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
export { getSessionTitle } from '@/lib/sessionTitle'
export function getWorktreeSessionLabel(session: SessionSummary): string | null {
const worktree = session.metadata?.worktree
if (!worktree) {
return null
}
const name = worktree.name.trim()
if (name) {
return name
}
const path = (worktree.worktreePath ?? session.metadata?.path ?? '').replace(/[\\/]+$/, '')
const parts = path.split(/[\\/]+/).filter(Boolean)
return parts.at(-1) ?? null
}
function getTodoProgress(session: SessionSummary): { completed: number; total: number } | null {
if (!session.todoProgress) return null
if (session.todoProgress.completed === session.todoProgress.total) return null
return session.todoProgress
}
export function normalizeSearch(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase()
}
export function sessionMatchesQuery(session: SessionSummary, query: string, machineLabel: string): boolean {
if (!query) return true
const searchable = [
getSessionTitle(session),
getWorktreeSessionLabel(session),
session.id,
session.metadata?.path,
session.metadata?.worktree?.basePath,
session.metadata?.worktree?.worktreePath,
session.metadata?.name,
session.metadata?.summary?.text,
session.metadata?.flavor,
machineLabel,
]
.filter((part): part is string => typeof part === 'string' && part.length > 0)
.join('\n')
.toLowerCase()
return searchable.includes(query)
}
export function getVisibleSessionPreview(
sessions: SessionSummary[],
options: {
expanded?: boolean
selectedSessionId?: string | null
limit?: number
} = {}
): SessionSummary[] {
const limit = options.limit ?? GROUP_SESSION_PREVIEW_LIMIT
if (options.expanded || sessions.length <= limit) return sessions
const requiredIds = new Set<string>()
for (const session of sessions) {
if (session.pendingRequestsCount > 0) requiredIds.add(session.id)
}
if (options.selectedSessionId && sessions.some(session => session.id === options.selectedSessionId)) {
requiredIds.add(options.selectedSessionId)
}
const visible: SessionSummary[] = sessions.filter((session, index) => {
return index < limit || requiredIds.has(session.id)
})
for (let index = visible.length - 1; visible.length > limit && index >= 0; index -= 1) {
const session = visible[index]
if (!session || requiredIds.has(session.id)) continue
visible.splice(index, 1)
}
return visible
}
function CalendarIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18" />
</svg>
)
}
function formatDateValue(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function SessionDateRangePicker(props: {
start: string
end: string
sessionActivityDates: ReadonlySet<string>
onChange: (start: string, end: string) => void
onClose: () => void
}) {
const { t } = useTranslation()
const initialDate = parseLocalDate(props.start) ?? new Date()
const [visibleMonth, setVisibleMonth] = useState(() => new Date(initialDate.getFullYear(), initialDate.getMonth(), 1))
const firstWeekday = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1).getDay()
const daysInMonth = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 0).getDate()
const weekdays = Array.from({ length: 7 }, (_, day) => (
new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(new Date(2026, 5, 7 + day))
))
const selectDate = (value: string) => {
if (!props.start || props.end) {
props.onChange(value, '')
return
}
props.onChange(value < props.start ? value : props.start, value < props.start ? props.start : value)
props.onClose()
}
return (
<div className="absolute right-0 top-full z-30 mt-2 w-72 rounded-xl border border-[var(--app-border)] bg-[var(--app-bg)] p-3 shadow-xl">
<div className="mb-2 flex items-center justify-between">
<button
type="button"
onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1))}
className="rounded-lg p-1.5 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
aria-label={t('sessions.timeFilter.previousMonth')}
>
<span aria-hidden="true"></span>
</button>
<div className="text-sm font-medium">
{visibleMonth.toLocaleDateString(undefined, { year: 'numeric', month: 'long' })}
</div>
<button
type="button"
onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1))}
className="rounded-lg p-1.5 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
aria-label={t('sessions.timeFilter.nextMonth')}
>
<span aria-hidden="true"></span>
</button>
</div>
<div className="mb-1 grid grid-cols-7 text-center text-[10px] text-[var(--app-hint)]">
{weekdays.map((weekday, index) => <div key={`${weekday}-${index}`} className="py-1">{weekday}</div>)}
</div>
<div className="grid grid-cols-7 gap-0.5">
{Array.from({ length: firstWeekday }, (_, index) => <div key={`blank-${index}`} />)}
{Array.from({ length: daysInMonth }, (_, index) => {
const date = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index + 1)
const value = formatDateValue(date)
const isEndpoint = value === props.start || value === props.end
const isInRange = Boolean(props.start && props.end && value > props.start && value < props.end)
const hasSessionActivity = props.sessionActivityDates.has(value)
const dateLabel = date.toLocaleDateString()
const activityLabel = hasSessionActivity
? t('sessions.timeFilter.dayWithActivity', { date: dateLabel })
: dateLabel
return (
<button
key={value}
type="button"
onClick={() => selectDate(value)}
aria-label={activityLabel}
title={hasSessionActivity ? activityLabel : undefined}
className={cn(
'h-8 rounded-lg text-xs transition-colors',
isEndpoint && 'bg-[var(--app-link)] text-white',
isInRange && 'bg-[var(--app-link)]/15 text-[var(--app-link)]',
!isEndpoint && !isInRange && hasSessionActivity && 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]',
!isEndpoint && !isInRange && !hasSessionActivity && 'text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)]'
)}
>
{index + 1}
</button>
)
})}
</div>
<div className="mt-2 flex items-center justify-between border-t border-[var(--app-divider)] pt-2 text-xs">
<span className="text-[var(--app-hint)]">
{!props.start
? t('sessions.timeFilter.pickStart')
: !props.end
? t('sessions.timeFilter.pickEnd')
: `${props.start} ${props.end}`}
</span>
{props.start ? (
<button type="button" onClick={() => props.onChange('', '')} className="text-[var(--app-link)]">
{t('sessions.timeFilter.clear')}
</button>
) : null}
</div>
</div>
)
}
function SessionListSearch(props: {
value: string
onChange: (value: string) => void
customStart: string
customEnd: string
sessionActivityDates: ReadonlySet<string>
onDateRangeChange: (start: string, end: string) => void
}) {
const { t } = useTranslation()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const hasDateRange = Boolean(props.customStart && props.customEnd)
return (
<div className="px-2 pb-2">
<div className="relative min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-[var(--app-hint)]">
<SearchIcon className="h-3.5 w-3.5" />
</div>
<input
type="search"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
placeholder={t('sessions.search.placeholder')}
className="w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 pr-16 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
/>
{props.value ? (
<button
type="button"
onClick={() => props.onChange('')}
className="absolute inset-y-0 right-9 flex items-center rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
title={t('sessions.search.clear')}
>
<XIcon className="h-3.5 w-3.5" />
</button>
) : null}
<div className="absolute inset-y-0 right-0 flex items-stretch">
<button
type="button"
onClick={() => setDatePickerOpen(open => !open)}
className={cn(
'relative flex items-center rounded-r-lg rounded-l-md px-2 transition-colors hover:bg-[var(--app-subtle-bg)]',
hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)]'
)}
title={hasDateRange ? `${props.customStart} ${props.customEnd}` : t('sessions.timeFilter.label')}
aria-label={t('sessions.timeFilter.label')}
aria-expanded={datePickerOpen}
>
<CalendarIcon className="h-5 w-5" />
{hasDateRange ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--app-link)]" /> : null}
</button>
{datePickerOpen ? (
<>
<button type="button" aria-label={t('sessions.timeFilter.close')} className="fixed inset-0 z-20 cursor-default" onClick={() => setDatePickerOpen(false)} />
<SessionDateRangePicker
start={props.customStart}
end={props.customEnd}
sessionActivityDates={props.sessionActivityDates}
onChange={props.onDateRangeChange}
onClose={() => setDatePickerOpen(false)}
/>
</>
) : null}
</div>
</div>
</div>
)
}
function formatCodexImportedRelativeTime(
value: number,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
if (!Number.isFinite(ms)) return null
const delta = Date.now() - ms
if (delta < 60_000) return t('session.time.importedFromCodex.justNow')
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) return t('session.time.importedFromCodex.minutesAgo', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return t('session.time.importedFromCodex.hoursAgo', { n: hours })
const days = Math.floor(hours / 24)
if (days < 7) return t('session.time.importedFromCodex.daysAgo', { n: days })
return formatRelativeTime(value, t)
}
function getSessionTimeLabel(
session: SessionSummary,
t: (key: string, params?: Record<string, string | number>) => string
): string | null {
const codexSessionId = session.metadata?.agentSessionId
const importedAt = session.metadata?.flavor === 'codex'
? getCodexImportedAt(codexSessionId)
: null
// 中文注释:导入标记存在时优先显示“xx 前从 Codex 客户端导入”;等用户在 Hapi 里继续发消息后,再由发送逻辑清除该标记。
if (importedAt !== null) {
return formatCodexImportedRelativeTime(importedAt, t)
}
return formatRelativeTime(session.updatedAt, t)
}
function SessionItem(props: {
session: SessionSummary
onSelect: (sessionId: string) => void
showPath?: boolean
api: ApiClient | null
selected?: boolean
showDetailedStatus?: boolean
}) {
const { t } = useTranslation()
const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const [renameOpen, setRenameOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const {
status: cursorChatStoreStatus,
isApplicable: cursorChatStoreApplicable,
error: cursorChatStoreError,
} = useCursorChatStoreStatus({
api,
session: s,
enabled: menuOpen
})
const cursorReopenDisabledReason = cursorChatStoreApplicable && cursorChatStoreStatus?.onDisk !== true
? cursorChatStoreError
? t('session.action.reopenCursorCheckFailed')
: cursorChatStoreStatus?.onDisk === false
? t('session.action.reopenCursorMissing')
: t('session.action.reopenCursorChecking')
: undefined
const { archiveSession, reopenSession, renameSession, deleteSession, isPending } = useSessionActions(
api,
s.id,
s.metadata?.flavor ?? null
)
const [reopenError, setReopenError] = useState<string | null>(null)
const handleReopen = async () => {
setReopenError(null)
try {
const result = await reopenSession()
// resumeSession may merge the row into a freshly-spawned sessionId.
// Follow it so the operator lands on the live session.
if (result.sessionId && result.sessionId !== s.id) {
onSelect(result.sessionId)
}
} catch (error) {
setReopenError(formatReopenError(error))
}
}
const longPressHandlers = useLongPress({
onLongPress: (point) => {
haptic.impact('medium')
setMenuAnchorPoint(point)
setMenuOpen(true)
},
onClick: () => {
if (!menuOpen) {
onSelect(s.id)
}
},
threshold: 500
})
const sessionName = getSessionTitle(s)
const worktreeLabel = getWorktreeSessionLabel(s)
const todoProgress = getTodoProgress(s)
const attention = useMemo(
() => showDetailedStatus
? classifySessionAttention(s, {
selected,
lastSeenAt: getSessionLastSeenAt(s.id)
})
: null,
[s, selected, showDetailedStatus]
)
const attentionLabel = attention ? getAttentionLabel(attention, t) : null
const scheduledLabel = s.futureScheduledMessageCount > 1
? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount })
: t('session.item.scheduledMessage')
const hasScheduleTooltip = showDetailedStatus && s.futureScheduledMessageCount > 0
const { attentionId, scheduleId, describedBy } = useSessionRowTooltipIds(
Boolean(attention),
hasScheduleTooltip
)
return (
<>
<button
type="button"
{...longPressHandlers}
className={`session-list-item group/session-row flex w-full flex-col gap-1 px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none rounded-lg ${selected ? 'bg-[var(--app-secondary-bg)]' : ''}`}
style={{ WebkitTouchCallout: 'none' }}
aria-current={selected ? 'page' : undefined}
aria-describedby={describedBy}
>
<div className={`flex items-center justify-between gap-3 ${!s.active ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-2 min-w-0">
<AgentFlavorIcon flavor={s.metadata?.flavor} className="h-4 w-4 shrink-0" />
<div className={`truncate text-sm font-medium ${s.active ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}>
{sessionName}
</div>
{s.active && s.thinking ? (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 text-[var(--app-hint)] animate-spin-slow" />
) : attention ? (
<SessionAttentionIndicator
attention={attention}
summary={s}
label={attentionLabel ?? ''}
tooltipId={attentionId!}
/>
) : null}
{hasScheduleTooltip ? (
<HoverTooltip
id={scheduleId!}
target={<ScheduleIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
side="bottom"
align="start"
className="shrink-0"
revealOnParentFocusClass={SESSION_ROW_TOOLTIP_FOCUS_CLASS}
>
<span className="block">
<span className="block font-medium">{scheduledLabel}</span>
<span className="mt-1 block text-[var(--app-hint)]">
{formatScheduledTooltipDetail(s, t)}
</span>
</span>
</HoverTooltip>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{todoProgress ? (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
{todoProgress.completed}/{todoProgress.total}
</span>
) : null}
{!attention && s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
{t('session.item.pending')} {s.pendingRequestsCount}
</span>
) : null}
<span className="tabular-nums text-[var(--app-hint)]">
{getSessionTimeLabel(s, t)}
</span>
</div>
</div>
{showPath || worktreeLabel ? (
<div
className="truncate text-xs text-[var(--app-hint)]"
title={worktreeLabel
? s.metadata?.worktree?.worktreePath ?? s.metadata?.path
: undefined}
>
{worktreeLabel ?? s.metadata?.path ?? s.id}
</div>
) : null}
</button>
<SessionActionMenu
isOpen={menuOpen}
onClose={() => setMenuOpen(false)}
sessionId={s.id}
sessionTitle={sessionName}
sessionActive={s.active}
onRename={() => setRenameOpen(true)}
onExport={() => setExportOpen(true)}
onArchive={() => setArchiveOpen(true)}
onReopen={cursorReopenDisabledReason ? undefined : handleReopen}
reopenDisabledReason={cursorReopenDisabledReason}
onDelete={() => setDeleteOpen(true)}
anchorPoint={menuAnchorPoint}
/>
{reopenError ? (
<ConfirmDialog
isOpen={true}
onClose={() => setReopenError(null)}
title={t('dialog.reopen.errorTitle')}
description={reopenError}
confirmLabel={t('dialog.reopen.dismiss')}
confirmingLabel={t('dialog.reopen.dismiss')}
onConfirm={async () => setReopenError(null)}
isPending={false}
/>
) : null}
<RenameSessionDialog
isOpen={renameOpen}
onClose={() => setRenameOpen(false)}
currentName={sessionName}
onRename={renameSession}
isPending={isPending}
/>
{exportOpen ? (
<SessionExportDialog
isOpen={true}
onClose={() => setExportOpen(false)}
sessionId={s.id}
api={api}
/>
) : null}
<ConfirmDialog
isOpen={archiveOpen}
onClose={() => setArchiveOpen(false)}
title={t('dialog.archive.title')}
description={t('dialog.archive.description', { name: sessionName })}
confirmLabel={t('dialog.archive.confirm')}
confirmingLabel={t('dialog.archive.confirming')}
onConfirm={archiveSession}
isPending={isPending}
destructive
/>
<ConfirmDialog
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
title={t('dialog.delete.title')}
description={t('dialog.delete.description', { name: sessionName })}
confirmLabel={t('dialog.delete.confirm')}
confirmingLabel={t('dialog.delete.confirming')}
onConfirm={deleteSession}
isPending={isPending}
destructive
/>
</>
)
}
export function SessionList(props: {
sessions: SessionSummary[]
onSelect: (sessionId: string) => void
onNewSession: () => void
onNewSessionInDirectory?: (args: { machineId: string | null; directory: string }) => void
onBrowse?: () => void
onRefresh: () => void
isLoading: boolean
renderHeader?: boolean
api: ApiClient | null
machineLabelsById?: Record<string, string>
machinesById?: Record<string, Machine>
selectedSessionId?: string | null
}) {
const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, machinesById = {}, onNewSessionInDirectory } = props
const { sessionPreviewLimit } = useSessionPreviewLimit()
const { sessionListStatusMode } = useSessionListStatusMode()
const { showActiveSessionsOnly } = useShowActiveSessionsOnly()
const showDetailedStatus = sessionListStatusMode === 'detailed'
const [searchQuery, setSearchQuery] = useState('')
const [customStart, setCustomStart] = useState('')
const [customEnd, setCustomEnd] = useState('')
const [, setCodexImportedSessionsVersion] = useState(0)
const normalizedQuery = normalizeSearch(searchQuery)
const timeRange = getSessionTimeRange(customStart, customEnd)
const isFiltering = normalizedQuery.length > 0 || timeRange !== null
useEffect(() => {
// 中文注释:监听导入标记变化,让列表在“导入完成”或“用户已在 Hapi 中继续会话”后立即刷新时间文案。
return subscribeCodexImportedSessions(() => {
setCodexImportedSessionsVersion((value) => value + 1)
})
}, [])
const resolveMachineLabel = (machineId: string | null): string => {
if (machineId && machineLabelsById[machineId]) {
return machineLabelsById[machineId]
}
if (machineId) {
return machineId.slice(0, 8)
}
return t('machine.unknown')
}
const allSessions = useMemo(
() => {
const prepared = prepareSidebarSessions(props.sessions, selectedSessionId)
return showActiveSessionsOnly ? filterActiveSessionsOnly(prepared, selectedSessionId) : prepared
},
[props.sessions, selectedSessionId, showActiveSessionsOnly]
)
const sessionActivityDates = useMemo(
() => new Set(allSessions.map(session => formatDateValue(new Date(session.updatedAt)))),
[allSessions]
)
const visibleSessions = useMemo(
() => isFiltering
? allSessions.filter(session => (
sessionMatchesTimeRange(session, timeRange)
&& sessionMatchesQuery(
session,
normalizedQuery,
resolveMachineLabel(session.metadata?.machineId ?? null)
)
))
: allSessions,
[allSessions, isFiltering, normalizedQuery, timeRange?.start, timeRange?.end, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
)
const allGroups = useMemo(
() => groupSessionsByDirectory(allSessions),
[allSessions]
)
const groups = useMemo(
() => groupSessionsByDirectory(visibleSessions),
[visibleSessions]
)
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()
)
const autoExpandedSelectedSessionKeyRef = useRef<string | null>(null)
const isGroupCollapsed = (group: SessionGroup): boolean => {
if (isFiltering) return false
const override = collapseOverrides.get(group.key)
if (override !== undefined) return override
const hasSelectedSession = selectedSessionId
? group.sessions.some(session => session.id === selectedSessionId)
: false
return !group.hasActiveSession && !hasSelectedSession
}
const toggleGroup = (groupKey: string, isCollapsed: boolean) => {
setCollapseOverrides(prev => {
const next = new Map(prev)
next.set(groupKey, !isCollapsed)
return next
})
}
// Per-group reveal cap for paginated "Show N more". Absent = collapsed to the
// preview limit; each "Show more" bumps it by one batch (step = preview limit).
const [sessionVisibleCounts, setSessionVisibleCounts] = useState<Map<string, number>>(
() => new Map()
)
const getGroupVisibleCount = (group: SessionGroup): number => {
return sessionVisibleCounts.get(group.key) ?? sessionPreviewLimit
}
const showMoreSessions = (group: SessionGroup) => {
setSessionVisibleCounts(prev => {
const next = new Map(prev)
const current = prev.get(group.key) ?? sessionPreviewLimit
next.set(group.key, getNextSessionVisibleCount(current, sessionPreviewLimit, group.sessions.length))
return next
})
}
const collapseSessionGroup = (group: SessionGroup) => {
setSessionVisibleCounts(prev => {
if (!prev.has(group.key)) return prev
const next = new Map(prev)
next.delete(group.key)
return next
})
}
const getVisibleGroupSessions = (group: SessionGroup): SessionSummary[] => {
return getVisibleSessionPreview(
group.sessions,
{
expanded: isFiltering,
selectedSessionId,
limit: getGroupVisibleCount(group)
}
)
}
const machineGroups = useMemo(
() => groupByMachine(groups, resolveMachineLabel),
[groups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
)
const isMachineCollapsed = (mg: MachineGroup): boolean => {
if (isFiltering) return false
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
const override = collapseOverrides.get(key)
if (override !== undefined) return override
const hasSelected = selectedSessionId
? mg.projectGroups.some(pg => pg.sessions.some(s => s.id === selectedSessionId))
: false
return !mg.hasActiveSession && !hasSelected
}
const toggleMachine = (mg: MachineGroup) => {
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
const current = isMachineCollapsed(mg)
setCollapseOverrides(prev => {
const next = new Map(prev)
next.set(key, !current)
return next
})
}
// Auto-expand group (and machine) containing the selected session only when
// the selected-session/group pair changes. Without this guard, every live
// session-list refresh (for example tool-call updates from a running selected
// session) reopens a path the user just collapsed.
useEffect(() => {
if (!selectedSessionId) {
autoExpandedSelectedSessionKeyRef.current = null
return
}
const group = allGroups.find(g =>
g.sessions.some(s => s.id === selectedSessionId)
)
if (!group) return
const autoExpandKey = `${selectedSessionId}::${group.key}`
if (autoExpandedSelectedSessionKeyRef.current === autoExpandKey) return
autoExpandedSelectedSessionKeyRef.current = autoExpandKey
setCollapseOverrides(prev => expandSelectedSessionCollapseOverrides(prev, group))
}, [selectedSessionId, allGroups])
// Clean up stale collapse overrides
useEffect(() => {
setCollapseOverrides(prev => {
if (prev.size === 0) return prev
const next = new Map(prev)
const knownKeys = new Set<string>()
for (const g of allGroups) {
knownKeys.add(g.key)
knownKeys.add(`sessions::${g.key}`)
knownKeys.add(`machine::${g.machineId ?? UNKNOWN_MACHINE_ID}`)
}
let changed = false
for (const key of next.keys()) {
if (!knownKeys.has(key)) {
next.delete(key)
changed = true
}
}
return changed ? next : prev
})
}, [allGroups])
// Clean up reveal caps for groups that no longer exist.
useEffect(() => {
setSessionVisibleCounts(prev => {
if (prev.size === 0) return prev
const knownKeys = new Set(allGroups.map(g => g.key))
const next = new Map(prev)
let changed = false
for (const key of next.keys()) {
if (!knownKeys.has(key)) {
next.delete(key)
changed = true
}
}
return changed ? next : prev
})
}, [allGroups])
return (
<div className="mx-auto w-full max-w-content flex flex-col">
{renderHeader ? (
<div className="flex items-center justify-between 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
type="button"
onClick={props.onNewSession}
className="session-list-new-button p-1.5 rounded-full text-[var(--app-link)] transition-colors"
title={t('sessions.new')}
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
) : null}
{props.sessions.length > 0 ? (
<SessionListSearch
value={searchQuery}
onChange={setSearchQuery}
customStart={customStart}
customEnd={customEnd}
sessionActivityDates={sessionActivityDates}
onDateRangeChange={(start, end) => {
setCustomStart(start)
setCustomEnd(end)
}}
/>
) : null}
{props.sessions.length === 0 && (
<SessionsEmptyState
onNewSession={props.onNewSession}
onBrowse={props.onBrowse}
/>
)}
{props.sessions.length > 0 && isFiltering && visibleSessions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
{t('sessions.search.noResults')}
</div>
) : null}
<div className="flex flex-col gap-3 px-2 pt-1 pb-2">
{machineGroups.map((mg) => {
const machineCollapsed = isMachineCollapsed(mg)
const machine = mg.machineId ? machinesById[mg.machineId] : undefined
const healthPresentation = presentMachineHealth(
machine?.health,
getMachinePlatform(machine)
)
return (
<div key={mg.machineId ?? UNKNOWN_MACHINE_ID}>
<MachineGroupHeader
label={mg.label}
sessionCount={mg.totalSessions}
collapsed={machineCollapsed}
onToggle={() => toggleMachine(mg)}
machine={machine}
healthPresentation={healthPresentation}
/>
{/* Level 2: Projects */}
<div className="collapsible-panel" data-open={!machineCollapsed || undefined}>
<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 visibleGroupSessions = getVisibleGroupSessions(group)
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length
const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit
const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount)
const canStartInGroupDirectory = group.directory !== 'Other'
return (
<div key={group.key}>
<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)}
title={group.directory}
>
<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">
{group.displayName}
</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 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>
)
}