diff --git a/src/client/app.tsx b/src/client/app.tsx index 6a65522..8177f43 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -5,8 +5,10 @@ import { FilterPanel } from "./components/FilterPanel"; import { SearchBar } from "./components/SearchBar"; import { SearchMinimap } from "./components/SearchMinimap"; import { ExportButton } from "./components/ExportButton"; +import { ErrorBoundary } from "./components/ErrorBoundary"; import { useSession } from "./hooks/useSession"; import { useFilters } from "./hooks/useFilters"; +import { countSensitiveMessages } from "../shared/sensitive-redactor"; export function App() { @@ -16,15 +18,47 @@ export function App() { currentSession, sessionLoading, loadSession, + refreshSessions, } = useSession(); const filters = useFilters(); + // URL-driven session selection: sync session ID with URL search params + const hasRestoredFromUrl = useRef(false); + + // On initial load (once sessions are available), restore session from URL + useEffect(() => { + if (hasRestoredFromUrl.current || sessionsLoading || sessions.length === 0) return; + hasRestoredFromUrl.current = true; + + const params = new URLSearchParams(window.location.search); + const sessionId = params.get("session"); + if (sessionId && sessions.some((s) => s.id === sessionId)) { + loadSession(sessionId); + } + }, [sessionsLoading, sessions, loadSession]); + + // Update URL when session changes + useEffect(() => { + if (!currentSession) return; + const params = new URLSearchParams(window.location.search); + if (params.get("session") !== currentSession.id) { + params.set("session", currentSession.id); + const newUrl = `${window.location.pathname}?${params.toString()}${window.location.hash}`; + window.history.replaceState(null, "", newUrl); + } + }, [currentSession]); + const filteredMessages = useMemo(() => { if (!currentSession) return []; return filters.filterMessages(currentSession.messages); }, [currentSession, filters.filterMessages]); + const sensitiveCount = useMemo( + () => countSensitiveMessages(filteredMessages), + [filteredMessages] + ); + // Track which filtered-message indices match the search query const matchIndices = useMemo(() => { if (!filters.searchQuery) return []; @@ -67,6 +101,46 @@ export function App() { const focusedMessageIndex = currentMatchPosition >= 0 ? matchIndices[currentMatchPosition] : -1; + // Keyboard navigation: j/k to move between messages + const [keyboardFocusIndex, setKeyboardFocusIndex] = useState(-1); + + // Reset keyboard focus when session or filters change + useEffect(() => { + setKeyboardFocusIndex(-1); + }, [filteredMessages]); + + // Combined focus: search focus takes precedence over keyboard focus + const activeFocusIndex = + focusedMessageIndex >= 0 ? focusedMessageIndex : keyboardFocusIndex; + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + // Don't intercept when typing in an input + if (document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA") { + return; + } + + if (e.key === "j" && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + setKeyboardFocusIndex((prev) => { + const max = filteredMessages.length - 1; + if (max < 0) return -1; + return prev < max ? prev + 1 : max; + }); + } + + if (e.key === "k" && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + setKeyboardFocusIndex((prev) => { + if (filteredMessages.length === 0) return -1; + return prev > 0 ? prev - 1 : 0; + }); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [filteredMessages.length]); + const visibleUuids = useMemo( () => filteredMessages.map((m) => m.uuid), [filteredMessages] @@ -109,9 +183,24 @@ export function App() { {/* Sidebar */}
Browse and export Claude sessions
@@ -129,6 +218,7 @@ export function App() { onToggle={filters.toggleCategory} autoRedactEnabled={filters.autoRedactEnabled} onAutoRedactToggle={filters.setAutoRedactEnabled} + sensitiveCount={sensitiveCount} />