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 { SessionList } from "./components/SessionList";
|
||||||
import { SessionViewer } from "./components/SessionViewer";
|
import { SessionViewer } from "./components/SessionViewer";
|
||||||
import { FilterPanel } from "./components/FilterPanel";
|
import { FilterPanel } from "./components/FilterPanel";
|
||||||
import { SearchBar } from "./components/SearchBar";
|
import { SearchBar } from "./components/SearchBar";
|
||||||
|
import { SearchMinimap } from "./components/SearchMinimap";
|
||||||
import { ExportButton } from "./components/ExportButton";
|
import { ExportButton } from "./components/ExportButton";
|
||||||
import { useSession } from "./hooks/useSession";
|
import { useSession } from "./hooks/useSession";
|
||||||
import { useFilters } from "./hooks/useFilters";
|
import { useFilters } from "./hooks/useFilters";
|
||||||
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
@@ -23,23 +25,91 @@ export function App() {
|
|||||||
return filters.filterMessages(currentSession.messages);
|
return filters.filterMessages(currentSession.messages);
|
||||||
}, [currentSession, filters.filterMessages]);
|
}, [currentSession, filters.filterMessages]);
|
||||||
|
|
||||||
// Derive match count from filtered messages - no setState during render
|
// Track which filtered-message indices match the search query
|
||||||
const matchCount = useMemo(
|
const matchIndices = useMemo(() => {
|
||||||
() => filters.getMatchCount(filteredMessages),
|
if (!filters.searchQuery) return [];
|
||||||
[filters.getMatchCount, filteredMessages]
|
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(
|
const visibleUuids = useMemo(
|
||||||
() => filteredMessages.map((m) => m.uuid),
|
() => filteredMessages.map((m) => m.uuid),
|
||||||
[filteredMessages]
|
[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 (
|
return (
|
||||||
<div className="flex h-screen bg-gray-50">
|
<div className="flex h-screen" style={{ background: "var(--color-canvas)" }}>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col">
|
||||||
<div className="p-4 border-b border-gray-200">
|
<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-lg font-bold text-gray-900">Session Viewer</h1>
|
<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>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<SessionList
|
<SessionList
|
||||||
@@ -49,7 +119,6 @@ export function App() {
|
|||||||
onSelect={loadSession}
|
onSelect={loadSession}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-gray-200">
|
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
enabledCategories={filters.enabledCategories}
|
enabledCategories={filters.enabledCategories}
|
||||||
onToggle={filters.toggleCategory}
|
onToggle={filters.toggleCategory}
|
||||||
@@ -57,35 +126,46 @@ export function App() {
|
|||||||
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<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">
|
<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
|
<SearchBar
|
||||||
query={filters.searchQuery}
|
query={filters.searchQuery}
|
||||||
onQueryChange={filters.setSearchQuery}
|
onQueryChange={filters.setSearchQuery}
|
||||||
matchCount={matchCount}
|
matchCount={matchCount}
|
||||||
|
currentMatchPosition={currentMatchPosition}
|
||||||
|
onNext={goToNextMatch}
|
||||||
|
onPrev={goToPrevMatch}
|
||||||
/>
|
/>
|
||||||
{filters.selectedForRedaction.size > 0 && (
|
{filters.selectedForRedaction.size > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<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-sm text-red-600 font-medium">
|
<span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap">
|
||||||
{filters.selectedForRedaction.size} selected
|
{filters.selectedForRedaction.size} selected
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={filters.confirmRedaction}
|
onClick={filters.confirmRedaction}
|
||||||
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700"
|
className="btn btn-sm btn-danger"
|
||||||
>
|
>
|
||||||
Redact Selected
|
Redact
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={filters.clearRedactionSelection}
|
onClick={filters.clearRedactionSelection}
|
||||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800"
|
className="btn btn-sm btn-ghost text-red-400/70 hover:text-red-300"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — export button, right-justified */}
|
||||||
|
<div className="flex-1 min-w-0 flex justify-end">
|
||||||
{currentSession && (
|
{currentSession && (
|
||||||
<ExportButton
|
<ExportButton
|
||||||
session={currentSession}
|
session={currentSession}
|
||||||
@@ -95,7 +175,10 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
</div>
|
||||||
|
{/* 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
|
<SessionViewer
|
||||||
messages={filteredMessages}
|
messages={filteredMessages}
|
||||||
allMessages={currentSession?.messages || []}
|
allMessages={currentSession?.messages || []}
|
||||||
@@ -105,6 +188,16 @@ export function App() {
|
|||||||
selectedForRedaction={filters.selectedForRedaction}
|
selectedForRedaction={filters.selectedForRedaction}
|
||||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||||
autoRedactEnabled={filters.autoRedactEnabled}
|
autoRedactEnabled={filters.autoRedactEnabled}
|
||||||
|
focusedIndex={focusedMessageIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SearchMinimap
|
||||||
|
matchIndices={matchIndices}
|
||||||
|
totalMessages={filteredMessages.length}
|
||||||
|
currentPosition={currentMatchPosition}
|
||||||
|
onClickMatch={setCurrentMatchPosition}
|
||||||
|
viewportTop={viewportTop}
|
||||||
|
viewportHeight={viewportHeight}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,22 @@ interface Props {
|
|||||||
query: string;
|
query: string;
|
||||||
onQueryChange: (q: string) => void;
|
onQueryChange: (q: string) => void;
|
||||||
matchCount: number;
|
matchCount: number;
|
||||||
|
currentMatchPosition: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchBar({ query, onQueryChange, matchCount }: Props) {
|
export function SearchBar({
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
matchCount,
|
||||||
|
currentMatchPosition,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
}: Props) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [localQuery, setLocalQuery] = useState(query);
|
const [localQuery, setLocalQuery] = useState(query);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
// Sync external query changes (e.g., clearing from Escape key)
|
// Sync external query changes (e.g., clearing from Escape key)
|
||||||
@@ -25,8 +36,10 @@ export function SearchBar({ query, onQueryChange, matchCount }: Props) {
|
|||||||
return () => clearTimeout(debounceRef.current);
|
return () => clearTimeout(debounceRef.current);
|
||||||
}, [localQuery, query, onQueryChange]);
|
}, [localQuery, query, onQueryChange]);
|
||||||
|
|
||||||
|
// Global keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// "/" to focus search
|
||||||
if (
|
if (
|
||||||
e.key === "/" &&
|
e.key === "/" &&
|
||||||
!e.ctrlKey &&
|
!e.ctrlKey &&
|
||||||
@@ -35,40 +48,154 @@ export function SearchBar({ query, onQueryChange, matchCount }: Props) {
|
|||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape to blur and clear
|
||||||
|
if (e.key === "Escape" && document.activeElement === inputRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (localQuery) {
|
||||||
|
setLocalQuery("");
|
||||||
|
onQueryChange("");
|
||||||
|
}
|
||||||
|
inputRef.current?.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+G / Ctrl+Shift+G for next/prev match
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
onPrev();
|
||||||
|
} else {
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, []);
|
}, [localQuery, onNext, onPrev, onQueryChange]);
|
||||||
|
|
||||||
|
function handleInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
// Flush debounce immediately
|
||||||
|
if (localQuery !== query) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
onQueryChange(localQuery);
|
||||||
|
}
|
||||||
|
if (e.shiftKey) {
|
||||||
|
onPrev();
|
||||||
|
} else {
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResults = query && matchCount > 0;
|
||||||
|
const hasNoResults = query && matchCount === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="w-80 sm:w-96">
|
||||||
<div className="relative flex-1 max-w-md">
|
{/* Unified search container */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative flex items-center rounded-xl transition-all duration-200
|
||||||
|
${isFocused
|
||||||
|
? "bg-surface ring-2 ring-accent/25 border-accent/50 shadow-glow-accent"
|
||||||
|
: "bg-surface-inset hover:bg-surface-inset/80"
|
||||||
|
}
|
||||||
|
border
|
||||||
|
${hasNoResults ? "border-red-500/30" : "border-border-muted"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Search icon */}
|
||||||
|
<div className={`pl-3.5 flex-shrink-0 transition-colors duration-200 ${isFocused ? "text-accent" : "text-foreground-muted"}`}>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={localQuery}
|
value={localQuery}
|
||||||
onChange={(e) => setLocalQuery(e.target.value)}
|
onChange={(e) => setLocalQuery(e.target.value)}
|
||||||
placeholder='Search messages... (press "/" to focus)'
|
onKeyDown={handleInputKeyDown}
|
||||||
className="w-full pl-3 pr-8 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
placeholder="Search messages..."
|
||||||
|
className="flex-1 min-w-0 bg-transparent px-2.5 py-2 text-body text-foreground
|
||||||
|
placeholder:text-foreground-muted
|
||||||
|
focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{localQuery && (
|
|
||||||
|
{/* Right-side controls — all inside the unified bar */}
|
||||||
|
<div className="flex items-center gap-0.5 pr-2 flex-shrink-0">
|
||||||
|
{/* Match count badge */}
|
||||||
|
{query && (
|
||||||
|
<div className={`
|
||||||
|
flex items-center gap-1 px-2 py-0.5 rounded-md text-caption tabular-nums whitespace-nowrap mr-0.5
|
||||||
|
${hasNoResults
|
||||||
|
? "text-red-400 bg-red-500/10"
|
||||||
|
: "text-foreground-muted bg-surface-overlay/50"
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{hasNoResults ? (
|
||||||
|
<span>No results</span>
|
||||||
|
) : (
|
||||||
|
<span>{currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}<span className="text-foreground-muted/50 mx-0.5">/</span>{matchCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation arrows — only when there are results */}
|
||||||
|
{hasResults && (
|
||||||
|
<div className="flex items-center border-l border-border-muted/60 ml-1 pl-1">
|
||||||
|
<button
|
||||||
|
onClick={onPrev}
|
||||||
|
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
|
||||||
|
aria-label="Previous match"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
|
||||||
|
aria-label="Next match"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear button or keyboard hint */}
|
||||||
|
{localQuery ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLocalQuery("");
|
setLocalQuery("");
|
||||||
onQueryChange("");
|
onQueryChange("");
|
||||||
|
inputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors ml-0.5"
|
||||||
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 text-caption text-foreground-muted/70 bg-surface-overlay/40 border border-border-muted rounded font-mono leading-none">
|
||||||
|
/
|
||||||
|
</kbd>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{query && (
|
</div>
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{matchCount} match{matchCount !== 1 ? "es" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
113
src/client/components/SearchMinimap.test.tsx
Normal file
113
src/client/components/SearchMinimap.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { render, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { SearchMinimap } from "./SearchMinimap";
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
onClickMatch: vi.fn(),
|
||||||
|
viewportTop: 0,
|
||||||
|
viewportHeight: 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SearchMinimap", () => {
|
||||||
|
it("renders nothing when matchIndices is empty", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SearchMinimap
|
||||||
|
matchIndices={[]}
|
||||||
|
totalMessages={10}
|
||||||
|
currentPosition={-1}
|
||||||
|
{...defaultProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when totalMessages is 0", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SearchMinimap
|
||||||
|
matchIndices={[0]}
|
||||||
|
totalMessages={0}
|
||||||
|
currentPosition={0}
|
||||||
|
{...defaultProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a tick for each match", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SearchMinimap
|
||||||
|
matchIndices={[2, 5, 8]}
|
||||||
|
totalMessages={10}
|
||||||
|
currentPosition={0}
|
||||||
|
{...defaultProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const ticks = container.querySelectorAll(".search-minimap-tick");
|
||||||
|
expect(ticks).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("positions ticks proportionally", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SearchMinimap
|
||||||
|
matchIndices={[5]}
|
||||||
|
totalMessages={10}
|
||||||
|
currentPosition={0}
|
||||||
|
{...defaultProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const tick = container.querySelector(".search-minimap-tick") as HTMLElement;
|
||||||
|
expect(tick.style.top).toBe("50%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the active tick with the active class", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SearchMinimap
|
||||||
|
matchIndices={[1, 3, 7]}
|
||||||
|
totalMessages={10}
|
||||||
|
currentPosition={1}
|
||||||
|
{...defaultProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const ticks = container.querySelectorAll(".search-minimap-tick");
|
||||||
|
expect(ticks[0]).not.toHaveClass("search-minimap-tick-active");
|
||||||
|
expect(ticks[1]).toHaveClass("search-minimap-tick-active");
|
||||||
|
expect(ticks[2]).not.toHaveClass("search-minimap-tick-active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClickMatch with the position index when a tick is clicked", () => {
|
||||||
|
const onClickMatch = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<SearchMinimap
|
||||||
|
matchIndices={[2, 5]}
|
||||||
|
totalMessages={10}
|
||||||
|
currentPosition={0}
|
||||||
|
onClickMatch={onClickMatch}
|
||||||
|
viewportTop={0}
|
||||||
|
viewportHeight={0.3}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const ticks = container.querySelectorAll(".search-minimap-tick");
|
||||||
|
fireEvent.click(ticks[1]);
|
||||||
|
expect(onClickMatch).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a viewport highlight rectangle", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SearchMinimap
|
||||||
|
matchIndices={[0, 5]}
|
||||||
|
totalMessages={10}
|
||||||
|
currentPosition={0}
|
||||||
|
onClickMatch={vi.fn()}
|
||||||
|
viewportTop={0.25}
|
||||||
|
viewportHeight={0.4}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const viewport = container.querySelector(".search-minimap-viewport") as HTMLElement;
|
||||||
|
expect(viewport).toBeInTheDocument();
|
||||||
|
expect(viewport.style.top).toBe("25%");
|
||||||
|
expect(viewport.style.height).toBe("40%");
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/client/components/SearchMinimap.tsx
Normal file
50
src/client/components/SearchMinimap.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
matchIndices: number[];
|
||||||
|
totalMessages: number;
|
||||||
|
currentPosition: number;
|
||||||
|
onClickMatch: (position: number) => void;
|
||||||
|
/** scrollTop / scrollHeight ratio (0-1) */
|
||||||
|
viewportTop: number;
|
||||||
|
/** clientHeight / scrollHeight ratio (0-1, clamped) */
|
||||||
|
viewportHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchMinimap({
|
||||||
|
matchIndices,
|
||||||
|
totalMessages,
|
||||||
|
currentPosition,
|
||||||
|
onClickMatch,
|
||||||
|
viewportTop,
|
||||||
|
viewportHeight,
|
||||||
|
}: Props) {
|
||||||
|
if (matchIndices.length === 0 || totalMessages === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-minimap" aria-hidden="true">
|
||||||
|
{/* Viewport highlight */}
|
||||||
|
<div
|
||||||
|
className="search-minimap-viewport"
|
||||||
|
style={{
|
||||||
|
top: `${viewportTop * 100}%`,
|
||||||
|
height: `${viewportHeight * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Match ticks */}
|
||||||
|
{matchIndices.map((msgIndex, pos) => {
|
||||||
|
const top = (msgIndex / totalMessages) * 100;
|
||||||
|
const isActive = pos === currentPosition;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={msgIndex}
|
||||||
|
className={`search-minimap-tick${isActive ? " search-minimap-tick-active" : ""}`}
|
||||||
|
style={{ top: `${top}%` }}
|
||||||
|
onClick={() => onClickMatch(pos)}
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useEffect } from "react";
|
import React, { useRef, useEffect, useMemo } from "react";
|
||||||
import type { ParsedMessage } from "../lib/types";
|
import type { ParsedMessage } from "../lib/types";
|
||||||
import { MessageBubble } from "./MessageBubble";
|
import { MessageBubble } from "./MessageBubble";
|
||||||
import { RedactedDivider } from "./RedactedDivider";
|
import { RedactedDivider } from "./RedactedDivider";
|
||||||
@@ -15,6 +15,25 @@ interface Props {
|
|||||||
focusedIndex?: number;
|
focusedIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border border-border-muted bg-surface-raised p-4 space-y-3"
|
||||||
|
style={{ animationDelay: `${delay}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="skeleton w-2 h-2 rounded-full" />
|
||||||
|
<div className="skeleton h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="skeleton h-4 w-full" />
|
||||||
|
<div className="skeleton h-4 w-5/6" />
|
||||||
|
<div className="skeleton h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionViewer({
|
export function SessionViewer({
|
||||||
messages,
|
messages,
|
||||||
allMessages,
|
allMessages,
|
||||||
@@ -26,44 +45,26 @@ export function SessionViewer({
|
|||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
focusedIndex = -1,
|
focusedIndex = -1,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const focusedRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusedIndex >= 0 && focusedRef.current) {
|
if (focusedIndex >= 0 && containerRef.current) {
|
||||||
focusedRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
const el = containerRef.current.querySelector(
|
||||||
|
`[data-msg-index="${focusedIndex}"]`
|
||||||
|
);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [focusedIndex]);
|
}, [focusedIndex]);
|
||||||
|
|
||||||
if (loading) {
|
// Build display list with redacted dividers.
|
||||||
return (
|
// Must be called before any early returns to satisfy Rules of Hooks.
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
const displayItems = useMemo(() => {
|
||||||
<div className="text-center">
|
if (messages.length === 0) return [];
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-400 mx-auto mb-3"></div>
|
|
||||||
Loading session...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.length === 0 && allMessages.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
|
||||||
Select a session to view
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
|
||||||
No messages match current filters
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build display list with redacted dividers
|
|
||||||
const visibleUuids = new Set(messages.map((m) => m.uuid));
|
const visibleUuids = new Set(messages.map((m) => m.uuid));
|
||||||
const displayItems: Array<
|
const items: Array<
|
||||||
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
||||||
| { type: "redacted_divider"; key: string }
|
| { type: "redacted_divider"; key: string }
|
||||||
> = [];
|
> = [];
|
||||||
@@ -79,22 +80,77 @@ export function SessionViewer({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (prevWasRedactedGap) {
|
if (prevWasRedactedGap) {
|
||||||
displayItems.push({
|
items.push({
|
||||||
type: "redacted_divider",
|
type: "redacted_divider",
|
||||||
key: `divider-${msg.uuid}`,
|
key: `divider-${msg.uuid}`,
|
||||||
});
|
});
|
||||||
prevWasRedactedGap = false;
|
prevWasRedactedGap = false;
|
||||||
}
|
}
|
||||||
displayItems.push({ type: "message", message: msg, messageIndex });
|
items.push({ type: "message", message: msg, messageIndex });
|
||||||
messageIndex++;
|
messageIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [messages, allMessages, redactedUuids]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-4 space-y-3">
|
<div className="max-w-6xl mx-auto p-6 space-y-4">
|
||||||
<div className="text-sm text-gray-500 mb-4">
|
<div className="skeleton h-4 w-24 mb-2" />
|
||||||
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<MessageSkeleton key={i} delay={i * 100} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{displayItems.map((item) => {
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0 && allMessages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center max-w-sm animate-fade-in">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
|
||||||
|
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
|
||||||
|
>
|
||||||
|
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-subheading font-medium text-foreground">Select a session</p>
|
||||||
|
<p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center max-w-sm animate-fade-in">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
|
||||||
|
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
|
||||||
|
>
|
||||||
|
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-subheading font-medium text-foreground">No matching messages</p>
|
||||||
|
<p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-caption text-foreground-muted tabular-nums">
|
||||||
|
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref={containerRef} className="space-y-3">
|
||||||
|
{displayItems.map((item, idx) => {
|
||||||
if (item.type === "redacted_divider") {
|
if (item.type === "redacted_divider") {
|
||||||
return <RedactedDivider key={item.key} />;
|
return <RedactedDivider key={item.key} />;
|
||||||
}
|
}
|
||||||
@@ -103,9 +159,15 @@ export function SessionViewer({
|
|||||||
searchQuery &&
|
searchQuery &&
|
||||||
msg.content.toLowerCase().includes(searchQuery.toLowerCase());
|
msg.content.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const isDimmed = searchQuery && !isMatch;
|
const isDimmed = searchQuery && !isMatch;
|
||||||
|
const isFocused = item.messageIndex === focusedIndex;
|
||||||
return (
|
return (
|
||||||
<MessageBubble
|
<div
|
||||||
key={msg.uuid}
|
key={msg.uuid}
|
||||||
|
data-msg-index={item.messageIndex}
|
||||||
|
className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`}
|
||||||
|
style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }}
|
||||||
|
>
|
||||||
|
<MessageBubble
|
||||||
message={msg}
|
message={msg}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
dimmed={!!isDimmed}
|
dimmed={!!isDimmed}
|
||||||
@@ -115,8 +177,10 @@ export function SessionViewer({
|
|||||||
}
|
}
|
||||||
autoRedactEnabled={autoRedactEnabled}
|
autoRedactEnabled={autoRedactEnabled}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user