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 { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' type SessionGroup = { directory: string displayName: string sessions: SessionSummary[] latestUpdatedAt: number hasActiveSession: boolean } 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]}` } function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] { const groups = new Map() sessions.forEach(session => { const path = session.metadata?.worktree?.basePath ?? session.metadata?.path ?? 'Other' if (!groups.has(path)) { groups.set(path, []) } groups.get(path)!.push(session) }) return Array.from(groups.entries()) .map(([directory, groupSessions]) => { const sortedSessions = [...groupSessions].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 = groupSessions.reduce( (max, s) => (s.updatedAt > max ? s.updatedAt : max), -Infinity ) const hasActiveSession = groupSessions.some(s => s.active) const displayName = getGroupDisplayName(directory) return { directory, displayName, sessions: sortedSessions, latestUpdatedAt, hasActiveSession } }) .sort((a, b) => { if (a.hasActiveSession !== b.hasActiveSession) { return a.hasActiveSession ? -1 : 1 } return b.latestUpdatedAt - a.latestUpdatedAt }) } function PlusIcon(props: { className?: string }) { return ( ) } function BulbIcon(props: { className?: string }) { return ( ) } function ChevronIcon(props: { className?: string; collapsed?: boolean }) { return ( ) } function getSessionTitle(session: SessionSummary): string { if (session.metadata?.name) { return session.metadata.name } if (session.metadata?.summary?.text) { return session.metadata.summary.text } if (session.metadata?.path) { const parts = session.metadata.path.split('/').filter(Boolean) return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) } return session.id.slice(0, 8) } 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 } function getAgentLabel(session: SessionSummary): string { const flavor = session.metadata?.flavor?.trim() if (flavor) return flavor return 'unknown' } function getModelLabel(session: SessionSummary): string { return session.modelMode ?? 'default' } function formatRelativeTime(value: number): 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 'just now' const minutes = Math.floor(delta / 60_000) if (minutes < 60) return `${minutes}m ago` const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours}h ago` const days = Math.floor(hours / 24) if (days < 7) return `${days}d ago` return new Date(ms).toLocaleDateString() } function SessionItem(props: { session: SessionSummary onSelect: (sessionId: string) => void showPath?: boolean api: ApiClient | null }) { const { session: s, onSelect, showPath = true, api } = props const { haptic } = usePlatform() const [menuOpen, setMenuOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false) const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions( api, s.id, s.metadata?.flavor ?? null ) const longPressHandlers = useLongPress({ onLongPress: () => { haptic.impact('medium') setMenuOpen(true) }, onClick: () => onSelect(s.id), threshold: 500 }) const sessionName = getSessionTitle(s) const statusDotClass = s.active ? (s.thinking ? 'bg-[#007AFF]' : 'bg-[var(--app-badge-success-text)]') : 'bg-[var(--app-hint)]' return ( <> setMenuOpen(false)} sessionActive={s.active} onRename={() => setRenameOpen(true)} onArchive={() => setArchiveOpen(true)} onDelete={() => setDeleteOpen(true)} /> setRenameOpen(false)} currentName={sessionName} onRename={renameSession} isPending={isPending} /> setArchiveOpen(false)} title="Archive Session" description={`Are you sure you want to archive "${sessionName}"? This will disconnect the active session.`} confirmLabel="Archive" confirmingLabel="Archiving..." onConfirm={archiveSession} isPending={isPending} destructive /> setDeleteOpen(false)} title="Delete Session" description={`Are you sure you want to delete "${sessionName}"? This action cannot be undone.`} confirmLabel="Delete" confirmingLabel="Deleting..." onConfirm={deleteSession} isPending={isPending} destructive /> ) } export function SessionList(props: { sessions: SessionSummary[] onSelect: (sessionId: string) => void onNewSession: () => void onRefresh: () => void isLoading: boolean renderHeader?: boolean api: ApiClient | null }) { const { renderHeader = true, api } = props const groups = useMemo( () => groupSessionsByDirectory(props.sessions), [props.sessions] ) const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() ) const isGroupCollapsed = (group: SessionGroup): boolean => { const override = collapseOverrides.get(group.directory) if (override !== undefined) return override return !group.hasActiveSession } const toggleGroup = (directory: string, isCollapsed: boolean) => { setCollapseOverrides(prev => { const next = new Map(prev) next.set(directory, !isCollapsed) return next }) } useEffect(() => { setCollapseOverrides(prev => { if (prev.size === 0) return prev const next = new Map(prev) const knownGroups = new Set(groups.map(group => group.directory)) let changed = false for (const directory of next.keys()) { if (!knownGroups.has(directory)) { next.delete(directory) changed = true } } return changed ? next : prev }) }, [groups]) return (
{renderHeader ? (
{props.sessions.length} sessions in {groups.length} projects
) : null}
{groups.map((group) => { const isCollapsed = isGroupCollapsed(group) return (
{!isCollapsed ? (
{group.sessions.map((s) => ( ))}
) : null}
) })}
) }