From 6a4e22f1f88705c8ae0b71455612e3da069d4020 Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 01:10:08 -0500 Subject: [PATCH] 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 --- src/client/app.tsx | 213 +++++++++++++------ src/client/components/SearchBar.tsx | 171 +++++++++++++-- src/client/components/SearchMinimap.test.tsx | 113 ++++++++++ src/client/components/SearchMinimap.tsx | 50 +++++ src/client/components/SessionViewer.tsx | 198 +++++++++++------ 5 files changed, 596 insertions(+), 149 deletions(-) create mode 100644 src/client/components/SearchMinimap.test.tsx create mode 100644 src/client/components/SearchMinimap.tsx diff --git a/src/client/app.tsx b/src/client/app.tsx index 00f46af..4c40467 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -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((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(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

+
+
+

+ Session Viewer +

+

+ Browse and export Claude sessions +

-
- -
+
{/* Main */}
-
- - {filters.selectedForRedaction.size > 0 && ( -
- - {filters.selectedForRedaction.size} selected - - - -
- )} - {currentSession && ( - + {/* 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 && ( + + )} +
-
- +
+ +
+
diff --git a/src/client/components/SearchBar.tsx b/src/client/components/SearchBar.tsx index 1631d2b..3cd903f 100644 --- a/src/client/components/SearchBar.tsx +++ b/src/client/components/SearchBar.tsx @@ -4,11 +4,22 @@ interface Props { query: string; onQueryChange: (q: string) => void; 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(null); const [localQuery, setLocalQuery] = useState(query); + const [isFocused, setIsFocused] = useState(false); const debounceRef = useRef>(); // 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); }, [localQuery, query, onQueryChange]); + // Global keyboard shortcuts useEffect(() => { function handleKeyDown(e: KeyboardEvent) { + // "/" to focus search if ( e.key === "/" && !e.ctrlKey && @@ -35,40 +48,154 @@ export function SearchBar({ query, onQueryChange, matchCount }: Props) { ) { e.preventDefault(); 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); return () => window.removeEventListener("keydown", handleKeyDown); - }, []); + }, [localQuery, onNext, onPrev, onQueryChange]); + + function handleInputKeyDown(e: React.KeyboardEvent) { + 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 ( -
-
+
+ {/* Unified search container */} +
+ {/* Search icon */} +
+ + + +
+ + {/* Input */} setLocalQuery(e.target.value)} - placeholder='Search messages... (press "/" to focus)' - 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" + onKeyDown={handleInputKeyDown} + 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 */} +
+ {/* Match count badge */} + {query && ( +
+ {hasNoResults ? ( + No results + ) : ( + {currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}/{matchCount} + )} +
+ )} + + {/* Navigation arrows — only when there are results */} + {hasResults && ( +
+ + +
+ )} + + {/* Clear button or keyboard hint */} + {localQuery ? ( + + ) : ( + + / + + )} +
- {query && ( - - {matchCount} match{matchCount !== 1 ? "es" : ""} - - )}
); } diff --git a/src/client/components/SearchMinimap.test.tsx b/src/client/components/SearchMinimap.test.tsx new file mode 100644 index 0000000..07563d2 --- /dev/null +++ b/src/client/components/SearchMinimap.test.tsx @@ -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( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when totalMessages is 0", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders a tick for each match", () => { + const { container } = render( + + ); + const ticks = container.querySelectorAll(".search-minimap-tick"); + expect(ticks).toHaveLength(3); + }); + + it("positions ticks proportionally", () => { + const { container } = render( + + ); + 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( + + ); + 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( + + ); + const ticks = container.querySelectorAll(".search-minimap-tick"); + fireEvent.click(ticks[1]); + expect(onClickMatch).toHaveBeenCalledWith(1); + }); + + it("renders a viewport highlight rectangle", () => { + const { container } = render( + + ); + const viewport = container.querySelector(".search-minimap-viewport") as HTMLElement; + expect(viewport).toBeInTheDocument(); + expect(viewport.style.top).toBe("25%"); + expect(viewport.style.height).toBe("40%"); + }); +}); diff --git a/src/client/components/SearchMinimap.tsx b/src/client/components/SearchMinimap.tsx new file mode 100644 index 0000000..990c46e --- /dev/null +++ b/src/client/components/SearchMinimap.tsx @@ -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 ( +