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:
113
src/client/app.tsx
Normal file
113
src/client/app.tsx
Normal file
@@ -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 (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h1 className="text-lg font-bold text-gray-900">Session Viewer</h1>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
loading={sessionsLoading}
|
||||
selectedId={currentSession?.id}
|
||||
onSelect={loadSession}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<FilterPanel
|
||||
enabledCategories={filters.enabledCategories}
|
||||
onToggle={filters.toggleCategory}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-3 p-3 border-b border-gray-200 bg-white">
|
||||
<SearchBar
|
||||
query={filters.searchQuery}
|
||||
onQueryChange={filters.setSearchQuery}
|
||||
matchCount={matchCount}
|
||||
/>
|
||||
{filters.selectedForRedaction.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600 font-medium">
|
||||
{filters.selectedForRedaction.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={filters.confirmRedaction}
|
||||
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Redact Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={filters.clearRedactionSelection}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{currentSession && (
|
||||
<ExportButton
|
||||
session={currentSession}
|
||||
visibleMessageUuids={visibleUuids}
|
||||
redactedMessageUuids={[...filters.redactedUuids]}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SessionViewer
|
||||
messages={filteredMessages}
|
||||
allMessages={currentSession?.messages || []}
|
||||
redactedUuids={filters.redactedUuids}
|
||||
loading={sessionLoading}
|
||||
searchQuery={filters.searchQuery}
|
||||
selectedForRedaction={filters.selectedForRedaction}
|
||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/client/components/ExportButton.tsx
Normal file
60
src/client/components/ExportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/client/components/FilterPanel.tsx
Normal file
47
src/client/components/FilterPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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, ">");
|
||||
}
|
||||
11
src/client/components/RedactedDivider.tsx
Normal file
11
src/client/components/RedactedDivider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/client/components/SearchBar.tsx
Normal file
74
src/client/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/client/components/SessionList.test.tsx
Normal file
124
src/client/components/SessionList.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
130
src/client/components/SessionList.tsx
Normal file
130
src/client/components/SessionList.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
122
src/client/components/SessionViewer.tsx
Normal file
122
src/client/components/SessionViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
src/client/hooks/useFilters.ts
Normal file
174
src/client/hooks/useFilters.ts
Normal file
@@ -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<MessageCategory>;
|
||||
toggleCategory: (cat: MessageCategory) => void;
|
||||
setAllCategories: (enabled: boolean) => void;
|
||||
filterMessages: (messages: ParsedMessage[]) => ParsedMessage[];
|
||||
searchQuery: string;
|
||||
setSearchQuery: (q: string) => void;
|
||||
redactedUuids: Set<string>;
|
||||
selectedForRedaction: Set<string>;
|
||||
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<MessageCategory>
|
||||
>(() => {
|
||||
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<Set<string>>(new Set());
|
||||
const [selectedForRedaction, setSelectedForRedaction] = useState<
|
||||
Set<string>
|
||||
>(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,
|
||||
]
|
||||
);
|
||||
}
|
||||
72
src/client/hooks/useSession.ts
Normal file
72
src/client/hooks/useSession.ts
Normal file
@@ -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<void>;
|
||||
loadSession: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSession(): SessionState {
|
||||
const [sessions, setSessions] = useState<SessionEntry[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
||||
const [currentSession, setCurrentSession] =
|
||||
useState<SessionDetailResponse | null>(null);
|
||||
const [sessionLoading, setSessionLoading] = useState(false);
|
||||
const [sessionError, setSessionError] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
12
src/client/index.html
Normal file
12
src/client/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Session Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
src/client/lib/constants.ts
Normal file
13
src/client/lib/constants.ts
Normal 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" },
|
||||
};
|
||||
50
src/client/lib/markdown.ts
Normal file
50
src/client/lib/markdown.ts
Normal 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, "&")
|
||||
.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,
|
||||
'<mark class="search-highlight">$1</mark>'
|
||||
);
|
||||
}
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
14
src/client/lib/types.ts
Normal file
14
src/client/lib/types.ts
Normal 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";
|
||||
10
src/client/main.tsx
Normal file
10
src/client/main.tsx
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
42
src/client/styles/main.css
Normal file
42
src/client/styles/main.css
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user