import React, { useMemo, useState, useEffect, useCallback, useRef } from "react"; import { SessionList } from "./components/SessionList"; import { SessionViewer } from "./components/SessionViewer"; import { FilterPanel } from "./components/FilterPanel"; import { SearchBar } from "./components/SearchBar"; import { SearchMinimap } from "./components/SearchMinimap"; import { ExportButton } from "./components/ExportButton"; import { useSession } from "./hooks/useSession"; import { useFilters } from "./hooks/useFilters"; export function App() { const { sessions, sessionsLoading, currentSession, sessionLoading, loadSession, } = useSession(); const filters = useFilters(); const filteredMessages = useMemo(() => { if (!currentSession) return []; return filters.filterMessages(currentSession.messages); }, [currentSession, filters.filterMessages]); // Track which filtered-message indices match the search query const matchIndices = useMemo(() => { if (!filters.searchQuery) return []; const lq = filters.searchQuery.toLowerCase(); return filteredMessages.reduce((acc, msg, i) => { if ( msg.content.toLowerCase().includes(lq) || (msg.toolInput && msg.toolInput.toLowerCase().includes(lq)) ) { acc.push(i); } return acc; }, []); }, [filteredMessages, filters.searchQuery]); const matchCount = matchIndices.length; // Which match is currently focused (index into matchIndices) const [currentMatchPosition, setCurrentMatchPosition] = useState(-1); // Reset to first match when search results change useEffect(() => { setCurrentMatchPosition(matchIndices.length > 0 ? 0 : -1); }, [matchIndices]); const goToNextMatch = useCallback(() => { if (matchIndices.length === 0) return; setCurrentMatchPosition((prev) => prev < matchIndices.length - 1 ? prev + 1 : 0 ); }, [matchIndices.length]); const goToPrevMatch = useCallback(() => { if (matchIndices.length === 0) return; setCurrentMatchPosition((prev) => prev > 0 ? prev - 1 : matchIndices.length - 1 ); }, [matchIndices.length]); const focusedMessageIndex = currentMatchPosition >= 0 ? matchIndices[currentMatchPosition] : -1; const visibleUuids = useMemo( () => filteredMessages.map((m) => m.uuid), [filteredMessages] ); // Scroll tracking for minimap viewport indicator const scrollRef = useRef(null); const [viewportTop, setViewportTop] = useState(0); const [viewportHeight, setViewportHeight] = useState(1); const updateViewport = useCallback(() => { const el = scrollRef.current; if (!el || el.scrollHeight === 0) return; setViewportTop(el.scrollTop / el.scrollHeight); setViewportHeight(Math.min(el.clientHeight / el.scrollHeight, 1)); }, []); useEffect(() => { const el = scrollRef.current; if (!el) return; el.addEventListener("scroll", updateViewport, { passive: true }); // Initial measurement updateViewport(); // Re-measure when content changes const ro = new ResizeObserver(updateViewport); ro.observe(el); return () => { el.removeEventListener("scroll", updateViewport); ro.disconnect(); }; }, [updateViewport]); // Re-measure when messages change (content size changes) useEffect(() => { updateViewport(); }, [filteredMessages, updateViewport]); return (
{/* Sidebar */}

Session Viewer

Browse and export Claude sessions

{/* Main */}
{/* Left spacer — mirrors right side width to keep search centered */}
{/* Center — search bar + contextual redaction controls */}
{filters.selectedForRedaction.size > 0 && (
{filters.selectedForRedaction.size} selected
)}
{/* Right — export button, right-justified */}
{currentSession && ( )}
{/* Scroll area wrapper — relative so minimap can position over the right edge */}
); }