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,13 @@
import type { MessageCategory } from "./types";
export const CATEGORY_COLORS: Record<MessageCategory, { border: string; bg: string }> = {
user_message: { border: "border-l-blue-500", bg: "bg-blue-50" },
assistant_text: { border: "border-l-emerald-500", bg: "bg-white" },
thinking: { border: "border-l-violet-500", bg: "bg-violet-50" },
tool_call: { border: "border-l-amber-500", bg: "bg-amber-50" },
tool_result: { border: "border-l-indigo-500", bg: "bg-indigo-50" },
system_message: { border: "border-l-gray-500", bg: "bg-gray-100" },
hook_progress: { border: "border-l-gray-400", bg: "bg-gray-50" },
file_snapshot: { border: "border-l-pink-500", bg: "bg-pink-50" },
summary: { border: "border-l-teal-500", bg: "bg-teal-50" },
};

View File

@@ -0,0 +1,50 @@
import { marked } from "marked";
import hljs from "highlight.js";
import { markedHighlight } from "marked-highlight";
marked.use(
markedHighlight({
highlight(code: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
},
})
);
export function renderMarkdown(text: string): string {
if (!text) return "";
try {
return marked.parse(text) as string;
} catch {
return `<p>${escapeHtml(text)}</p>`;
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function highlightSearchText(html: string, query: string): string {
if (!query) return html;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escaped})`, "gi");
// Split HTML into tags and text segments, only highlight within text segments.
// This avoids corrupting tag attributes or self-closing tags.
const parts = html.split(/(<[^>]*>)/);
for (let i = 0; i < parts.length; i++) {
// Even indices are text content, odd indices are tags
if (i % 2 === 0 && parts[i]) {
parts[i] = parts[i].replace(
regex,
'<mark class="search-highlight">$1</mark>'
);
}
}
return parts.join("");
}

14
src/client/lib/types.ts Normal file
View File

@@ -0,0 +1,14 @@
export type {
MessageCategory,
ParsedMessage,
SessionEntry,
SessionListResponse,
SessionDetailResponse,
ExportRequest
} from "@shared/types";
export {
ALL_CATEGORIES,
CATEGORY_LABELS,
DEFAULT_HIDDEN_CATEGORIES
} from "@shared/types";