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

View File

@@ -0,0 +1,60 @@
import React, { useState } from "react";
import type { SessionDetailResponse } from "../lib/types";
interface Props {
session: SessionDetailResponse;
visibleMessageUuids: string[];
redactedMessageUuids: string[];
autoRedactEnabled: boolean;
}
export function ExportButton({
session,
visibleMessageUuids,
redactedMessageUuids,
autoRedactEnabled,
}: Props) {
const [exporting, setExporting] = useState(false);
async function handleExport() {
setExporting(true);
try {
const res = await fetch("/api/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session,
visibleMessageUuids,
redactedMessageUuids,
autoRedactEnabled,
}),
});
if (!res.ok) throw new Error(`Export failed: HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `session-${session.id}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
alert("Export failed. Check console for details.");
} finally {
setExporting(false);
}
}
return (
<button
onClick={handleExport}
disabled={exporting}
className="px-4 py-1.5 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 disabled:opacity-50 flex-shrink-0"
>
{exporting ? "Exporting..." : "Export HTML"}
</button>
);
}

View File

@@ -0,0 +1,47 @@
import React from "react";
import type { MessageCategory } from "../lib/types";
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
interface Props {
enabledCategories: Set<MessageCategory>;
onToggle: (cat: MessageCategory) => void;
autoRedactEnabled: boolean;
onAutoRedactToggle: (enabled: boolean) => void;
}
export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) {
return (
<div className="p-3">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
Filters
</div>
<div className="space-y-1">
{ALL_CATEGORIES.map((cat) => (
<label
key={cat}
className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900"
>
<input
type="checkbox"
checked={enabledCategories.has(cat)}
onChange={() => onToggle(cat)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>{CATEGORY_LABELS[cat]}</span>
</label>
))}
</div>
<div className="mt-3 pt-3 border-t border-gray-200">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900">
<input
type="checkbox"
checked={autoRedactEnabled}
onChange={(e) => onAutoRedactToggle(e.target.checked)}
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<span>Auto-redact sensitive info</span>
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import React, { useMemo } from "react";
import type { ParsedMessage } from "../lib/types";
import { CATEGORY_LABELS } from "../lib/types";
import { CATEGORY_COLORS } from "../lib/constants";
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
import { redactMessage } from "../../shared/sensitive-redactor";
interface Props {
message: ParsedMessage;
searchQuery: string;
dimmed: boolean;
selectedForRedaction: boolean;
onToggleRedactionSelection: () => void;
autoRedactEnabled: boolean;
}
/**
* MessageBubble renders session messages using innerHTML.
* This is safe here because content comes only from local JSONL session files
* owned by the user, processed through the `marked` markdown renderer.
* This is a local-only developer tool, not exposed to untrusted input.
*/
export function MessageBubble({
message,
searchQuery,
dimmed,
selectedForRedaction,
onToggleRedactionSelection,
autoRedactEnabled,
}: Props) {
const colors = CATEGORY_COLORS[message.category];
const label = CATEGORY_LABELS[message.category];
const renderedHtml = useMemo(() => {
const msg = autoRedactEnabled ? redactMessage(message) : message;
if (msg.category === "tool_call") {
const inputHtml = msg.toolInput
? `<pre class="bg-gray-50 p-3 rounded text-xs overflow-x-auto mt-2"><code>${escapeHtml(msg.toolInput)}</code></pre>`
: "";
const html = `<div class="font-semibold text-amber-800">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
return searchQuery ? highlightSearchText(html, searchQuery) : html;
}
const html = renderMarkdown(msg.content);
return searchQuery ? highlightSearchText(html, searchQuery) : html;
}, [message, searchQuery, autoRedactEnabled]);
return (
<div
onClick={onToggleRedactionSelection}
className={`
border-l-4 rounded-lg p-4 shadow-sm cursor-pointer transition-all
${colors.border} ${colors.bg}
${dimmed ? "message-dimmed" : ""}
${selectedForRedaction ? "redaction-selected" : ""}
hover:shadow-md
`}
>
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
{label}
</div>
{/* Content from local user-owned JSONL files, not external/untrusted input */}
<div
className="prose prose-sm max-w-none overflow-wrap-break-word"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

View File

@@ -0,0 +1,11 @@
import React from "react";
export function RedactedDivider() {
return (
<div className="flex items-center gap-3 py-2 text-gray-400 text-sm">
<div className="flex-1 border-t border-dashed border-gray-300" />
<span>··· content redacted ···</span>
<div className="flex-1 border-t border-dashed border-gray-300" />
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React, { useRef, useEffect, useState } from "react";
interface Props {
query: string;
onQueryChange: (q: string) => void;
matchCount: number;
}
export function SearchBar({ query, onQueryChange, matchCount }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [localQuery, setLocalQuery] = useState(query);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Sync external query changes (e.g., clearing from Escape key)
useEffect(() => {
setLocalQuery(query);
}, [query]);
// Debounce local input -> parent, but skip if already in sync
useEffect(() => {
if (localQuery === query) return;
debounceRef.current = setTimeout(() => {
onQueryChange(localQuery);
}, 200);
return () => clearTimeout(debounceRef.current);
}, [localQuery, query, onQueryChange]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (
e.key === "/" &&
!e.ctrlKey &&
!e.metaKey &&
document.activeElement?.tagName !== "INPUT"
) {
e.preventDefault();
inputRef.current?.focus();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div className="flex items-center gap-2 flex-1">
<div className="relative flex-1 max-w-md">
<input
ref={inputRef}
type="text"
value={localQuery}
onChange={(e) => 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"
/>
{localQuery && (
<button
onClick={() => {
setLocalQuery("");
onQueryChange("");
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
×
</button>
)}
</div>
{query && (
<span className="text-sm text-gray-500">
{matchCount} match{matchCount !== 1 ? "es" : ""}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { SessionList } from "./SessionList";
import type { SessionEntry } from "../lib/types";
function makeSession(overrides: Partial<SessionEntry> = {}): SessionEntry {
return {
id: "sess-1",
summary: "Test session",
firstPrompt: "Hello",
project: "-data-projects-my-app",
created: "2025-01-15T10:00:00Z",
modified: "2025-01-15T12:00:00Z",
messageCount: 5,
path: "/tmp/sess-1",
...overrides,
};
}
const sessions: SessionEntry[] = [
makeSession({ id: "s1", project: "-data-projects-alpha", summary: "Alpha session 1", modified: "2025-01-15T12:00:00Z" }),
makeSession({ id: "s2", project: "-data-projects-alpha", summary: "Alpha session 2", modified: "2025-01-14T10:00:00Z" }),
makeSession({ id: "s3", project: "-data-projects-beta", summary: "Beta session 1", modified: "2025-01-13T08:00:00Z" }),
];
describe("SessionList", () => {
it("renders project list by default", () => {
render(
<SessionList sessions={sessions} loading={false} onSelect={vi.fn()} />
);
// Should show project names, not individual sessions
expect(screen.getByText(/alpha/i)).toBeInTheDocument();
expect(screen.getByText(/beta/i)).toBeInTheDocument();
expect(screen.getByText("2 sessions")).toBeInTheDocument();
expect(screen.getByText("1 session")).toBeInTheDocument();
// Should NOT show individual session summaries at project level
expect(screen.queryByText("Alpha session 1")).not.toBeInTheDocument();
expect(screen.queryByText("Beta session 1")).not.toBeInTheDocument();
});
it("clicking a project shows its sessions", () => {
render(
<SessionList sessions={sessions} loading={false} onSelect={vi.fn()} />
);
// Click the alpha project
fireEvent.click(screen.getByText(/alpha/i));
// Should show sessions for alpha
expect(screen.getByText("Alpha session 1")).toBeInTheDocument();
expect(screen.getByText("Alpha session 2")).toBeInTheDocument();
// Should NOT show beta sessions
expect(screen.queryByText("Beta session 1")).not.toBeInTheDocument();
// Should show back button
expect(screen.getByText(/all projects/i)).toBeInTheDocument();
});
it("back button returns to project list", () => {
render(
<SessionList sessions={sessions} loading={false} onSelect={vi.fn()} />
);
// Drill into alpha
fireEvent.click(screen.getByText(/alpha/i));
expect(screen.getByText("Alpha session 1")).toBeInTheDocument();
// Click back
fireEvent.click(screen.getByText(/all projects/i));
// Should be back at project list
expect(screen.getByText("2 sessions")).toBeInTheDocument();
expect(screen.queryByText("Alpha session 1")).not.toBeInTheDocument();
});
it("auto-selects project when selectedId matches a session", () => {
render(
<SessionList
sessions={sessions}
loading={false}
selectedId="s3"
onSelect={vi.fn()}
/>
);
// Should auto-drill into beta since s3 belongs to beta
expect(screen.getByText("Beta session 1")).toBeInTheDocument();
expect(screen.getByText(/all projects/i)).toBeInTheDocument();
});
it("shows loading state", () => {
render(
<SessionList sessions={[]} loading={true} onSelect={vi.fn()} />
);
expect(screen.getByText("Loading sessions...")).toBeInTheDocument();
});
it("shows empty state", () => {
render(
<SessionList sessions={[]} loading={false} onSelect={vi.fn()} />
);
expect(screen.getByText("No sessions found")).toBeInTheDocument();
});
it("calls onSelect when clicking a session", () => {
const onSelect = vi.fn();
render(
<SessionList sessions={sessions} loading={false} onSelect={onSelect} />
);
// Drill into alpha
fireEvent.click(screen.getByText(/alpha/i));
// Click a session
fireEvent.click(screen.getByText("Alpha session 1"));
expect(onSelect).toHaveBeenCalledWith("s1");
});
});

View File

@@ -0,0 +1,130 @@
import React, { useState, useEffect } from "react";
import type { SessionEntry } from "../lib/types";
interface Props {
sessions: SessionEntry[];
loading: boolean;
selectedId?: string;
onSelect: (id: string) => void;
}
export function SessionList({ sessions, loading, selectedId, onSelect }: Props) {
const [selectedProject, setSelectedProject] = useState<string | null>(null);
// Group by project
const grouped = new Map<string, SessionEntry[]>();
for (const session of sessions) {
const group = grouped.get(session.project) || [];
group.push(session);
grouped.set(session.project, group);
}
// Auto-select project when selectedId changes
useEffect(() => {
if (selectedId) {
const match = sessions.find((s) => s.id === selectedId);
if (match) {
setSelectedProject(match.project);
}
}
}, [selectedId, sessions]);
if (loading) {
return (
<div className="p-4 text-sm text-gray-500">Loading sessions...</div>
);
}
if (sessions.length === 0) {
return (
<div className="p-4 text-sm text-gray-500">No sessions found</div>
);
}
// Phase 2: Session list for selected project
if (selectedProject !== null) {
const projectSessions = grouped.get(selectedProject) || [];
return (
<div className="py-2">
<button
onClick={() => setSelectedProject(null)}
className="w-full text-left px-4 py-2 text-sm text-blue-600 hover:text-blue-800 hover:bg-gray-50 transition-colors flex items-center gap-1"
>
<span></span>
<span>All Projects</span>
</button>
<div className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50">
{formatProjectName(selectedProject)}
</div>
{projectSessions.map((session) => (
<button
key={session.id}
onClick={() => onSelect(session.id)}
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors ${
selectedId === session.id ? "bg-blue-50 border-l-2 border-l-blue-500" : ""
}`}
>
<div className="text-sm font-medium text-gray-900 truncate">
{session.summary || session.firstPrompt || "Untitled Session"}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{formatDate(session.modified || session.created)}</span>
<span>·</span>
<span>{session.messageCount} msgs</span>
</div>
</button>
))}
</div>
);
}
// Phase 1: Project list
return (
<div className="py-2">
{[...grouped.entries()].map(([project, projectSessions]) => {
const latest = projectSessions.reduce((a, b) =>
(a.modified || a.created) > (b.modified || b.created) ? a : b
);
const count = projectSessions.length;
return (
<button
key={project}
onClick={() => setSelectedProject(project)}
className="w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
<div className="text-sm font-medium text-gray-900 truncate">
{formatProjectName(project)}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{count} {count === 1 ? "session" : "sessions"}</span>
<span>·</span>
<span>{formatDate(latest.modified || latest.created)}</span>
</div>
</button>
);
})}
</div>
);
}
function formatProjectName(project: string): string {
if (project.startsWith("-")) {
return project.replace(/^-/, "/").replace(/-/g, "/");
}
return project;
}
function formatDate(dateStr: string): string {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
}

