Add search navigation with match cycling, keyboard shortcuts, and minimap
Implement full-featured search navigation across the session viewer: App.tsx: Compute matchIndices (filtered message indices matching the query), track currentMatchPosition state, and provide goToNext/goToPrev callbacks. Add scroll tracking via ResizeObserver + scroll events for minimap viewport. Restructure toolbar layout with centered search bar and right-aligned export. Pass focusedIndex to SessionViewer for scroll-into-view behavior. SearchBar: Redesign as a unified search container with integrated match count badge, prev/next navigation arrows, clear button, and keyboard hint (/). Add keyboard shortcuts: Enter/Shift+Enter for next/prev, Ctrl+G/Ctrl+Shift+G for navigation, Escape to clear and blur. Show 'X/N' position indicator and 'No results' state. SessionViewer: Add data-msg-index attributes for scroll targeting via querySelector instead of individual refs. Memoize displayItems list. Add MessageSkeleton component for loading state. Add empty state illustrations with icons and descriptive text. Apply staggered fade-in animations and search-match-focused outline to the active match. SearchMinimap: New component rendering match positions as amber ticks on a narrow strip overlaying the scroll area's right edge. Includes viewport position indicator and click-to-jump behavior. Add unit tests for SearchMinimap: empty/zero states, tick rendering, active tick styling, viewport indicator, and click handler. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import React, { useMemo } from "react";
|
||||
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,
|
||||
@@ -23,23 +25,91 @@ export function App() {
|
||||
return filters.filterMessages(currentSession.messages);
|
||||
}, [currentSession, filters.filterMessages]);
|
||||
|
||||
// Derive match count from filtered messages - no setState during render
|
||||
const matchCount = useMemo(
|
||||
() => filters.getMatchCount(filteredMessages),
|
||||
[filters.getMatchCount, filteredMessages]
|
||||
);
|
||||
// Track which filtered-message indices match the search query
|
||||
const matchIndices = useMemo(() => {
|
||||
if (!filters.searchQuery) return [];
|
||||
const lq = filters.searchQuery.toLowerCase();
|
||||
return filteredMessages.reduce<number[]>((acc, msg, i) => {
|
||||
if (msg.content.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<HTMLDivElement>(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 (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<div className="flex h-screen" style={{ background: "var(--color-canvas)" }}>
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h1 className="text-lg font-bold text-gray-900">Session Viewer</h1>
|
||||
<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%)" }}>
|
||||
<h1 className="text-heading font-semibold text-foreground tracking-tight">
|
||||
Session Viewer
|
||||
</h1>
|
||||
<p className="text-caption text-foreground-muted mt-0.5">
|
||||
Browse and export Claude sessions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SessionList
|
||||
@@ -49,62 +119,85 @@ export function App() {
|
||||
onSelect={loadSession}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<FilterPanel
|
||||
enabledCategories={filters.enabledCategories}
|
||||
onToggle={filters.toggleCategory}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
||||
/>
|
||||
</div>
|
||||
<FilterPanel
|
||||
enabledCategories={filters.enabledCategories}
|
||||
onToggle={filters.toggleCategory}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-3 p-3 border-b border-gray-200 bg-white">
|
||||
<SearchBar
|
||||
query={filters.searchQuery}
|
||||
onQueryChange={filters.setSearchQuery}
|
||||
matchCount={matchCount}
|
||||
/>
|
||||
{filters.selectedForRedaction.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600 font-medium">
|
||||
{filters.selectedForRedaction.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={filters.confirmRedaction}
|
||||
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Redact Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={filters.clearRedactionSelection}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{currentSession && (
|
||||
<ExportButton
|
||||
session={currentSession}
|
||||
visibleMessageUuids={visibleUuids}
|
||||
redactedMessageUuids={[...filters.redactedUuids]}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
<div className="glass flex items-center px-5 py-2.5 border-b border-border z-10">
|
||||
{/* Left spacer — mirrors right side width to keep search centered */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{/* Center — search bar + contextual redaction controls */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<SearchBar
|
||||
query={filters.searchQuery}
|
||||
onQueryChange={filters.setSearchQuery}
|
||||
matchCount={matchCount}
|
||||
currentMatchPosition={currentMatchPosition}
|
||||
onNext={goToNextMatch}
|
||||
onPrev={goToPrevMatch}
|
||||
/>
|
||||
)}
|
||||
{filters.selectedForRedaction.size > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/8 border border-red-500/15 animate-fade-in">
|
||||
<span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap">
|
||||
{filters.selectedForRedaction.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={filters.confirmRedaction}
|
||||
className="btn btn-sm btn-danger"
|
||||
>
|
||||
Redact
|
||||
</button>
|
||||
<button
|
||||
onClick={filters.clearRedactionSelection}
|
||||
className="btn btn-sm btn-ghost text-red-400/70 hover:text-red-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — export button, right-justified */}
|
||||
<div className="flex-1 min-w-0 flex justify-end">
|
||||
{currentSession && (
|
||||
<ExportButton
|
||||
session={currentSession}
|
||||
visibleMessageUuids={visibleUuids}
|
||||
redactedMessageUuids={[...filters.redactedUuids]}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SessionViewer
|
||||
messages={filteredMessages}
|
||||
allMessages={currentSession?.messages || []}
|
||||
redactedUuids={filters.redactedUuids}
|
||||
loading={sessionLoading}
|
||||
searchQuery={filters.searchQuery}
|
||||
selectedForRedaction={filters.selectedForRedaction}
|
||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
{/* Scroll area wrapper — relative so minimap can position over the right edge */}
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto">
|
||||
<SessionViewer
|
||||
messages={filteredMessages}
|
||||
allMessages={currentSession?.messages || []}
|
||||
redactedUuids={filters.redactedUuids}
|
||||
loading={sessionLoading}
|
||||
searchQuery={filters.searchQuery}
|
||||
selectedForRedaction={filters.selectedForRedaction}
|
||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
focusedIndex={focusedMessageIndex}
|
||||
/>
|
||||
</div>
|
||||
<SearchMinimap
|
||||
matchIndices={matchIndices}
|
||||
totalMessages={filteredMessages.length}
|
||||
currentPosition={currentMatchPosition}
|
||||
onClickMatch={setCurrentMatchPosition}
|
||||
viewportTop={viewportTop}
|
||||
viewportHeight={viewportHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user