diff --git a/src/client/app.tsx b/src/client/app.tsx
new file mode 100644
index 0000000..00f46af
--- /dev/null
+++ b/src/client/app.tsx
@@ -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 (
+
+ {/* Sidebar */}
+
+
+
Session Viewer
+
+
+
+
+
+
+
+
+
+ {/* Main */}
+
+
+
+ {filters.selectedForRedaction.size > 0 && (
+
+
+ {filters.selectedForRedaction.size} selected
+
+
+
+
+ )}
+ {currentSession && (
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/client/components/ExportButton.tsx b/src/client/components/ExportButton.tsx
new file mode 100644
index 0000000..3940966
--- /dev/null
+++ b/src/client/components/ExportButton.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/client/components/FilterPanel.tsx b/src/client/components/FilterPanel.tsx
new file mode 100644
index 0000000..11fdbdb
--- /dev/null
+++ b/src/client/components/FilterPanel.tsx
@@ -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;
+ onToggle: (cat: MessageCategory) => void;
+ autoRedactEnabled: boolean;
+ onAutoRedactToggle: (enabled: boolean) => void;
+}
+
+export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) {
+ return (
+
+
+ Filters
+
+
+ {ALL_CATEGORIES.map((cat) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/client/components/MessageBubble.tsx b/src/client/components/MessageBubble.tsx
new file mode 100644
index 0000000..fb9001c
--- /dev/null
+++ b/src/client/components/MessageBubble.tsx
@@ -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
+ ? `${escapeHtml(msg.toolInput)}
`
+ : "";
+ const html = `${escapeHtml(msg.toolName || "Unknown Tool")}
${inputHtml}`;
+ return searchQuery ? highlightSearchText(html, searchQuery) : html;
+ }
+ const html = renderMarkdown(msg.content);
+ return searchQuery ? highlightSearchText(html, searchQuery) : html;
+ }, [message, searchQuery, autoRedactEnabled]);
+
+ return (
+
+
+ {label}
+
+ {/* Content from local user-owned JSONL files, not external/untrusted input */}
+
+
+ );
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
diff --git a/src/client/components/RedactedDivider.tsx b/src/client/components/RedactedDivider.tsx
new file mode 100644
index 0000000..d18d49c
--- /dev/null
+++ b/src/client/components/RedactedDivider.tsx
@@ -0,0 +1,11 @@
+import React from "react";
+
+export function RedactedDivider() {
+ return (
+
+
+
··· content redacted ···
+
+
+ );
+}
diff --git a/src/client/components/SearchBar.tsx b/src/client/components/SearchBar.tsx
new file mode 100644
index 0000000..1631d2b
--- /dev/null
+++ b/src/client/components/SearchBar.tsx
@@ -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(null);
+ const [localQuery, setLocalQuery] = useState(query);
+ const debounceRef = useRef>();
+
+ // 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 (
+
+
+ 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 && (
+
+ )}
+
+ {query && (
+
+ {matchCount} match{matchCount !== 1 ? "es" : ""}
+
+ )}
+
+ );
+}
diff --git a/src/client/components/SessionList.test.tsx b/src/client/components/SessionList.test.tsx
new file mode 100644
index 0000000..fbd97dc
--- /dev/null
+++ b/src/client/components/SessionList.test.tsx
@@ -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 {
+ 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(
+
+ );
+
+ // 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(
+
+ );
+
+ // 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(
+
+ );
+
+ // 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(
+
+ );
+
+ // 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(
+
+ );
+ expect(screen.getByText("Loading sessions...")).toBeInTheDocument();
+ });
+
+ it("shows empty state", () => {
+ render(
+
+ );
+ expect(screen.getByText("No sessions found")).toBeInTheDocument();
+ });
+
+ it("calls onSelect when clicking a session", () => {
+ const onSelect = vi.fn();
+ render(
+
+ );
+
+ // Drill into alpha
+ fireEvent.click(screen.getByText(/alpha/i));
+
+ // Click a session
+ fireEvent.click(screen.getByText("Alpha session 1"));
+ expect(onSelect).toHaveBeenCalledWith("s1");
+ });
+});
diff --git a/src/client/components/SessionList.tsx b/src/client/components/SessionList.tsx
new file mode 100644
index 0000000..c507761
--- /dev/null
+++ b/src/client/components/SessionList.tsx
@@ -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(null);
+
+ // Group by project
+ const grouped = new Map();
+ 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 (
+ Loading sessions...
+ );
+ }
+
+ if (sessions.length === 0) {
+ return (
+ No sessions found
+ );
+ }
+
+ // Phase 2: Session list for selected project
+ if (selectedProject !== null) {
+ const projectSessions = grouped.get(selectedProject) || [];
+ return (
+
+
+
+ {formatProjectName(selectedProject)}
+
+ {projectSessions.map((session) => (
+
+ ))}
+
+ );
+ }
+
+ // Phase 1: Project list
+ return (
+
+ {[...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 (
+
+ );
+ })}
+
+ );
+}
+
+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;
+ }
+}
diff --git a/src/client/components/SessionViewer.tsx b/src/client/components/SessionViewer.tsx
new file mode 100644
index 0000000..0972493
--- /dev/null
+++ b/src/client/components/SessionViewer.tsx
@@ -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;
+ loading: boolean;
+ searchQuery: string;
+ selectedForRedaction: Set;
+ 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(null);
+
+ useEffect(() => {
+ if (focusedIndex >= 0 && focusedRef.current) {
+ focusedRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ }, [focusedIndex]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (messages.length === 0 && allMessages.length === 0) {
+ return (
+
+ Select a session to view
+
+ );
+ }
+
+ if (messages.length === 0) {
+ return (
+
+ No messages match current filters
+
+ );
+ }
+
+ // 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 (
+
+
+ {messages.length} message{messages.length !== 1 ? "s" : ""}
+
+ {displayItems.map((item) => {
+ if (item.type === "redacted_divider") {
+ return
;
+ }
+ const msg = item.message;
+ const isMatch =
+ searchQuery &&
+ msg.content.toLowerCase().includes(searchQuery.toLowerCase());
+ const isDimmed = searchQuery && !isMatch;
+ return (
+
+ onToggleRedactionSelection(msg.uuid)
+ }
+ autoRedactEnabled={autoRedactEnabled}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/src/client/hooks/useFilters.ts b/src/client/hooks/useFilters.ts
new file mode 100644
index 0000000..36d89f1
--- /dev/null
+++ b/src/client/hooks/useFilters.ts
@@ -0,0 +1,174 @@
+import { useState, useCallback, useMemo } from "react";
+import type { MessageCategory, ParsedMessage } from "../lib/types";
+import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../lib/types";
+
+interface FilterState {
+ enabledCategories: Set;
+ toggleCategory: (cat: MessageCategory) => void;
+ setAllCategories: (enabled: boolean) => void;
+ filterMessages: (messages: ParsedMessage[]) => ParsedMessage[];
+ searchQuery: string;
+ setSearchQuery: (q: string) => void;
+ redactedUuids: Set;
+ selectedForRedaction: Set;
+ toggleRedactionSelection: (uuid: string) => void;
+ confirmRedaction: () => void;
+ clearRedactionSelection: () => void;
+ getMatchCount: (messages: ParsedMessage[]) => number;
+ autoRedactEnabled: boolean;
+ setAutoRedactEnabled: (enabled: boolean) => void;
+}
+
+export function useFilters(): FilterState {
+ const [enabledCategories, setEnabledCategories] = useState<
+ Set
+ >(() => {
+ const set = new Set(ALL_CATEGORIES);
+ for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
+ set.delete(cat);
+ }
+ return set;
+ });
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [redactedUuids, setRedactedUuids] = useState>(new Set());
+ const [selectedForRedaction, setSelectedForRedaction] = useState<
+ Set
+ >(new Set());
+
+ const [autoRedactEnabled, setAutoRedactEnabled] = useState(false);
+
+ const toggleCategory = useCallback((cat: MessageCategory) => {
+ setEnabledCategories((prev) => {
+ const next = new Set(prev);
+ if (next.has(cat)) {
+ next.delete(cat);
+ } else {
+ next.add(cat);
+ }
+ return next;
+ });
+ }, []);
+
+ const setAllCategories = useCallback((enabled: boolean) => {
+ if (enabled) {
+ setEnabledCategories(new Set(ALL_CATEGORIES));
+ } else {
+ setEnabledCategories(new Set());
+ }
+ }, []);
+
+ const setPreset = useCallback((categories: MessageCategory[]) => {
+ setEnabledCategories(new Set(categories));
+ }, []);
+
+ const toggleRedactionSelection = useCallback((uuid: string) => {
+ setSelectedForRedaction((prev) => {
+ const next = new Set(prev);
+ if (next.has(uuid)) {
+ next.delete(uuid);
+ } else {
+ next.add(uuid);
+ }
+ return next;
+ });
+ }, []);
+
+ // Fix #9: Use functional updater to avoid stale closure over selectedForRedaction
+ const confirmRedaction = useCallback(() => {
+ setSelectedForRedaction((currentSelected) => {
+ setRedactedUuids((prev) => {
+ const next = new Set(prev);
+ for (const uuid of currentSelected) {
+ next.add(uuid);
+ }
+ return next;
+ });
+ return new Set();
+ });
+ }, []);
+
+ const clearRedactionSelection = useCallback(() => {
+ setSelectedForRedaction(new Set());
+ }, []);
+
+ const undoRedaction = useCallback((uuid: string) => {
+ setRedactedUuids((prev) => {
+ const next = new Set(prev);
+ next.delete(uuid);
+ return next;
+ });
+ }, []);
+
+ const clearAllRedactions = useCallback(() => {
+ setRedactedUuids(new Set());
+ }, []);
+
+ const selectAllVisible = useCallback((uuids: string[]) => {
+ setSelectedForRedaction(new Set(uuids));
+ }, []);
+
+ // Fix #1: filterMessages is now a pure function - no setState calls during render.
+ // Match count is computed separately via getMatchCount.
+ const filterMessages = useCallback(
+ (messages: ParsedMessage[]): ParsedMessage[] => {
+ return messages.filter(
+ (m) => enabledCategories.has(m.category) && !redactedUuids.has(m.uuid)
+ );
+ },
+ [enabledCategories, redactedUuids]
+ );
+
+ // Derive match count from filtered messages + search query without setState
+ const getMatchCount = useCallback(
+ (messages: ParsedMessage[]): number => {
+ if (!searchQuery) return 0;
+ const lowerQuery = searchQuery.toLowerCase();
+ return messages.filter((m) =>
+ m.content.toLowerCase().includes(lowerQuery)
+ ).length;
+ },
+ [searchQuery]
+ );
+
+ return useMemo(
+ () => ({
+ enabledCategories,
+ toggleCategory,
+ setAllCategories,
+ setPreset,
+ filterMessages,
+ searchQuery,
+ setSearchQuery,
+ redactedUuids,
+ selectedForRedaction,
+ toggleRedactionSelection,
+ confirmRedaction,
+ clearRedactionSelection,
+ undoRedaction,
+ clearAllRedactions,
+ selectAllVisible,
+ getMatchCount,
+ autoRedactEnabled,
+ setAutoRedactEnabled,
+ }),
+ [
+ enabledCategories,
+ toggleCategory,
+ setAllCategories,
+ setPreset,
+ filterMessages,
+ searchQuery,
+ redactedUuids,
+ selectedForRedaction,
+ toggleRedactionSelection,
+ confirmRedaction,
+ clearRedactionSelection,
+ undoRedaction,
+ clearAllRedactions,
+ selectAllVisible,
+ getMatchCount,
+ autoRedactEnabled,
+ ]
+ );
+}
diff --git a/src/client/hooks/useSession.ts b/src/client/hooks/useSession.ts
new file mode 100644
index 0000000..68f5b92
--- /dev/null
+++ b/src/client/hooks/useSession.ts
@@ -0,0 +1,72 @@
+import { useState, useEffect, useCallback } from "react";
+import type { SessionEntry, SessionDetailResponse } from "../lib/types";
+
+interface SessionState {
+ sessions: SessionEntry[];
+ sessionsLoading: boolean;
+ sessionsError: string | null;
+ currentSession: SessionDetailResponse | null;
+ sessionLoading: boolean;
+ sessionError: string | null;
+ loadSessions: () => Promise;
+ loadSession: (id: string) => Promise;
+}
+
+export function useSession(): SessionState {
+ const [sessions, setSessions] = useState([]);
+ const [sessionsLoading, setSessionsLoading] = useState(true);
+ const [sessionsError, setSessionsError] = useState(null);
+ const [currentSession, setCurrentSession] =
+ useState(null);
+ const [sessionLoading, setSessionLoading] = useState(false);
+ const [sessionError, setSessionError] = useState(null);
+
+ const loadSessions = useCallback(async () => {
+ setSessionsLoading(true);
+ setSessionsError(null);
+ try {
+ const res = await fetch("/api/sessions");
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ setSessions(data.sessions);
+ } catch (err) {
+ setSessionsError(
+ err instanceof Error ? err.message : "Failed to load sessions"
+ );
+ } finally {
+ setSessionsLoading(false);
+ }
+ }, []);
+
+ const loadSession = useCallback(async (id: string) => {
+ setSessionLoading(true);
+ setSessionError(null);
+ try {
+ const res = await fetch(`/api/sessions/${id}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ setCurrentSession(data);
+ } catch (err) {
+ setSessionError(
+ err instanceof Error ? err.message : "Failed to load session"
+ );
+ } finally {
+ setSessionLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadSessions();
+ }, [loadSessions]);
+
+ return {
+ sessions,
+ sessionsLoading,
+ sessionsError,
+ currentSession,
+ sessionLoading,
+ sessionError,
+ loadSessions,
+ loadSession,
+ };
+}
diff --git a/src/client/index.html b/src/client/index.html
new file mode 100644
index 0000000..fec5bc8
--- /dev/null
+++ b/src/client/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Session Viewer
+
+
+
+
+
+
diff --git a/src/client/lib/constants.ts b/src/client/lib/constants.ts
new file mode 100644
index 0000000..4dc2915
--- /dev/null
+++ b/src/client/lib/constants.ts
@@ -0,0 +1,13 @@
+import type { MessageCategory } from "./types";
+
+export const CATEGORY_COLORS: Record = {
+ 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" },
+};
diff --git a/src/client/lib/markdown.ts b/src/client/lib/markdown.ts
new file mode 100644
index 0000000..7d80e86
--- /dev/null
+++ b/src/client/lib/markdown.ts
@@ -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 `${escapeHtml(text)}
`;
+ }
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+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,
+ '$1'
+ );
+ }
+ }
+ return parts.join("");
+}
diff --git a/src/client/lib/types.ts b/src/client/lib/types.ts
new file mode 100644
index 0000000..48a48a3
--- /dev/null
+++ b/src/client/lib/types.ts
@@ -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";
diff --git a/src/client/main.tsx b/src/client/main.tsx
new file mode 100644
index 0000000..0b11abd
--- /dev/null
+++ b/src/client/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { App } from "./app.js";
+import "./styles/main.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/src/client/styles/main.css b/src/client/styles/main.css
new file mode 100644
index 0000000..2abdd02
--- /dev/null
+++ b/src/client/styles/main.css
@@ -0,0 +1,42 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: #d1d5db;
+ border-radius: 3px;
+}
+
+/* Highlight.js overrides for client */
+.hljs {
+ background: #f6f8fa;
+ padding: 1rem;
+ border-radius: 6px;
+ overflow-x: auto;
+}
+
+/* Search highlight */
+mark.search-highlight {
+ background: #fde68a;
+ color: inherit;
+ padding: 0 2px;
+ border-radius: 2px;
+}
+
+/* Redaction selection indicator */
+.redaction-selected {
+ outline: 2px solid #ef4444;
+ outline-offset: 2px;
+}
+
+/* Message dimming for search */
+.message-dimmed {
+ opacity: 0.3;
+}