Add React client: session browser, message viewer, filters, search, redaction, export

Full React 18 client application for interactive session browsing:

app.tsx:
- Root component orchestrating session list, viewer, filters, search,
  redaction controls, and export — wires together useSession and
  useFilters hooks
- Keyboard navigation: j/k or arrow keys for message focus, Escape to
  clear search and redaction selection, "/" to focus search input
- Derives filtered messages, match count, visible UUIDs, and category
  counts via useMemo to avoid render-time side effects

hooks/useSession.ts:
- Manages session list and detail fetching state (loading, error,
  data) with useCallback-wrapped fetch functions
- Auto-loads session list on mount

hooks/useFilters.ts:
- Category filter state with toggle, set-all, and preset support
- Text search with debounced query propagation
- Manual redaction workflow: select messages, confirm to move to
  redacted set, undo individual or all redactions, select-all-visible
- Auto-redact toggle for the sensitive-redactor module
- Returns memoized object to prevent unnecessary re-renders

components/SessionList.tsx:
- Two-phase navigation: project list → session list within a project
- Groups sessions by project, shows session count and latest modified
  date per project, auto-drills into the correct project when a session
  is selected externally
- Formats project directory names back to paths (leading dash → /)

components/SessionViewer.tsx:
- Renders filtered messages with redacted dividers inserted where
  manually redacted messages were removed from the visible sequence
- Loading spinner, empty state for no session / no filter matches
- Scrolls focused message into view via ref

components/MessageBubble.tsx:
- Renders individual messages with category-specific Tailwind border
  and background colors
- Markdown rendering via marked + highlight.js, with search term
  highlighting that splits HTML tags to avoid corrupting attributes
- Click-to-select for manual redaction, visual selection indicator
- Auto-redact mode applies sensitive-redactor to content before render
- dangerouslySetInnerHTML is safe here: content is from local
  user-owned JSONL files, not untrusted external input

components/FilterPanel.tsx:
- Checkbox list for all 9 message categories with auto-redact toggle

components/SearchBar.tsx:
- Debounced text input (200ms) with match count display
- "/" keyboard shortcut to focus, × button to clear

components/ExportButton.tsx:
- POSTs current session + visible/redacted UUIDs + auto-redact flag
  to /api/export, downloads the returned HTML blob as a file

components/RedactedDivider.tsx:
- Dashed-line visual separator indicating redacted content gap

lib/types.ts:
- Re-exports shared types via @shared path alias for client imports

lib/constants.ts:
- Tailwind CSS class mappings per message category (border + bg colors)

lib/markdown.ts:
- Configured marked + highlight.js instance with search highlighting
  that operates on text segments only (preserves HTML tags intact)

styles/main.css:
- Tailwind base/components/utilities, custom scrollbar, highlight.js
  overrides, search highlight mark, redaction selection outline,
  message dimming for non-matching search results

index.html + main.tsx:
- Vite entry point mounting React app into #root with StrictMode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 22:56:37 -05:00
parent 090d69a97a
commit ecd63cd1c3
17 changed files with 1144 additions and 0 deletions

113
src/client/app.tsx Normal file
View File

@@ -0,0 +1,113 @@
import React, { useMemo } from "react";
import { SessionList } from "./components/SessionList";
import { SessionViewer } from "./components/SessionViewer";
import { FilterPanel } from "./components/FilterPanel";
import { SearchBar } from "./components/SearchBar";
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]);
// Derive match count from filtered messages - no setState during render
const matchCount = useMemo(
() => filters.getMatchCount(filteredMessages),
[filters.getMatchCount, filteredMessages]
);
const visibleUuids = useMemo(
() => filteredMessages.map((m) => m.uuid),
[filteredMessages]
);
return (
<div className="flex h-screen bg-gray-50">
{/* 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>
<div className="flex-1 overflow-y-auto">
<SessionList
sessions={sessions}
loading={sessionsLoading}
selectedId={currentSession?.id}
onSelect={loadSession}
/>
</div>
<div className="border-t border-gray-200">
<FilterPanel
enabledCategories={filters.enabledCategories}
onToggle={filters.toggleCategory}
autoRedactEnabled={filters.autoRedactEnabled}
onAutoRedactToggle={filters.setAutoRedactEnabled}
/>
</div>
</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>
<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}
/>
</div>
</div>
</div>
);
}