Wire up ErrorBoundary, URL session sync, j/k navigation, and refresh
Wrap SessionViewer in an ErrorBoundary so render errors show a recovery UI instead of a white screen. Sync the selected session ID with the URL search param (?session=). On initial load, load the session from the URL if it exists in the session list. On session change, update the URL via history.replaceState without triggering navigation. Add j/k keyboard navigation to step through filtered messages. Search focus takes precedence over keyboard focus; both reset when filters or session changes. Add a refresh button in the sidebar header that calls the new refreshSessions hook, with a spinning icon while loading. Pass sensitiveCount to FilterPanel for the new badge display. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,10 @@ import { FilterPanel } from "./components/FilterPanel";
|
|||||||
import { SearchBar } from "./components/SearchBar";
|
import { SearchBar } from "./components/SearchBar";
|
||||||
import { SearchMinimap } from "./components/SearchMinimap";
|
import { SearchMinimap } from "./components/SearchMinimap";
|
||||||
import { ExportButton } from "./components/ExportButton";
|
import { ExportButton } from "./components/ExportButton";
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { useSession } from "./hooks/useSession";
|
import { useSession } from "./hooks/useSession";
|
||||||
import { useFilters } from "./hooks/useFilters";
|
import { useFilters } from "./hooks/useFilters";
|
||||||
|
import { countSensitiveMessages } from "../shared/sensitive-redactor";
|
||||||
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -16,15 +18,47 @@ export function App() {
|
|||||||
currentSession,
|
currentSession,
|
||||||
sessionLoading,
|
sessionLoading,
|
||||||
loadSession,
|
loadSession,
|
||||||
|
refreshSessions,
|
||||||
} = useSession();
|
} = useSession();
|
||||||
|
|
||||||
const filters = useFilters();
|
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(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
if (!currentSession) return [];
|
if (!currentSession) return [];
|
||||||
return filters.filterMessages(currentSession.messages);
|
return filters.filterMessages(currentSession.messages);
|
||||||
}, [currentSession, filters.filterMessages]);
|
}, [currentSession, filters.filterMessages]);
|
||||||
|
|
||||||
|
const sensitiveCount = useMemo(
|
||||||
|
() => countSensitiveMessages(filteredMessages),
|
||||||
|
[filteredMessages]
|
||||||
|
);
|
||||||
|
|
||||||
// Track which filtered-message indices match the search query
|
// Track which filtered-message indices match the search query
|
||||||
const matchIndices = useMemo(() => {
|
const matchIndices = useMemo(() => {
|
||||||
if (!filters.searchQuery) return [];
|
if (!filters.searchQuery) return [];
|
||||||
@@ -67,6 +101,46 @@ export function App() {
|
|||||||
const focusedMessageIndex =
|
const focusedMessageIndex =
|
||||||
currentMatchPosition >= 0 ? matchIndices[currentMatchPosition] : -1;
|
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(
|
const visibleUuids = useMemo(
|
||||||
() => filteredMessages.map((m) => m.uuid),
|
() => filteredMessages.map((m) => m.uuid),
|
||||||
[filteredMessages]
|
[filteredMessages]
|
||||||
@@ -109,9 +183,24 @@ export function App() {
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col">
|
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col">
|
||||||
<div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}>
|
<div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}>
|
||||||
<h1 className="text-heading font-semibold text-foreground tracking-tight">
|
<div className="flex items-center justify-between">
|
||||||
Session Viewer
|
<h1 className="text-heading font-semibold text-foreground tracking-tight">
|
||||||
</h1>
|
Session Viewer
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={refreshSessions}
|
||||||
|
disabled={sessionsLoading}
|
||||||
|
className="flex items-center justify-center w-7 h-7 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors disabled:opacity-40"
|
||||||
|
title="Refresh sessions"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 ${sessionsLoading ? "animate-spin" : ""}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p className="text-caption text-foreground-muted mt-0.5">
|
<p className="text-caption text-foreground-muted mt-0.5">
|
||||||
Browse and export Claude sessions
|
Browse and export Claude sessions
|
||||||
</p>
|
</p>
|
||||||
@@ -129,6 +218,7 @@ export function App() {
|
|||||||
onToggle={filters.toggleCategory}
|
onToggle={filters.toggleCategory}
|
||||||
autoRedactEnabled={filters.autoRedactEnabled}
|
autoRedactEnabled={filters.autoRedactEnabled}
|
||||||
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
||||||
|
sensitiveCount={sensitiveCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,17 +274,19 @@ export function App() {
|
|||||||
{/* Scroll area wrapper — relative so minimap can position over the right edge */}
|
{/* Scroll area wrapper — relative so minimap can position over the right edge */}
|
||||||
<div className="flex-1 relative min-h-0">
|
<div className="flex-1 relative min-h-0">
|
||||||
<div ref={scrollRef} className="h-full overflow-y-auto">
|
<div ref={scrollRef} className="h-full overflow-y-auto">
|
||||||
<SessionViewer
|
<ErrorBoundary>
|
||||||
messages={filteredMessages}
|
<SessionViewer
|
||||||
allMessages={currentSession?.messages || []}
|
messages={filteredMessages}
|
||||||
redactedUuids={filters.redactedUuids}
|
allMessages={currentSession?.messages || []}
|
||||||
loading={sessionLoading}
|
redactedUuids={filters.redactedUuids}
|
||||||
searchQuery={filters.searchQuery}
|
loading={sessionLoading}
|
||||||
selectedForRedaction={filters.selectedForRedaction}
|
searchQuery={filters.searchQuery}
|
||||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
selectedForRedaction={filters.selectedForRedaction}
|
||||||
autoRedactEnabled={filters.autoRedactEnabled}
|
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||||
focusedIndex={focusedMessageIndex}
|
autoRedactEnabled={filters.autoRedactEnabled}
|
||||||
/>
|
focusedIndex={activeFocusIndex}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<SearchMinimap
|
<SearchMinimap
|
||||||
matchIndices={matchIndices}
|
matchIndices={matchIndices}
|
||||||
|
|||||||
Reference in New Issue
Block a user