View File

@@ -0,0 +1,122 @@
import React, { useRef, useEffect } from "react";
import type { ParsedMessage } from "../lib/types";
import { MessageBubble } from "./MessageBubble";
import { RedactedDivider } from "./RedactedDivider";
interface Props {
messages: ParsedMessage[];
allMessages: ParsedMessage[];
redactedUuids: Set<string>;
loading: boolean;
searchQuery: string;
selectedForRedaction: Set<string>;
onToggleRedactionSelection: (uuid: string) => void;
autoRedactEnabled: boolean;
focusedIndex?: number;
}
export function SessionViewer({
messages,
allMessages,
redactedUuids,
loading,
searchQuery,
selectedForRedaction,
onToggleRedactionSelection,
autoRedactEnabled,
focusedIndex = -1,
}: Props) {
const focusedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focusedIndex >= 0 && focusedRef.current) {
focusedRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [focusedIndex]);
if (loading) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
<div className="text-center">
<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 displayItems: Array<
| { type: "message"; message: ParsedMessage; messageIndex: number }
| { type: "redacted_divider"; key: string }
> = [];
let prevWasRedactedGap = false;
let messageIndex = 0;
for (const msg of allMessages) {
if (redactedUuids.has(msg.uuid)) {
prevWasRedactedGap = true;
continue;
}
if (!visibleUuids.has(msg.uuid)) {
continue;
}
if (prevWasRedactedGap) {
displayItems.push({
type: "redacted_divider",
key: `divider-${msg.uuid}`,
});
prevWasRedactedGap = false;
}
displayItems.push({ type: "message", message: msg, messageIndex });
messageIndex++;
}
return (
<div className="max-w-4xl mx-auto p-4 space-y-3">
<div className="text-sm text-gray-500 mb-4">
{messages.length} message{messages.length !== 1 ? "s" : ""}
</div>
{displayItems.map((item) => {
if (item.type === "redacted_divider") {
return <RedactedDivider key={item.key} />;
}
const msg = item.message;
const isMatch =
searchQuery &&
msg.content.toLowerCase().includes(searchQuery.toLowerCase());
const isDimmed = searchQuery && !isMatch;
return (
<MessageBubble
key={msg.uuid}
message={msg}
searchQuery={searchQuery}
dimmed={!!isDimmed}
selectedForRedaction={selectedForRedaction.has(msg.uuid)}
onToggleRedactionSelection={() =>
onToggleRedactionSelection(msg.uuid)
}
autoRedactEnabled={autoRedactEnabled}
/>
);
})}
</div>
);
}