Files
session-viewer/src/client/app.tsx
teernisse 4c5d6dd4c8 Extend search to match tool input content, not just message body
The search index (app.tsx) and the per-message match check
(SessionViewer.tsx) now also search msg.toolInput when present.
This means searching for a file path, command, or argument that
appears in a tool call's input will correctly highlight and
navigate to that message, rather than dimming it as a non-match.

Both locations use the same compound condition so the match index
and the visual dimming/focus logic stay in sync.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:34:20 -05:00

212 lines
7.6 KiB
TypeScript

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,
sessionsLoading,
currentSession,
sessionLoading,
loadSession,
} = useSession();
const filters = useFilters();
const filteredMessages = useMemo(() => {
if (!currentSession) return [];
return filters.filterMessages(currentSession.messages);
}, [currentSession, filters.filterMessages]);
// 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) ||
(msg.toolInput && msg.toolInput.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" style={{ background: "var(--color-canvas)" }}>
{/* Sidebar */}
<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
sessions={sessions}
loading={sessionsLoading}
selectedId={currentSession?.id}
onSelect={loadSession}
/>
</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="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>
{/* 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>
</div>
);
}