/** * SessionItem - Compact session row in the session list. * Shows title, message count, and time ago. * Supports right-click context menu for pane management. */ import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; import { EyeOff, MessageSquare, Pin, Play, RotateCw, Users } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { OngoingIndicator } from '../common/OngoingIndicator'; import { SessionContextMenu } from './SessionContextMenu'; import type { PhaseTokenBreakdown, Session } from '@renderer/types/data'; interface SessionItemProps { session: Session; isActive?: boolean; isPinned?: boolean; isHidden?: boolean; multiSelectActive?: boolean; isSelected?: boolean; onToggleSelect?: () => void; } /** * Format time distance in short form (e.g., "4m", "2h", "1d") */ function formatShortTime(date: Date): string { const distance = formatDistanceToNowStrict(date, { addSuffix: false }); return distance .replace(' seconds', 's') .replace(' second', 's') .replace(' minutes', 'm') .replace(' minute', 'm') .replace(' hours', 'h') .replace(' hour', 'h') .replace(' days', 'd') .replace(' day', 'd') .replace(' weeks', 'w') .replace(' week', 'w') .replace(' months', 'mo') .replace(' month', 'mo') .replace(' years', 'y') .replace(' year', 'y'); } /** * Consumption badge with hover popover showing phase breakdown. */ const ConsumptionBadge = ({ contextConsumption, phaseBreakdown, }: Readonly<{ contextConsumption: number; phaseBreakdown?: PhaseTokenBreakdown[]; }>): React.JSX.Element => { const [popoverPosition, setPopoverPosition] = useState<{ top: number; left: number; } | null>(null); const badgeRef = useRef(null); const isHigh = contextConsumption > 150_000; const showPopover = popoverPosition !== null; return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive { const rect = badgeRef.current?.getBoundingClientRect(); if (rect) { setPopoverPosition({ top: rect.top - 6, left: rect.left + rect.width / 2, }); } }} onMouseLeave={() => setPopoverPosition(null)} > {formatTokensCompact(contextConsumption)} {showPopover && popoverPosition && phaseBreakdown && phaseBreakdown.length > 0 && createPortal(
Total Context: {formatTokensCompact(contextConsumption)} tokens
{phaseBreakdown.length === 1 ? (
Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}
) : ( phaseBreakdown.map((phase) => (
Phase {phase.phaseNumber}: {formatTokensCompact(phase.contribution)} {phase.postCompaction != null && ( (compacted to {formatTokensCompact(phase.postCompaction)}) )}
)) )}
, document.body )}
); }; export const SessionItem = ({ session, isActive, isPinned, isHidden, multiSelectActive, isSelected, onToggleSelect, }: Readonly): React.JSX.Element => { const { openTab, activeProjectId, selectSession, paneCount, splitPane, togglePinSession, toggleHideSession, } = useStore( useShallow((s) => ({ openTab: s.openTab, activeProjectId: s.activeProjectId, selectSession: s.selectSession, paneCount: s.paneLayout.panes.length, splitPane: s.splitPane, togglePinSession: s.togglePinSession, toggleHideSession: s.toggleHideSession, })) ); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const handleClick = (event: React.MouseEvent): void => { if (!activeProjectId) return; // In multi-select mode, clicks toggle selection if (multiSelectActive && onToggleSelect) { onToggleSelect(); return; } // Cmd/Ctrl+click: open in new tab; plain click: replace current tab const forceNewTab = event.ctrlKey || event.metaKey; openTab( { type: 'session', sessionId: session.id, projectId: activeProjectId, label: formatSessionLabel(session.firstMessage), }, forceNewTab ? { forceNewTab } : { replaceActiveTab: true } ); selectSession(session.id); }; const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }, []); const sessionLabel = formatSessionLabel(session.firstMessage); const handleOpenInCurrentPane = useCallback(() => { if (!activeProjectId) return; openTab( { type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, }, { replaceActiveTab: true } ); selectSession(session.id); }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); const handleOpenInNewTab = useCallback(() => { if (!activeProjectId) return; openTab( { type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, }, { forceNewTab: true } ); selectSession(session.id); }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); const handleSplitRightAndOpen = useCallback(() => { if (!activeProjectId) return; // First open the tab in the focused pane openTab({ type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, }); selectSession(session.id); // Then split it to the right const state = useStore.getState(); const focusedPaneId = state.paneLayout.focusedPaneId; const activeTabId = state.activeTabId; if (activeTabId) { splitPane(focusedPaneId, activeTabId, 'right'); } }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll return ( <> {contextMenu && activeProjectId && createPortal( setContextMenu(null)} onOpenInCurrentPane={handleOpenInCurrentPane} onOpenInNewTab={handleOpenInNewTab} onSplitRightAndOpen={handleSplitRightAndOpen} onTogglePin={() => void togglePinSession(session.id)} onToggleHide={() => void toggleHideSession(session.id)} />, document.body )} ); };