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 ( +
+
+
+ Loading session... +
+
+ ); + } + + 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; +}