/** * SessionBrowserDialog - Interactive session browser component * * @plan PLAN-20260214-SESSIONBROWSER.P15 * @plan PLAN-20260214-SESSIONBROWSER.P17 * @requirement REQ-SB-012, REQ-RW-001, REQ-RN-001 * @pseudocode session-browser-dialog.md */ import { Box, Text } from 'ink'; import type React from 'react'; import { useCallback } from 'react'; import type { ContinueTarget, SessionRecordingService, } from '@vybestack/llxprt-code-core'; import { SemanticColors } from '../colors.js'; import type { PerformResumeResult } from '../../services/performResume.js'; import { useResponsive } from '../hooks/useResponsive.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { useSessionBrowser } from '../hooks/useSessionBrowser.js'; import type { EnrichedSessionSummary } from '../hooks/useSessionBrowser.js'; import { formatRelativeTime } from '../../utils/formatRelativeTime.js'; import { truncateEnd } from '../utils/responsive.js'; import { getBorderStyle } from '../contexts/UnicodeRenderingContext.js'; /** * Props for the SessionBrowserDialog component * @plan PLAN-20260214-SESSIONBROWSER.P15 * @requirement REQ-SB-012 */ export interface SessionBrowserDialogProps { chatsDir: string; projectHash: string; currentSessionId: string; hasActiveConversation: boolean; activeRecording?: SessionRecordingService | null; onSelect: (target: ContinueTarget) => Promise; onClose: () => void; } /** * Format file size in human-readable format */ function formatFileSize(bytes: number): string { if (bytes < 1024) { return `${bytes}B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } type PersistedSessionDisplay = { provider: string; model: string; fileSize: number; }; function getSessionDisplay( session: EnrichedSessionSummary, ): PersistedSessionDisplay { const boundarySession = session as { provider?: unknown; model?: unknown; fileSize?: unknown; }; return { provider: typeof boundarySession.provider === 'string' ? boundarySession.provider : 'unknown', model: typeof boundarySession.model === 'string' ? boundarySession.model : 'unknown', fileSize: typeof boundarySession.fileSize === 'number' ? boundarySession.fileSize : 0, }; } const SessionPreview: React.FC<{ session: EnrichedSessionSummary; isNarrow: boolean; }> = ({ session, isNarrow }) => { switch (session.previewState) { case 'loading': return ( Loading... ); case 'none': return ( (no user message) ); case 'error': return ( (preview unavailable) ); case 'loaded': default: { const previewText = session.firstUserMessage ?? ''; const maxLen = isNarrow ? 40 : 80; const truncated = truncateEnd(previewText.replace(/\n/g, ' '), maxLen); return {truncated}; } } }; function getTargetLabel(session: EnrichedSessionSummary): string { if (session.target.kind === 'checkpoint') { return `checkpoint ${session.target.checkpointName}`; } if (session.target.session.name) { return `session ${session.target.session.name}`; } return `session ${session.sessionId.slice(0, 8)}`; } const NarrowSessionRow: React.FC<{ session: EnrichedSessionSummary; isSelected: boolean; display: PersistedSessionDisplay; relTime: string; isNarrow: boolean; }> = ({ session, isSelected, display, relTime, isNarrow }) => ( {isSelected ? '● ' : '○ '} {getTargetLabel(session)} {' '} · {display.provider} ·{' '} {relTime} {session.isLocked ? ( (in use) ) : null} {isSelected ? ( {' '} [{session.sessionId.slice(0, 8)}] ) : null} ); const WideSessionRow: React.FC<{ session: EnrichedSessionSummary; isSelected: boolean; display: PersistedSessionDisplay; relTime: string; oneBasedIndex: number; isNarrow: boolean; }> = ({ session, isSelected, display, relTime, oneBasedIndex, isNarrow }) => ( {isSelected ? '● ' : '○ '} #{oneBasedIndex} {getTargetLabel(session)} · {display.provider}/ {truncateEnd(display.model, 30)} · {relTime} · {formatFileSize(display.fileSize)} {session.isLocked ? ( (in use) ) : null} ); const LoadingState: React.FC<{ isNarrow: boolean }> = ({ isNarrow }) => { if (isNarrow) { return ( Sessions Loading sessions... ); } return ( Session Browser Loading sessions... ); }; const EmptyState: React.FC<{ isNarrow: boolean }> = ({ isNarrow }) => { if (isNarrow) { return ( Sessions No sessions found for this project. Sessions are created automatically when you start a conversation. Press Esc to close ); } return ( Session Browser No sessions found for this project. Sessions are created automatically when you start a conversation. Press Esc to close ); }; const SearchBarNarrow: React.FC<{ state: ReturnType; }> = ({ state }) => ( Search:{' '} {state.isSearching ? ( ) : null} {state.searchTerm} {state.searchTerm.length > 0 ? ( {' '} ({state.filteredSessions.length} found) ) : null} ); const SearchBarWide: React.FC<{ state: ReturnType; }> = ({ state }) => { const matchCount = state.filteredSessions.length; return ( Search:{' '} {state.isSearching ? ( ) : null} {state.searchTerm} {' '} ({matchCount} {matchCount === 1 ? 'target' : 'targets'} found) {state.isSearching ? ' (Tab to navigate)' : ''} ); }; const SortBar: React.FC<{ sortOrder: string; }> = ({ sortOrder }) => ( Sort: {sortOrder === 'newest' ? '[newest]' : 'newest'} {sortOrder === 'oldest' ? '[oldest]' : 'oldest'} {sortOrder === 'size' ? '[size]' : 'size'} (press s to cycle) ); const PageIndicator: React.FC<{ page: number; totalPages: number; }> = ({ page, totalPages }) => { if (totalPages <= 1) return null; return ( Page {page + 1} of {totalPages} (PgUp/PgDn to navigate) ); }; const SelectionDetail: React.FC<{ session: EnrichedSessionSummary | null; }> = ({ session }) => { if (!session) return null; const relTime = formatRelativeTime(session.lastModified, { mode: 'long' }); const display = getSessionDisplay(session); return ( Selected: {getTargetLabel(session)} [{session.sessionId}] · {display.provider}/{display.model} · {relTime} ); }; const ControlsBarNarrow: React.FC<{ hasSessions: boolean; sortOrder: string; }> = ({ hasSessions, sortOrder }) => ( Nav:↑↓ {hasSessions ? 'Enter ' : ''}s:{sortOrder} Esc ); const ControlsBarWide: React.FC<{ hasSessions: boolean; }> = ({ hasSessions }) => ( Controls: ↑↓ Navigate {hasSessions ? ' [Enter] Resume [Del] Delete' : ''} [s] Sort [Tab] Toggle Mode [Esc] Close ); const ErrorMessage: React.FC<{ error: string | null }> = ({ error }) => { if (error === null) return null; return ( {error} ); }; const ErrorState: React.FC<{ error: string; isNarrow: boolean }> = ({ error, isNarrow, }) => ( {isNarrow ? 'Sessions' : 'Session Browser'} Press Esc to close ); const SkippedNotice: React.FC<{ skippedCount: number }> = ({ skippedCount, }) => { if (skippedCount === 0) return null; return ( Skipped {skippedCount} unreadable session(s). ); }; const ResumingStatus: React.FC<{ isResuming: boolean }> = ({ isResuming }) => { if (isResuming !== true) return null; return ( Resuming... ); }; const DeleteConfirmation: React.FC<{ deleteConfirmIndex: number | null; selectedSession: EnrichedSessionSummary | null; }> = ({ deleteConfirmIndex, selectedSession }) => { if (deleteConfirmIndex === null) return null; const kind = selectedSession?.target.kind ?? 'session'; return ( Delete this {kind}? Press Y to confirm, N or Esc to cancel. ); }; const ConversationConfirmation: React.FC<{ conversationConfirmActive: boolean; }> = ({ conversationConfirmActive }) => { if (conversationConfirmActive !== true) return null; return ( This will replace your current conversation. Continue? Y/N ); }; const EmptySearchResults: React.FC<{ pageItemsLength: number; searchTerm: string; }> = ({ pageItemsLength, searchTerm }) => { if (pageItemsLength > 0 || searchTerm === '') return null; return ( No targets match "{searchTerm}" ); }; const SessionList: React.FC<{ state: ReturnType; isNarrow: boolean; }> = ({ state, isNarrow }) => { if (state.pageItems.length === 0) { return ( ); } return ( {state.pageItems.map((session, index) => { const isSelected = index === state.selectedIndex; const relTime = formatRelativeTime(session.lastModified, { mode: isNarrow ? 'short' : 'long', }); const display = getSessionDisplay(session); if (isNarrow) { return ( ); } const oneBasedIndex = state.page * 20 + index + 1; return ( ); })} ); }; const SessionContent: React.FC<{ isNarrow: boolean; state: ReturnType; }> = ({ isNarrow, state }) => ( <> {/* Title */} {isNarrow ? 'Sessions' : 'Session Browser'} {/* Search bar */} {isNarrow ? ( ) : ( )} {/* Sort bar (wide mode only) */} {!isNarrow && } {/* Skipped notice */} {/* Session list */} {/* Page indicator */} {/* Error message */} {/* Resuming status */} {/* Delete confirmation */} {/* Conversation confirmation */} {/* Selection detail (wide mode only) */} {!isNarrow && } {/* Controls bar */} {isNarrow ? ( 0} sortOrder={state.sortOrder} /> ) : ( 0} /> )} ); /** * Interactive session browser dialog for selecting and resuming sessions * @plan PLAN-20260214-SESSIONBROWSER.P15 */ export function SessionBrowserDialog( props: SessionBrowserDialogProps, ): React.ReactElement { const { chatsDir, projectHash, currentSessionId, hasActiveConversation, activeRecording, onSelect, onClose, } = props; const { isNarrow, width } = useResponsive(); const state = useSessionBrowser({ chatsDir, projectHash, currentSessionId, onSelect, activeRecording, onClose, hasActiveConversation, }); const handleKeypress = useCallback( (key: Parameters[0]>[0]) => { state.handleKeypress(key.sequence, key); }, [state], ); useKeypress(handleKeypress, { isActive: true }); if (state.isLoading) { return ; } const noLoadedSessionState = state.sessions.length + state.pageItems.length + state.skippedCount === 0; if (noLoadedSessionState && state.searchTerm === '') { if (state.error !== null) { return ; } return ; } const content = ; if (isNarrow) { return ( {content} ); } return ( {content} ); }