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 } from '@/components/icons' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' import { classifySessionAttention } from '@/lib/sessionAttention' import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { useSessionRowTooltipIds } from '@/components/HoverTooltip' import { subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' import { getSessionTitle } from '@/lib/sessionTitle' import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel' import type { Machine } from '@/types/api' import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth' import { MachineFilterBar, MachineFilterMenu } from '@/components/MachineFilterBar' import { useSessionListMachineFilter } from '@/hooks/useSessionListMachineFilter' import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus' import { SessionRowSummary } from '@/components/SessionRowSummary' import { Spinner } from '@/components/Spinner' export { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel' 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 session previews move one batch at a time in either direction. // Counts always stay within the configured preview floor and the group total. export function getNextSessionVisibleCount(current: number, step: number, total: number): number { return Math.min(current + Math.max(1, step), total) } export function getPreviousSessionVisibleCount(current: number, step: number): number { const normalizedStep = Math.max(1, step) return Math.max(normalizedStep, current - normalizedStep) } 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 } ): Map { // Keep auto-expanded paths open after selection moves so content above the // clicked row does not collapse and displace the sidebar viewport. if (overrides.get(group.key) === false) { return overrides } const next = new Map(overrides) next.set(group.key, false) return next } 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 ChevronIcon(props: { className?: string; collapsed?: boolean }) { return ( ) } function SessionPreviewArrowIcon(props: { direction: 'up' | 'down'; className?: string }) { return ( ) } export { getSessionTitle } from '@/lib/sessionTitle' 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 onClear: () => 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 today = formatDateValue(new Date()) 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 isToday = value === today 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 expanded: boolean onExpandedChange: (expanded: boolean) => void }) { const { t } = useTranslation() const [datePickerOpen, setDatePickerOpen] = useState(false) const inputRef = useRef(null) const hasDateRange = Boolean(props.customStart && props.customEnd) const hasActiveFilters = props.value.length > 0 || hasDateRange useEffect(() => { if (props.expanded) { inputRef.current?.focus() } else { setDatePickerOpen(false) } }, [props.expanded]) if (!props.expanded) { return ( ) } return (
{ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { props.onExpandedChange(false) } }} >
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 SessionItem(props: { session: SessionSummary onSelect: (sessionId: string) => void showPath?: boolean api: ApiClient | null selected?: boolean showDetailedStatus?: boolean inRunningSection?: boolean projectLabel?: string }) { const { t } = useTranslation() const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false, inRunningSection = false, projectLabel } = 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 attention = useMemo( () => showDetailedStatus ? classifySessionAttention(s, { selected, lastSeenAt: getSessionLastSeenAt(s.id) }) : null, [s, selected, showDetailedStatus] ) 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} centerTitle /> ) : null} {renameOpen ? ( setRenameOpen(false)} currentName={sessionName} onRename={renameSession} isPending={isPending} /> ) : null} {exportOpen ? ( setExportOpen(false)} sessionId={s.id} api={api} /> ) : null} {archiveOpen ? ( 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 centerTitle /> ) : null} {deleteOpen ? ( 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 centerTitle /> ) : null} ) } type PullToRefreshState = 'idle' | 'pulling' | 'ready' const PULL_REFRESH_FEEDBACK_PX = 16 const PULL_REFRESH_TRIGGER_PX = 64 export function getPullToRefreshState(distancePx: number): PullToRefreshState { if (distancePx >= PULL_REFRESH_TRIGGER_PX) { return 'ready' } if (distancePx >= PULL_REFRESH_FEEDBACK_PX) { return 'pulling' } return 'idle' } export function SessionList(props: { sessions: SessionSummary[] onSelect: (sessionId: string) => void onNewSession: () => void onNewSessionInDirectory?: (args: { machineId: string | null; directory: string }) => void onBrowse?: () => void onRefresh: () => Promise | void isLoading: boolean renderHeader?: boolean headerActions?: React.ReactNode 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 { machineFilter, setMachineFilter } = useSessionListMachineFilter() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') const [searchExpanded, setSearchExpanded] = useState(false) 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 machineFilters = useMemo( () => groupByMachine(allGroups, resolveMachineLabel), [allGroups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps ) const machineFilterItems = useMemo( () => machineFilters.map((mg) => { const machine = mg.machineId ? machinesById[mg.machineId] : undefined return { id: mg.machineId ?? UNKNOWN_MACHINE_ID, label: mg.label, sessionCount: mg.totalSessions, healthPresentation: presentMachineHealth( machine?.health, getMachinePlatform(machine) ) } }), [machineFilters, machinesById] ) 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 runningSessions = useMemo( () => machineFilteredSessions .filter((session) => session.active) .sort((a, b) => b.updatedAt - a.updatedAt), [machineFilteredSessions] ) const groups = useMemo( () => groupSessionsByDirectory(machineFilteredSessions.filter((session) => !session.active)), [machineFilteredSessions] ) 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 session previews. Absent = the configured // preview limit; expand/collapse controls move the cap by one preview-sized batch. 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 currentLimit = Math.min( prev.get(group.key) ?? sessionPreviewLimit, group.sessions.length ) const currentVisibleCount = getVisibleSessionPreview(group.sessions, { selectedSessionId, limit: currentLimit }).length next.set(group.key, getNextSessionVisibleCount( Math.max(currentLimit, currentVisibleCount), sessionPreviewLimit, group.sessions.length )) return next }) } const showFewerSessions = (group: SessionGroup) => { setSessionVisibleCounts(prev => { const next = new Map(prev) const current = Math.min( prev.get(group.key) ?? sessionPreviewLimit, group.sessions.length ) const previous = getPreviousSessionVisibleCount(current, sessionPreviewLimit) if (previous <= sessionPreviewLimit) { next.delete(group.key) } else { next.set(group.key, previous) } return next }) } const getVisibleGroupSessions = (group: SessionGroup): SessionSummary[] => { return getVisibleSessionPreview( group.sessions, { selectedSessionId, limit: getGroupVisibleCount(group) } ) } // Auto-expand group 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}`) } 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]) // The search control unmounts when the list empties; reset the expansion so // it cannot suppress header actions (or re-expand on its own when sessions // return) while no search control is rendered. const showSearch = props.sessions.length > 0 useEffect(() => { if (!showSearch) setSearchExpanded(false) }, [showSearch]) const showHeaderRow = showSearch || renderHeader || Boolean(props.headerActions) // Pull-to-refresh on the scrollable list. Touch-only gesture mirroring the // pull-to-load-older pattern in HappyThread; desktop has no overscroll // bounce to make a wheel pull feel right, so it stays on live updates. const scrollContainerRef = useRef(null) const [pullState, setPullState] = useState('idle') const pullStateRef = useRef('idle') const [isRefreshing, setIsRefreshing] = useState(false) const isRefreshingRef = useRef(false) const onRefreshRef = useRef(props.onRefresh) useEffect(() => { onRefreshRef.current = props.onRefresh }, [props.onRefresh]) useEffect(() => { const container = scrollContainerRef.current if (!container) return let pullStartY: number | null = null const updatePullState = (state: PullToRefreshState) => { if (pullStateRef.current === state) { return } pullStateRef.current = state setPullState(state) } const triggerRefresh = () => { if (isRefreshingRef.current) { return } isRefreshingRef.current = true setIsRefreshing(true) void Promise.resolve(onRefreshRef.current()).finally(() => { isRefreshingRef.current = false setIsRefreshing(false) }) } const handleTouchStart = (event: TouchEvent) => { updatePullState('idle') pullStartY = container.scrollTop <= 0 && !isRefreshingRef.current ? event.touches[0]?.clientY ?? null : null } const handleTouchMove = (event: TouchEvent) => { if (pullStartY === null) { return } if (container.scrollTop > 0) { pullStartY = null updatePullState('idle') return } const currentY = event.touches[0]?.clientY if (currentY !== undefined) { updatePullState(getPullToRefreshState(currentY - pullStartY)) } } const handleTouchEnd = () => { const shouldRefresh = pullStartY !== null && pullStateRef.current === 'ready' && container.scrollTop <= 0 pullStartY = null updatePullState('idle') if (shouldRefresh) { triggerRefresh() } } const handleTouchCancel = () => { pullStartY = null updatePullState('idle') } container.addEventListener('touchstart', handleTouchStart, { passive: true }) container.addEventListener('touchmove', handleTouchMove, { passive: true }) container.addEventListener('touchend', handleTouchEnd, { passive: true }) container.addEventListener('touchcancel', handleTouchCancel, { passive: true }) return () => { container.removeEventListener('touchstart', handleTouchStart) container.removeEventListener('touchmove', handleTouchMove) container.removeEventListener('touchend', handleTouchEnd) container.removeEventListener('touchcancel', handleTouchCancel) } }, []) return (
{showHeaderRow ? (
{showSearch ? ( { setCustomStart(start) setCustomEnd(end) }} expanded={searchExpanded} onExpandedChange={setSearchExpanded} /> ) : null} {!(showSearch && searchExpanded) ? ( <>
{showMachineFilterBar ? ( ) : null} {renderHeader ? ( ) : null} {props.headerActions} ) : null}
) : null} {showMachineFilterBar ? ( ) : null}
{isRefreshing || pullState !== 'idle' || props.isLoading ? (
{isRefreshing || props.isLoading ? : null} {isRefreshing ? t('sessions.refresh.refreshing') : props.isLoading ? t('misc.loading') : pullState === 'ready' ? t('sessions.refresh.release') : t('sessions.refresh.pull')}
) : null}
{props.sessions.length === 0 && !props.isLoading ? ( ) : null} {props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null) && groups.length === 0 && runningSessions.length === 0 ? (
{t('sessions.search.noResults')}
) : null} {runningSessions.length > 0 ? (
{runningSessions.map((s) => ( ))}
) : null} {groups.map((group) => { const isCollapsed = isGroupCollapsed(group) const visibleGroupSessions = getVisibleGroupSessions(group) const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length const currentLimit = Math.min( getGroupVisibleCount(group), group.sessions.length ) const previousLimit = getPreviousSessionVisibleCount(currentLimit, sessionPreviewLimit) const previousGroupSessions = getVisibleSessionPreview(group.sessions, { selectedSessionId, limit: previousLimit }) const collapseCount = visibleGroupSessions.length - previousGroupSessions.length const canShowFewerSessions = previousLimit < currentLimit && collapseCount > 0 const expandCount = 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 (
toggleGroup(group.key, isCollapsed)} title={group.directory} > {groupTitle} {onNewSessionInDirectory && canStartInGroupDirectory ? ( ) : null} ({group.sessions.length})
{/* Sessions */}
{visibleGroupSessions.map((s) => ( ))} {group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canShowFewerSessions) ? (
{canShowFewerSessions ? ( ) : null} {hiddenSessionCount > 0 ? ( ) : null}
) : null}
) })}
) }