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>
212 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|