import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import type { Session } from '../types/kanban' import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons' import { getSessionRuntimeStatus } from '../lib/sessionRuntime' import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions' interface SessionSidebarProps { sessions: Session[] activeSessionId: string | null activeChannel?: string | null secretaryChannelId?: string unreadCounts?: Record onSelect: (taskId: string | null) => void onCreateSession: () => void onDeleteSession: (taskId: string) => void onSelectSecretary?: () => void } const STATUS_DOT: Record = { idle: 'status-idle', done: 'status-done', pending: 'status-pending', failed: 'status-failed', cancelled: 'status-cancelled', blocked: 'status-blocked', } function sessionDotClass(session: Session): string { const displayStatus = deriveCompanyRuntimeDisplayStatus(session) ?? session.status const runtimeStatus = getSessionRuntimeStatus({ ...session, status: displayStatus }) if (runtimeStatus === 'tool_active') return 'status-tool-active' if (runtimeStatus === 'reflecting') return 'status-reflecting' if (displayStatus === 'running') return 'status-running-idle' return STATUS_DOT[displayStatus] ?? 'status-pending' } function relativeTime(ts: number): string { const diff = Date.now() - ts if (diff < 60_000) return 'just now' if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m` if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h` return `${Math.floor(diff / 86_400_000)}d` } function dateGroup(ts: number): string { const now = new Date() const d = new Date(ts) const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterday = new Date(today.getTime() - 86_400_000) if (d >= today) return 'Today' if (d >= yesterday) return 'Yesterday' return 'Earlier' } interface SessionTree { session: Session children: Session[] } type SidebarRow = | { kind: 'group'; group: 'Today' | 'Yesterday' | 'Earlier' } | { kind: 'primary'; node: SessionTree } | { kind: 'child'; parentTaskId: string; child: Session; childIndex: number; childCount: number } | { kind: 'child-count'; node: SessionTree } function buildSessionTree(sessions: Session[]): SessionTree[] { const childMap = new Map() const primarySessions: Session[] = [] for (const s of sessions) { if (s.mode === 'child' && s.parentSessionId) { const siblings = childMap.get(s.parentSessionId) ?? [] siblings.push(s) childMap.set(s.parentSessionId, siblings) } else { primarySessions.push(s) } } return primarySessions.map(p => ({ session: p, children: Array.from(new Map( [...(p.sessionId ? (childMap.get(p.sessionId) ?? []) : []), ...(childMap.get(p.taskId) ?? [])] .map(child => [child.taskId, child]), ).values()), })) } function SessionItem({ session, isActive, isChild, isLast, unreadCount, onSelect, onDelete, }: { session: Session isActive: boolean isChild?: boolean isLast?: boolean unreadCount?: number onSelect: () => void onDelete: () => void }) { const [showDelete, setShowDelete] = useState(false) const [confirming, setConfirming] = useState(false) const agentLabel = session.assigneeIds.length > 0 ? session.assigneeIds[0].replace(/^agent-/, '') : null const displayStatus = deriveCompanyRuntimeDisplayStatus(session) ?? session.status useEffect(() => { if (!confirming) return const timer = setTimeout(() => setConfirming(false), 3000) return () => clearTimeout(timer) }, [confirming]) return ( )} {confirming && ( e.stopPropagation()}> )} ) } export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) { const [search, setSearch] = useState('') const [collapsed, setCollapsed] = useState>(new Set()) const hasRuntimeSessions = sessions.some(session => session.isCompanyRuntime || session.mode === 'child' || session.execMode === 'company' || session.execMode === 'org' || session.execMode === 'custom' ) const toggleCollapse = useCallback((taskId: string) => { setCollapsed(prev => { const next = new Set(prev) if (next.has(taskId)) next.delete(taskId) else next.add(taskId) return next }) }, []) const filtered = useMemo(() => { if (!search.trim()) return sessions const q = search.toLowerCase() return sessions.filter(s => s.title.toLowerCase().includes(q)) }, [sessions, search]) const tree = useMemo(() => buildSessionTree(filtered), [filtered]) const grouped = useMemo(() => { const groups: Record = { Today: [], Yesterday: [], Earlier: [] } for (const node of tree) { const g = dateGroup(node.session.createdAt) groups[g]?.push(node) } return groups }, [tree]) const rows = useMemo(() => { const nextRows: SidebarRow[] = [] for (const group of ['Today', 'Yesterday', 'Earlier'] as const) { const items = grouped[group] if (!items || items.length === 0) continue nextRows.push({ kind: 'group', group }) for (const node of items) { nextRows.push({ kind: 'primary', node }) const hasChildren = node.children.length > 0 const isCollapsed = collapsed.has(node.session.taskId) if (hasChildren && isCollapsed) { nextRows.push({ kind: 'child-count', node }) } else if (hasChildren) { node.children.forEach((child, idx) => { nextRows.push({ kind: 'child', parentTaskId: node.session.taskId, child, childIndex: idx, childCount: node.children.length, }) }) } } } return nextRows }, [collapsed, grouped]) const useVirtualRows = rows.length > 120 const listRef = useRef(null) const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => listRef.current, estimateSize: (index) => { const row = rows[index] if (row?.kind === 'group') return 26 if (row?.kind === 'child-count') return 30 return 54 }, overscan: 8, }) const renderPrimaryRow = useCallback((node: SessionTree) => { const hasChildren = node.children.length > 0 const isCollapsed = collapsed.has(node.session.taskId) return (
{hasChildren && (
) }, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts]) const renderVirtualRow = useCallback((row: SidebarRow) => { if (row.kind === 'group') { return (
{hasRuntimeSessions ? `${row.group} Runtime Sessions` : row.group}
) } if (row.kind === 'primary') { return renderPrimaryRow(row.node) } if (row.kind === 'child-count') { return ( ) } return (
onSelect(row.child.taskId)} onDelete={() => onDeleteSession(row.child.taskId)} />
) }, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts]) return (
{onSelectSecretary && ( )}
setSearch(e.target.value)} />
{useVirtualRows ? (
{rowVirtualizer.getVirtualItems().map(virtualRow => (
{renderVirtualRow(rows[virtualRow.index])}
))}
) : (['Today', 'Yesterday', 'Earlier'] as const).map(group => { const items = grouped[group] if (!items || items.length === 0) return null return (
{hasRuntimeSessions ? `${group} Runtime Sessions` : group}
{items.map(node => { const hasChildren = node.children.length > 0 const isCollapsed = collapsed.has(node.session.taskId) return (
{hasChildren && (
{hasChildren && !isCollapsed && (
{node.children.map((child, idx) => ( onSelect(child.taskId)} onDelete={() => onDeleteSession(child.taskId)} /> ))}
)} {hasChildren && isCollapsed && ( )}
) })}
) })} {filtered.length === 0 && (
No sessions yet
)}
) }