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 (
{t('sessions.empty.title')}
{t('sessions.empty.hint')}
{props.onBrowse && ( )}
) } 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() 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() 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, group: { key: string; machineId: string | null } ): Map { 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() 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>(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 ( ) } function SearchIcon(props: { className?: string }) { return ( ) } function XIcon(props: { className?: string }) { return ( ) } function PlusIcon(props: { className?: string }) { return ( ) } function LoaderIcon(props: { className?: string }) { return ( ) } function BulbIcon(props: { className?: string }) { return ( ) } function ChevronIcon(props: { className?: string; collapsed?: boolean }) { return ( ) } 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() 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 ( ) } 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 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 (
{visibleMonth.toLocaleDateString(undefined, { year: 'numeric', month: 'long' })}
{weekdays.map((weekday, index) =>
{weekday}
)}
{Array.from({ length: firstWeekday }, (_, 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 ( ) })}
{!props.start ? t('sessions.timeFilter.pickStart') : !props.end ? t('sessions.timeFilter.pickEnd') : `${props.start} – ${props.end}`} {props.start ? ( ) : null}
) } function SessionListSearch(props: { value: string onChange: (value: string) => void customStart: string customEnd: string sessionActivityDates: ReadonlySet onDateRangeChange: (start: string, end: string) => void }) { const { t } = useTranslation() const [datePickerOpen, setDatePickerOpen] = useState(false) const hasDateRange = Boolean(props.customStart && props.customEnd) return (
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 ? ( ) : null}
{datePickerOpen ? ( <>
) } function formatCodexImportedRelativeTime( value: number, t: (key: string, params?: Record) => 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 | 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(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 ( <> 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 ? ( 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} setRenameOpen(false)} currentName={sessionName} onRename={renameSession} isPending={isPending} /> {exportOpen ? ( setExportOpen(false)} sessionId={s.id} api={api} /> ) : null} 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 /> 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 machinesById?: Record 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>( () => new Map() ) const autoExpandedSelectedSessionKeyRef = useRef(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>( () => 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() 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 (
{renderHeader ? (
{isFiltering ? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length }) : t('sessions.count', { n: allSessions.length, m: allGroups.length })}
) : null} {props.sessions.length > 0 ? ( { setCustomStart(start) setCustomEnd(end) }} /> ) : null} {props.sessions.length === 0 && ( )} {props.sessions.length > 0 && isFiltering && visibleSessions.length === 0 ? (
{t('sessions.search.noResults')}
) : null}
{machineGroups.map((mg) => { const machineCollapsed = isMachineCollapsed(mg) const machine = mg.machineId ? machinesById[mg.machineId] : undefined const healthPresentation = presentMachineHealth( machine?.health, getMachinePlatform(machine) ) return (
toggleMachine(mg)} machine={machine} healthPresentation={healthPresentation} /> {/* Level 2: Projects */}
{mg.projectGroups.map((group) => { const isCollapsed = isGroupCollapsed(group) const visibleGroupSessions = getVisibleGroupSessions(group) const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount) const canStartInGroupDirectory = group.directory !== 'Other' return (
toggleGroup(group.key, isCollapsed)} title={group.directory} > {group.displayName} {onNewSessionInDirectory && canStartInGroupDirectory ? ( ) : null} ({group.sessions.length})
{/* Level 3: Sessions */}
{visibleGroupSessions.map((s) => ( ))} {!isFiltering && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? ( ) : null}
) })}
) })}
) }