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:
76
src/client/components/MessageBubble.tsx
Normal file
76
src/client/components/MessageBubble.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
Reference in New Issue
Block a user