Compare commits

..

11 Commits

Author SHA1 Message Date
69857fa825 Fix session discovery tests to use dynamic paths and add containment test
Replace hardcoded absolute paths in test assertions with dynamically
constructed paths matching the temp directory. This makes tests portable
across environments where path.resolve() produces different results.

Add test verifying that absolute paths pointing outside the projects
directory (e.g. /etc/shadow.jsonl) are rejected by the discovery filter.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:10:55 -05:00
baaf2fca4c Expand vitest include glob to cover client lib tests
Widen the test include pattern from 'src/client/components/**/*.test.tsx'
to 'src/client/**/*.test.{ts,tsx}' so that tests in src/client/lib/ (e.g.
markdown.test.ts) are discovered automatically.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:10:41 -05:00
0e89924685 Redesign HTML exporter with dark theme, timestamps, and performance fixes
Visual overhaul of exported HTML to match the new client dark design:
- Replace category-specific CSS classes with inline border/dot/text styles
  from a CATEGORY_STYLES map matching client-side colors
- Add message header layout with category dot, label, and timestamp
- Add Inter font family, refined prose typography, and proper code styling
- Add print-friendly media query
- Redesign redacted divider with SVG eye-slash icon and red accent
- Add SVG icons to session header metadata (project, date, message count)
- Fix singular/plural for '1 message' vs 'N messages'

Performance: Skip markdown parsing for hook_progress, tool_result, and
file_snapshot categories (structured data). Render as preformatted text
instead, avoiding expensive marked.parse() on large JSON blobs (~300ms each).

Replace local escapeHtml with shared/escape-html module. Add formatTimestamp
helper. Add cast safety comment for marked.parse() sync usage.

Update test to verify singular message count ('1 message' not '1 messages').

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:10:35 -05:00
9716091ecc Optimize markdown rendering: skip highlightAuto, fix entity-safe highlighting
Performance: Replace hljs.highlightAuto() fallback with plain escapeHtml()
for unlabeled code blocks. highlightAuto tries every grammar (~6.7ms/block)
while escapeHtml costs ~0.04ms. With thousands of unlabeled blocks in
typical sessions this dominated render time.

Import shared escapeHtml instead of the local duplicate. Import github-dark
highlight.js theme CSS directly.

Fix highlightSearchText to avoid corrupting HTML entities: split text on
entity patterns (&amp; &lt; etc.) before applying search regex, so searching
for 'amp' does not break &amp; into &<mark>amp</mark>;.

Add unit tests for highlightSearchText covering: plain text matches, empty
queries, avoiding matches inside HTML tags, and preserving HTML entities.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:10:22 -05:00
6a4e22f1f8 Add search navigation with match cycling, keyboard shortcuts, and minimap
Implement full-featured search navigation across the session viewer:

App.tsx: Compute matchIndices (filtered message indices matching the query),
track currentMatchPosition state, and provide goToNext/goToPrev callbacks.
Add scroll tracking via ResizeObserver + scroll events for minimap viewport.
Restructure toolbar layout with centered search bar and right-aligned export.
Pass focusedIndex to SessionViewer for scroll-into-view behavior.

SearchBar: Redesign as a unified search container with integrated match count
badge, prev/next navigation arrows, clear button, and keyboard hint (/).
Add keyboard shortcuts: Enter/Shift+Enter for next/prev, Ctrl+G/Ctrl+Shift+G
for navigation, Escape to clear and blur. Show 'X/N' position indicator and
'No results' state.

SessionViewer: Add data-msg-index attributes for scroll targeting via
querySelector instead of individual refs. Memoize displayItems list. Add
MessageSkeleton component for loading state. Add empty state illustrations
with icons and descriptive text. Apply staggered fade-in animations and
search-match-focused outline to the active match.

SearchMinimap: New component rendering match positions as amber ticks on a
narrow strip overlaying the scroll area's right edge. Includes viewport
position indicator and click-to-jump behavior.

Add unit tests for SearchMinimap: empty/zero states, tick rendering,
active tick styling, viewport indicator, and click handler.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:10:08 -05:00
15a312d98c Redesign all client components with dark theme, polish, and UX improvements
MessageBubble: Replace border-left colored bars with rounded cards featuring
accent strips, category dot indicators, and timestamp display. Use shared
escapeHtml. Render tool_result, hook_progress, and file_snapshot as
preformatted text instead of markdown (avoids expensive marked.parse on
large JSON/log blobs).

ExportButton: Add state machine (idle/exporting/success/error) with animated
icons, gradient backgrounds, and auto-reset timers. Replace alert() with
inline error state.

FilterPanel: Add collapsible panel with category dot colors, enable count
badge, custom checkbox styling, and smooth animations.

SessionList: Replace text loading state with skeleton placeholders. Add
empty state illustration with descriptive text. Style session items as
rounded cards with hover/selected states, glow effects, and staggered
entry animations. Add project name decode explanation comment.

RedactedDivider: Add eye-slash SVG icon, red accent color, and styled
dashed lines replacing plain text divider.

useFilters: Remove unused exports (setAllCategories, setPreset, undoRedaction,
clearAllRedactions, selectAllVisible, getMatchCount) to reduce hook surface
area. Match counting moved to App component for search navigation.

SessionList.test: Update assertions for skeleton loading state and expanded
empty state text.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:09:41 -05:00
40b3ccf33e Add dark design system with CSS custom properties and refined typography
Establish a cohesive dark UI foundation:

- Define CSS custom properties for surfaces (4-level depth hierarchy),
  borders, foreground text (3-tier), accent colors, and canvas background
- Add Inter (text) and JetBrains Mono (code) font loading via Google Fonts
- Extend Tailwind with semantic color tokens, typography scale (caption/body/
  subheading/heading), box shadows (card, glow), and animations (fade-in,
  slide-in, skeleton shimmer)
- Add component-layer utilities: .btn system (primary/secondary/ghost/danger),
  .glass frosted overlays, .custom-checkbox, .skeleton loaders
- Add .prose-message typographic styles for rendered markdown content
- Add search minimap CSS (tick marks, viewport indicator)
- Restyle scrollbars for thin dark appearance (WebKit + Firefox)
- Replace hardcoded Tailwind color classes in CATEGORY_COLORS with semantic
  tokens (dot/border/text) mapped to the new design system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:09:24 -05:00
0e5a36f0d1 Fix sensitive redactor keyword matching for case-insensitive patterns
The keyword pre-filter used case-sensitive string matching for all patterns,
but several regex patterns use the /i flag (e.g. generic_api_key). This meant
inputs like 'ApiKey = "secret"' would skip the keyword check for 'api_key'
and miss the redaction entirely.

Changes:
- Add caseInsensitive parameter to hasKeyword() that lowercases both content
  and keywords before comparison
- Detect /i flag on pattern regex and pass it through automatically
- Narrow IP address keywords from ["."] to ["0.", "1.", ..., "9."] to reduce
  false-positive regex invocations on content containing periods
- Fix email regex character class [A-Z|a-z] → [A-Za-z] (the pipe was literal)
- Add clarifying comment on url_with_creds pattern
- Add test cases for mixed-case and UPPER_CASE key assignments
- Relax SECRET_KEY test assertion to accept either redaction label

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:09:11 -05:00
eb8001dbf1 Harden session discovery with path validation and parallel I/O
Security: Reject session paths containing '..' traversal segments or
non-.jsonl extensions before resolving them. This prevents a malicious
sessions-index.json from tricking the viewer into reading arbitrary files.

Performance: Process all project directories concurrently with Promise.all
instead of sequentially awaiting each one. Each directory's stat + readFile
is independent I/O that benefits from parallelism.

Add test case verifying that traversal paths and non-JSONL paths are rejected
while valid paths pass through.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:08:57 -05:00
96da009086 Remove hardcoded Tailscale IP, bind server and Vite to localhost only
The Express server was binding to both 127.0.0.1 and a specific Tailscale IP
(100.84.4.113), creating two separate http.Server instances. Simplify to a
single localhost binding. Also update Vite dev server to use 127.0.0.1 instead
of the Tailscale IP and disable the HMR error overlay which obscures the UI
during development.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:08:46 -05:00
8e713b9c50 Extract escapeHtml into shared module for reuse across client and server
The same HTML entity escaping logic was duplicated in three places:
MessageBubble.tsx, html-exporter.ts, and markdown.ts. Consolidate into
a single shared/escape-html.ts with a single-pass regex+lookup implementation
instead of five chained .replace() calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 01:08:38 -05:00
28 changed files with 2101 additions and 490 deletions

View File

@@ -1,12 +1,14 @@
import React, { useMemo } from "react"; import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
import { SessionList } from "./components/SessionList"; import { SessionList } from "./components/SessionList";
import { SessionViewer } from "./components/SessionViewer"; import { SessionViewer } from "./components/SessionViewer";
import { FilterPanel } from "./components/FilterPanel"; import { FilterPanel } from "./components/FilterPanel";
import { SearchBar } from "./components/SearchBar"; import { SearchBar } from "./components/SearchBar";
import { SearchMinimap } from "./components/SearchMinimap";
import { ExportButton } from "./components/ExportButton"; import { ExportButton } from "./components/ExportButton";
import { useSession } from "./hooks/useSession"; import { useSession } from "./hooks/useSession";
import { useFilters } from "./hooks/useFilters"; import { useFilters } from "./hooks/useFilters";
export function App() { export function App() {
const { const {
sessions, sessions,
@@ -23,23 +25,91 @@ export function App() {
return filters.filterMessages(currentSession.messages); return filters.filterMessages(currentSession.messages);
}, [currentSession, filters.filterMessages]); }, [currentSession, filters.filterMessages]);
// Derive match count from filtered messages - no setState during render // Track which filtered-message indices match the search query
const matchCount = useMemo( const matchIndices = useMemo(() => {
() => filters.getMatchCount(filteredMessages), if (!filters.searchQuery) return [];
[filters.getMatchCount, filteredMessages] const lq = filters.searchQuery.toLowerCase();
); return filteredMessages.reduce<number[]>((acc, msg, i) => {
if (msg.content.toLowerCase().includes(lq)) acc.push(i);
return acc;
}, []);
}, [filteredMessages, filters.searchQuery]);
const matchCount = matchIndices.length;
// Which match is currently focused (index into matchIndices)
const [currentMatchPosition, setCurrentMatchPosition] = useState(-1);
// Reset to first match when search results change
useEffect(() => {
setCurrentMatchPosition(matchIndices.length > 0 ? 0 : -1);
}, [matchIndices]);
const goToNextMatch = useCallback(() => {
if (matchIndices.length === 0) return;
setCurrentMatchPosition((prev) =>
prev < matchIndices.length - 1 ? prev + 1 : 0
);
}, [matchIndices.length]);
const goToPrevMatch = useCallback(() => {
if (matchIndices.length === 0) return;
setCurrentMatchPosition((prev) =>
prev > 0 ? prev - 1 : matchIndices.length - 1
);
}, [matchIndices.length]);
const focusedMessageIndex =
currentMatchPosition >= 0 ? matchIndices[currentMatchPosition] : -1;
const visibleUuids = useMemo( const visibleUuids = useMemo(
() => filteredMessages.map((m) => m.uuid), () => filteredMessages.map((m) => m.uuid),
[filteredMessages] [filteredMessages]
); );
// Scroll tracking for minimap viewport indicator
const scrollRef = useRef<HTMLDivElement>(null);
const [viewportTop, setViewportTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(1);
const updateViewport = useCallback(() => {
const el = scrollRef.current;
if (!el || el.scrollHeight === 0) return;
setViewportTop(el.scrollTop / el.scrollHeight);
setViewportHeight(Math.min(el.clientHeight / el.scrollHeight, 1));
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.addEventListener("scroll", updateViewport, { passive: true });
// Initial measurement
updateViewport();
// Re-measure when content changes
const ro = new ResizeObserver(updateViewport);
ro.observe(el);
return () => {
el.removeEventListener("scroll", updateViewport);
ro.disconnect();
};
}, [updateViewport]);
// Re-measure when messages change (content size changes)
useEffect(() => {
updateViewport();
}, [filteredMessages, updateViewport]);
return ( return (
<div className="flex h-screen bg-gray-50"> <div className="flex h-screen" style={{ background: "var(--color-canvas)" }}>
{/* Sidebar */} {/* Sidebar */}
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col"> <div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col">
<div className="p-4 border-b border-gray-200"> <div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}>
<h1 className="text-lg font-bold text-gray-900">Session Viewer</h1> <h1 className="text-heading font-semibold text-foreground tracking-tight">
Session Viewer
</h1>
<p className="text-caption text-foreground-muted mt-0.5">
Browse and export Claude sessions
</p>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<SessionList <SessionList
@@ -49,62 +119,85 @@ export function App() {
onSelect={loadSession} onSelect={loadSession}
/> />
</div> </div>
<div className="border-t border-gray-200"> <FilterPanel
<FilterPanel enabledCategories={filters.enabledCategories}
enabledCategories={filters.enabledCategories} onToggle={filters.toggleCategory}
onToggle={filters.toggleCategory} autoRedactEnabled={filters.autoRedactEnabled}
autoRedactEnabled={filters.autoRedactEnabled} onAutoRedactToggle={filters.setAutoRedactEnabled}
onAutoRedactToggle={filters.setAutoRedactEnabled} />
/>
</div>
</div> </div>
{/* Main */} {/* Main */}
<div className="flex-1 flex flex-col min-w-0"> <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"> <div className="glass flex items-center px-5 py-2.5 border-b border-border z-10">
<SearchBar {/* Left spacer — mirrors right side width to keep search centered */}
query={filters.searchQuery} <div className="flex-1 min-w-0" />
onQueryChange={filters.setSearchQuery}
matchCount={matchCount} {/* Center — search bar + contextual redaction controls */}
/> <div className="flex items-center gap-3 flex-shrink-0">
{filters.selectedForRedaction.size > 0 && ( <SearchBar
<div className="flex items-center gap-2"> query={filters.searchQuery}
<span className="text-sm text-red-600 font-medium"> onQueryChange={filters.setSearchQuery}
{filters.selectedForRedaction.size} selected matchCount={matchCount}
</span> currentMatchPosition={currentMatchPosition}
<button onNext={goToNextMatch}
onClick={filters.confirmRedaction} onPrev={goToPrevMatch}
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}
/> />
)} {filters.selectedForRedaction.size > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/8 border border-red-500/15 animate-fade-in">
<span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap">
{filters.selectedForRedaction.size} selected
</span>
<button
onClick={filters.confirmRedaction}
className="btn btn-sm btn-danger"
>
Redact
</button>
<button
onClick={filters.clearRedactionSelection}
className="btn btn-sm btn-ghost text-red-400/70 hover:text-red-300"
>
Cancel
</button>
</div>
)}
</div>
{/* Right — export button, right-justified */}
<div className="flex-1 min-w-0 flex justify-end">
{currentSession && (
<ExportButton
session={currentSession}
visibleMessageUuids={visibleUuids}
redactedMessageUuids={[...filters.redactedUuids]}
autoRedactEnabled={filters.autoRedactEnabled}
/>
)}
</div>
</div> </div>
<div className="flex-1 overflow-y-auto"> {/* Scroll area wrapper — relative so minimap can position over the right edge */}
<SessionViewer <div className="flex-1 relative min-h-0">
messages={filteredMessages} <div ref={scrollRef} className="h-full overflow-y-auto">
allMessages={currentSession?.messages || []} <SessionViewer
redactedUuids={filters.redactedUuids} messages={filteredMessages}
loading={sessionLoading} allMessages={currentSession?.messages || []}
searchQuery={filters.searchQuery} redactedUuids={filters.redactedUuids}
selectedForRedaction={filters.selectedForRedaction} loading={sessionLoading}
onToggleRedactionSelection={filters.toggleRedactionSelection} searchQuery={filters.searchQuery}
autoRedactEnabled={filters.autoRedactEnabled} selectedForRedaction={filters.selectedForRedaction}
onToggleRedactionSelection={filters.toggleRedactionSelection}
autoRedactEnabled={filters.autoRedactEnabled}
focusedIndex={focusedMessageIndex}
/>
</div>
<SearchMinimap
matchIndices={matchIndices}
totalMessages={filteredMessages.length}
currentPosition={currentMatchPosition}
onClickMatch={setCurrentMatchPosition}
viewportTop={viewportTop}
viewportHeight={viewportHeight}
/> />
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect, useRef } from "react";
import type { SessionDetailResponse } from "../lib/types"; import type { SessionDetailResponse } from "../lib/types";
interface Props { interface Props {
@@ -14,10 +14,17 @@ export function ExportButton({
redactedMessageUuids, redactedMessageUuids,
autoRedactEnabled, autoRedactEnabled,
}: Props) { }: Props) {
const [exporting, setExporting] = useState(false); const [state, setState] = useState<"idle" | "exporting" | "success" | "error">("idle");
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
async function handleExport() { async function handleExport() {
setExporting(true); setState("exporting");
try { try {
const res = await fetch("/api/export", { const res = await fetch("/api/export", {
method: "POST", method: "POST",
@@ -40,21 +47,67 @@ export function ExportButton({
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setState("success");
timerRef.current = setTimeout(() => setState("idle"), 2000);
} catch (err) { } catch (err) {
console.error("Export failed:", err); console.error("Export failed:", err);
alert("Export failed. Check console for details."); setState("error");
} finally { timerRef.current = setTimeout(() => setState("idle"), 3000);
setExporting(false);
} }
} }
return ( return (
<button <button
onClick={handleExport} onClick={handleExport}
disabled={exporting} disabled={state === "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" className={`
btn btn-sm flex-shrink-0 gap-1.5 transition-all duration-200
${state === "success" ? "text-white shadow-glow-success" : ""}
${state === "error" ? "text-white" : ""}
${state === "idle" || state === "exporting" ? "btn-primary" : ""}
`}
style={
state === "success"
? { background: "linear-gradient(135deg, #22c55e, #16a34a)" }
: state === "error"
? { background: "linear-gradient(135deg, #dc2626, #b91c1c)" }
: undefined
}
> >
{exporting ? "Exporting..." : "Export HTML"} {state === "idle" && (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Export
</>
)}
{state === "exporting" && (
<>
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Exporting...
</>
)}
{state === "success" && (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Downloaded
</>
)}
{state === "error" && (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
Failed
</>
)}
</button> </button>
); );
} }

View File

@@ -1,6 +1,7 @@
import React from "react"; import React, { useState } from "react";
import type { MessageCategory } from "../lib/types"; import type { MessageCategory } from "../lib/types";
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types"; import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
import { CATEGORY_COLORS } from "../lib/constants";
interface Props { interface Props {
enabledCategories: Set<MessageCategory>; enabledCategories: Set<MessageCategory>;
@@ -10,38 +11,79 @@ interface Props {
} }
export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) { export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) {
const [collapsed, setCollapsed] = useState(false);
const enabledCount = enabledCategories.size;
const totalCount = ALL_CATEGORIES.length;
return ( return (
<div className="p-3"> <div className="border-t border-border" style={{ background: "linear-gradient(0deg, var(--color-surface-raised) 0%, var(--color-surface) 100%)" }}>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"> <button
Filters onClick={() => setCollapsed(!collapsed)}
</div> className="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-overlay/50 transition-colors"
<div className="space-y-1"> >
{ALL_CATEGORIES.map((cat) => ( <div className="flex items-center gap-2">
<label <span className="text-caption font-semibold text-foreground-muted uppercase tracking-wider">
key={cat} Filters
className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900" </span>
> <span className="text-caption text-foreground-muted bg-surface-inset px-2 py-0.5 rounded-full tabular-nums border border-border-muted">
<input {enabledCount}/{totalCount}
type="checkbox" </span>
checked={enabledCategories.has(cat)} </div>
onChange={() => onToggle(cat)} <svg
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" className={`w-4 h-4 text-foreground-muted transition-transform duration-200 ${collapsed ? "" : "rotate-180"}`}
/> fill="none"
<span>{CATEGORY_LABELS[cat]}</span> viewBox="0 0 24 24"
</label> stroke="currentColor"
))} strokeWidth={2}
</div> >
<div className="mt-3 pt-3 border-t border-gray-200"> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900"> </svg>
<input </button>
type="checkbox"
checked={autoRedactEnabled} {!collapsed && (
onChange={(e) => onAutoRedactToggle(e.target.checked)} <div className="px-4 pb-3 animate-fade-in">
className="rounded border-gray-300 text-red-600 focus:ring-red-500" <div className="space-y-0.5">
/> {ALL_CATEGORIES.map((cat) => {
<span>Auto-redact sensitive info</span> const colors = CATEGORY_COLORS[cat];
</label> const isEnabled = enabledCategories.has(cat);
</div> return (
<label
key={cat}
className={`
flex items-center gap-2.5 py-1.5 px-2 rounded-md cursor-pointer transition-all duration-150
${isEnabled ? "hover:bg-surface-overlay/50" : "opacity-40 hover:opacity-65"}
`}
>
<input
type="checkbox"
checked={isEnabled}
onChange={() => onToggle(cat)}
className="custom-checkbox"
/>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${colors.dot}`} />
<span className="text-body text-foreground-secondary">{CATEGORY_LABELS[cat]}</span>
</label>
);
})}
</div>
<div className="mt-3 pt-3 border-t border-border-muted">
<label className="flex items-center gap-2.5 py-1.5 px-2 rounded-md cursor-pointer hover:bg-surface-inset transition-colors">
<input
type="checkbox"
checked={autoRedactEnabled}
onChange={(e) => onAutoRedactToggle(e.target.checked)}
className="custom-checkbox checkbox-danger"
/>
<svg className="w-3.5 h-3.5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<span className="text-body text-foreground-secondary">Auto-redact sensitive</span>
</label>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { CATEGORY_LABELS } from "../lib/types";
import { CATEGORY_COLORS } from "../lib/constants"; import { CATEGORY_COLORS } from "../lib/constants";
import { renderMarkdown, highlightSearchText } from "../lib/markdown"; import { renderMarkdown, highlightSearchText } from "../lib/markdown";
import { redactMessage } from "../../shared/sensitive-redactor"; import { redactMessage } from "../../shared/sensitive-redactor";
import { escapeHtml } from "../../shared/escape-html";
interface Props { interface Props {
message: ParsedMessage; message: ParsedMessage;
@@ -16,9 +17,10 @@ interface Props {
/** /**
* MessageBubble renders session messages using innerHTML. * MessageBubble renders session messages using innerHTML.
* This is safe here because content comes only from local JSONL session files * SECURITY: This is safe because content comes only from local JSONL session files
* owned by the user, processed through the `marked` markdown renderer. * owned by the user, processed through the `marked` markdown renderer.
* This is a local-only developer tool, not exposed to untrusted input. * This is a local-only developer tool, not exposed to untrusted input.
* The session files are read from the user's own filesystem (~/.claude/projects/).
*/ */
export function MessageBubble({ export function MessageBubble({
message, message,
@@ -36,41 +38,77 @@ export function MessageBubble({
if (msg.category === "tool_call") { if (msg.category === "tool_call") {
const inputHtml = msg.toolInput 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>` ? `<pre class="hljs mt-2"><code>${escapeHtml(msg.toolInput)}</code></pre>`
: ""; : "";
const html = `<div class="font-semibold text-amber-800">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`; const html = `<div class="font-medium ${colors.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
return searchQuery ? highlightSearchText(html, searchQuery) : html; return searchQuery ? highlightSearchText(html, searchQuery) : html;
} }
// Structured data categories: render as preformatted text, not markdown.
// Avoids expensive marked.parse() on large JSON/log blobs.
if (msg.category === "hook_progress" || msg.category === "tool_result" || msg.category === "file_snapshot") {
const html = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
return searchQuery ? highlightSearchText(html, searchQuery) : html;
}
const html = renderMarkdown(msg.content); const html = renderMarkdown(msg.content);
return searchQuery ? highlightSearchText(html, searchQuery) : html; return searchQuery ? highlightSearchText(html, searchQuery) : html;
}, [message, searchQuery, autoRedactEnabled]); }, [message, searchQuery, autoRedactEnabled, colors.text]);
const timestamp = message.timestamp
? formatTimestamp(message.timestamp)
: null;
return ( return (
<div <div
onClick={onToggleRedactionSelection} onClick={onToggleRedactionSelection}
className={` className={`
border-l-4 rounded-lg p-4 shadow-sm cursor-pointer transition-all group rounded-xl border bg-surface-raised cursor-pointer
${colors.border} ${colors.bg} transition-all duration-200 relative overflow-hidden
${colors.border}
${dimmed ? "message-dimmed" : ""} ${dimmed ? "message-dimmed" : ""}
${selectedForRedaction ? "redaction-selected" : ""} ${selectedForRedaction ? "redaction-selected" : ""}
hover:shadow-md hover:shadow-card-hover hover:-translate-y-px
shadow-card
`} `}
> >
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2"> {/* Category accent strip */}
{label} <div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
{/* Header bar */}
<div className="flex items-center gap-2 px-4 pl-5 pt-3 pb-1">
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} />
<span className="text-caption font-semibold uppercase tracking-wider text-foreground-muted">
{label}
</span>
{timestamp && (
<>
<span className="text-border">·</span>
<span className="text-caption text-foreground-muted tabular-nums">
{timestamp}
</span>
</>
)}
</div> </div>
{/* Content from local user-owned JSONL files, not external/untrusted input */}
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
<div <div
className="prose prose-sm max-w-none overflow-wrap-break-word" className="prose-message text-body text-foreground px-4 pl-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
dangerouslySetInnerHTML={{ __html: renderedHtml }} dangerouslySetInnerHTML={{ __html: renderedHtml }}
/> />
</div> </div>
); );
} }
function escapeHtml(text: string): string { function formatTimestamp(ts: string): string {
return text try {
.replace(/&/g, "&amp;") const d = new Date(ts);
.replace(/</g, "&lt;") return d.toLocaleTimeString(undefined, {
.replace(/>/g, "&gt;"); hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return "";
}
} }

View File

@@ -2,10 +2,15 @@ import React from "react";
export function RedactedDivider() { export function RedactedDivider() {
return ( return (
<div className="flex items-center gap-3 py-2 text-gray-400 text-sm"> <div className="flex items-center gap-3 py-3 text-foreground-muted">
<div className="flex-1 border-t border-dashed border-gray-300" /> <div className="flex-1 border-t border-dashed border-red-900/40" />
<span>··· content redacted ···</span> <div className="flex items-center gap-1.5 text-caption">
<div className="flex-1 border-t border-dashed border-gray-300" /> <svg className="w-3.5 h-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
<span className="text-red-400 font-medium">content redacted</span>
</div>
<div className="flex-1 border-t border-dashed border-red-900/40" />
</div> </div>
); );
} }

View File

@@ -4,11 +4,22 @@ interface Props {
query: string; query: string;
onQueryChange: (q: string) => void; onQueryChange: (q: string) => void;
matchCount: number; matchCount: number;
currentMatchPosition: number;
onNext: () => void;
onPrev: () => void;
} }
export function SearchBar({ query, onQueryChange, matchCount }: Props) { export function SearchBar({
query,
onQueryChange,
matchCount,
currentMatchPosition,
onNext,
onPrev,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [localQuery, setLocalQuery] = useState(query); const [localQuery, setLocalQuery] = useState(query);
const [isFocused, setIsFocused] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(); const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Sync external query changes (e.g., clearing from Escape key) // Sync external query changes (e.g., clearing from Escape key)
@@ -25,8 +36,10 @@ export function SearchBar({ query, onQueryChange, matchCount }: Props) {
return () => clearTimeout(debounceRef.current); return () => clearTimeout(debounceRef.current);
}, [localQuery, query, onQueryChange]); }, [localQuery, query, onQueryChange]);
// Global keyboard shortcuts
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// "/" to focus search
if ( if (
e.key === "/" && e.key === "/" &&
!e.ctrlKey && !e.ctrlKey &&
@@ -35,40 +48,154 @@ export function SearchBar({ query, onQueryChange, matchCount }: Props) {
) { ) {
e.preventDefault(); e.preventDefault();
inputRef.current?.focus(); inputRef.current?.focus();
return;
}
// Escape to blur and clear
if (e.key === "Escape" && document.activeElement === inputRef.current) {
e.preventDefault();
if (localQuery) {
setLocalQuery("");
onQueryChange("");
}
inputRef.current?.blur();
return;
}
// Ctrl+G / Ctrl+Shift+G for next/prev match
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g") {
e.preventDefault();
if (e.shiftKey) {
onPrev();
} else {
onNext();
}
} }
} }
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, []); }, [localQuery, onNext, onPrev, onQueryChange]);
function handleInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
// Flush debounce immediately
if (localQuery !== query) {
clearTimeout(debounceRef.current);
onQueryChange(localQuery);
}
if (e.shiftKey) {
onPrev();
} else {
onNext();
}
}
}
const hasResults = query && matchCount > 0;
const hasNoResults = query && matchCount === 0;
return ( return (
<div className="flex items-center gap-2 flex-1"> <div className="w-80 sm:w-96">
<div className="relative flex-1 max-w-md"> {/* Unified search container */}
<div
className={`
relative flex items-center rounded-xl transition-all duration-200
${isFocused
? "bg-surface ring-2 ring-accent/25 border-accent/50 shadow-glow-accent"
: "bg-surface-inset hover:bg-surface-inset/80"
}
border
${hasNoResults ? "border-red-500/30" : "border-border-muted"}
`}
>
{/* Search icon */}
<div className={`pl-3.5 flex-shrink-0 transition-colors duration-200 ${isFocused ? "text-accent" : "text-foreground-muted"}`}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
</div>
{/* Input */}
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={localQuery} value={localQuery}
onChange={(e) => setLocalQuery(e.target.value)} onChange={(e) => setLocalQuery(e.target.value)}
placeholder='Search messages... (press "/" to focus)' onKeyDown={handleInputKeyDown}
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" onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="Search messages..."
className="flex-1 min-w-0 bg-transparent px-2.5 py-2 text-body text-foreground
placeholder:text-foreground-muted
focus:outline-none"
/> />
{localQuery && (
<button {/* Right-side controls — all inside the unified bar */}
onClick={() => { <div className="flex items-center gap-0.5 pr-2 flex-shrink-0">
setLocalQuery(""); {/* Match count badge */}
onQueryChange(""); {query && (
}} <div className={`
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" flex items-center gap-1 px-2 py-0.5 rounded-md text-caption tabular-nums whitespace-nowrap mr-0.5
> ${hasNoResults
× ? "text-red-400 bg-red-500/10"
</button> : "text-foreground-muted bg-surface-overlay/50"
)} }
`}>
{hasNoResults ? (
<span>No results</span>
) : (
<span>{currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}<span className="text-foreground-muted/50 mx-0.5">/</span>{matchCount}</span>
)}
</div>
)}
{/* Navigation arrows — only when there are results */}
{hasResults && (
<div className="flex items-center border-l border-border-muted/60 ml-1 pl-1">
<button
onClick={onPrev}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
aria-label="Previous match"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
</button>
<button
onClick={onNext}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
aria-label="Next match"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</div>
)}
{/* Clear button or keyboard hint */}
{localQuery ? (
<button
onClick={() => {
setLocalQuery("");
onQueryChange("");
inputRef.current?.focus();
}}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors ml-0.5"
aria-label="Clear search"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
) : (
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 text-caption text-foreground-muted/70 bg-surface-overlay/40 border border-border-muted rounded font-mono leading-none">
/
</kbd>
)}
</div>
</div> </div>
{query && (
<span className="text-sm text-gray-500">
{matchCount} match{matchCount !== 1 ? "es" : ""}
</span>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { SearchMinimap } from "./SearchMinimap";
const defaultProps = {
onClickMatch: vi.fn(),
viewportTop: 0,
viewportHeight: 0.3,
};
describe("SearchMinimap", () => {
it("renders nothing when matchIndices is empty", () => {
const { container } = render(
<SearchMinimap
matchIndices={[]}
totalMessages={10}
currentPosition={-1}
{...defaultProps}
/>
);
expect(container.firstChild).toBeNull();
});
it("renders nothing when totalMessages is 0", () => {
const { container } = render(
<SearchMinimap
matchIndices={[0]}
totalMessages={0}
currentPosition={0}
{...defaultProps}
/>
);
expect(container.firstChild).toBeNull();
});
it("renders a tick for each match", () => {
const { container } = render(
<SearchMinimap
matchIndices={[2, 5, 8]}
totalMessages={10}
currentPosition={0}
{...defaultProps}
/>
);
const ticks = container.querySelectorAll(".search-minimap-tick");
expect(ticks).toHaveLength(3);
});
it("positions ticks proportionally", () => {
const { container } = render(
<SearchMinimap
matchIndices={[5]}
totalMessages={10}
currentPosition={0}
{...defaultProps}
/>
);
const tick = container.querySelector(".search-minimap-tick") as HTMLElement;
expect(tick.style.top).toBe("50%");
});
it("marks the active tick with the active class", () => {
const { container } = render(
<SearchMinimap
matchIndices={[1, 3, 7]}
totalMessages={10}
currentPosition={1}
{...defaultProps}
/>
);
const ticks = container.querySelectorAll(".search-minimap-tick");
expect(ticks[0]).not.toHaveClass("search-minimap-tick-active");
expect(ticks[1]).toHaveClass("search-minimap-tick-active");
expect(ticks[2]).not.toHaveClass("search-minimap-tick-active");
});
it("calls onClickMatch with the position index when a tick is clicked", () => {
const onClickMatch = vi.fn();
const { container } = render(
<SearchMinimap
matchIndices={[2, 5]}
totalMessages={10}
currentPosition={0}
onClickMatch={onClickMatch}
viewportTop={0}
viewportHeight={0.3}
/>
);
const ticks = container.querySelectorAll(".search-minimap-tick");
fireEvent.click(ticks[1]);
expect(onClickMatch).toHaveBeenCalledWith(1);
});
it("renders a viewport highlight rectangle", () => {
const { container } = render(
<SearchMinimap
matchIndices={[0, 5]}
totalMessages={10}
currentPosition={0}
onClickMatch={vi.fn()}
viewportTop={0.25}
viewportHeight={0.4}
/>
);
const viewport = container.querySelector(".search-minimap-viewport") as HTMLElement;
expect(viewport).toBeInTheDocument();
expect(viewport.style.top).toBe("25%");
expect(viewport.style.height).toBe("40%");
});
});

View File

@@ -0,0 +1,50 @@
import React from "react";
interface Props {
matchIndices: number[];
totalMessages: number;
currentPosition: number;
onClickMatch: (position: number) => void;
/** scrollTop / scrollHeight ratio (0-1) */
viewportTop: number;
/** clientHeight / scrollHeight ratio (0-1, clamped) */
viewportHeight: number;
}
export function SearchMinimap({
matchIndices,
totalMessages,
currentPosition,
onClickMatch,
viewportTop,
viewportHeight,
}: Props) {
if (matchIndices.length === 0 || totalMessages === 0) return null;
return (
<div className="search-minimap" aria-hidden="true">
{/* Viewport highlight */}
<div
className="search-minimap-viewport"
style={{
top: `${viewportTop * 100}%`,
height: `${viewportHeight * 100}%`,
}}
/>
{/* Match ticks */}
{matchIndices.map((msgIndex, pos) => {
const top = (msgIndex / totalMessages) * 100;
const isActive = pos === currentPosition;
return (
<button
key={msgIndex}
className={`search-minimap-tick${isActive ? " search-minimap-tick-active" : ""}`}
style={{ top: `${top}%` }}
onClick={() => onClickMatch(pos)}
tabIndex={-1}
/>
);
})}
</div>
);
}

View File

@@ -94,11 +94,13 @@ describe("SessionList", () => {
expect(screen.getByText(/all projects/i)).toBeInTheDocument(); expect(screen.getByText(/all projects/i)).toBeInTheDocument();
}); });
it("shows loading state", () => { it("shows loading state with skeleton placeholders", () => {
render( const { container } = render(
<SessionList sessions={[]} loading={true} onSelect={vi.fn()} /> <SessionList sessions={[]} loading={true} onSelect={vi.fn()} />
); );
expect(screen.getByText("Loading sessions...")).toBeInTheDocument(); // Loading state now uses skeleton placeholders instead of text
const skeletons = container.querySelectorAll(".skeleton");
expect(skeletons.length).toBeGreaterThan(0);
}); });
it("shows empty state", () => { it("shows empty state", () => {
@@ -106,6 +108,7 @@ describe("SessionList", () => {
<SessionList sessions={[]} loading={false} onSelect={vi.fn()} /> <SessionList sessions={[]} loading={false} onSelect={vi.fn()} />
); );
expect(screen.getByText("No sessions found")).toBeInTheDocument(); expect(screen.getByText("No sessions found")).toBeInTheDocument();
expect(screen.getByText("Sessions will appear here once created")).toBeInTheDocument();
}); });
it("calls onSelect when clicking a session", () => { it("calls onSelect when clicking a session", () => {

View File

@@ -31,56 +31,83 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
if (loading) { if (loading) {
return ( return (
<div className="p-4 text-sm text-gray-500">Loading sessions...</div> <div className="p-4 space-y-3">
); {[...Array(5)].map((_, i) => (
} <div key={i} className="space-y-2 px-1">
<div className="skeleton h-4 w-3/4" />
if (sessions.length === 0) { <div className="skeleton h-3 w-1/2" />
return ( </div>
<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> </div>
); );
} }
// Phase 1: Project list if (sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<div className="w-10 h-10 rounded-xl bg-surface-inset flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
</div>
<p className="text-body font-medium text-foreground-secondary">No sessions found</p>
<p className="text-caption text-foreground-muted mt-1">Sessions will appear here once created</p>
</div>
);
}
// Session list for selected project
if (selectedProject !== null) {
const projectSessions = grouped.get(selectedProject) || [];
return (
<div className="animate-slide-in">
<button
onClick={() => setSelectedProject(null)}
className="w-full text-left px-4 py-2.5 text-caption font-medium text-accent hover:text-accent-dark hover:bg-surface-overlay flex items-center gap-1.5 border-b border-border-muted transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
<span>All Projects</span>
</button>
<div className="px-4 py-2 text-caption font-semibold text-foreground-muted uppercase tracking-wider border-b border-border-muted" style={{ background: "var(--color-surface-inset)" }}>
{formatProjectName(selectedProject)}
</div>
<div className="py-1">
{projectSessions.map((session, idx) => {
const isSelected = selectedId === session.id;
return (
<button
key={session.id}
onClick={() => onSelect(session.id)}
className={`
w-full text-left mx-2 my-0.5 px-3 py-2.5 rounded-lg transition-all duration-200
${isSelected
? "bg-accent-light shadow-glow-accent ring-1 ring-accent/25"
: "hover:bg-surface-overlay"
}
`}
style={{ width: "calc(100% - 1rem)", animationDelay: `${idx * 30}ms` }}
>
<div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}>
{session.summary || session.firstPrompt || "Untitled Session"}
</div>
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
<span>{formatDate(session.modified || session.created)}</span>
<span className="text-border">·</span>
<span className="tabular-nums">{session.messageCount} msgs</span>
</div>
</button>
);
})}
</div>
</div>
);
}
// Project list
return ( return (
<div className="py-2"> <div className="py-1 animate-fade-in">
{[...grouped.entries()].map(([project, projectSessions]) => { {[...grouped.entries()].map(([project, projectSessions]) => {
const latest = projectSessions.reduce((a, b) => const latest = projectSessions.reduce((a, b) =>
(a.modified || a.created) > (b.modified || b.created) ? a : b (a.modified || a.created) > (b.modified || b.created) ? a : b
@@ -90,14 +117,20 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
<button <button
key={project} key={project}
onClick={() => setSelectedProject(project)} onClick={() => setSelectedProject(project)}
className="w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors" className="w-full text-left mx-2 my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
style={{ width: "calc(100% - 1rem)" }}
> >
<div className="text-sm font-medium text-gray-900 truncate"> <div className="flex items-center justify-between">
{formatProjectName(project)} <div className="text-body font-medium text-foreground truncate">
{formatProjectName(project)}
</div>
<svg className="w-4 h-4 text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div> </div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500"> <div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
<span>{count} {count === 1 ? "session" : "sessions"}</span> <span className="tabular-nums">{count} {count === 1 ? "session" : "sessions"}</span>
<span>·</span> <span className="text-border">·</span>
<span>{formatDate(latest.modified || latest.created)}</span> <span>{formatDate(latest.modified || latest.created)}</span>
</div> </div>
</button> </button>
@@ -107,6 +140,13 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
); );
} }
/**
* Best-effort decode of Claude Code's project directory name back to a path.
* Claude encodes project paths by replacing '/' with '-', but this is lossy:
* a path like /home/user/my-cool-app encodes as -home-user-my-cool-app and
* decodes as /home/user/my/cool/app (hyphens in the original name are lost).
* There is no way to distinguish path separators from literal hyphens.
*/
function formatProjectName(project: string): string { function formatProjectName(project: string): string {
if (project.startsWith("-")) { if (project.startsWith("-")) {
return project.replace(/^-/, "/").replace(/-/g, "/"); return project.replace(/^-/, "/").replace(/-/g, "/");

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from "react"; import React, { useRef, useEffect, useMemo } from "react";
import type { ParsedMessage } from "../lib/types"; import type { ParsedMessage } from "../lib/types";
import { MessageBubble } from "./MessageBubble"; import { MessageBubble } from "./MessageBubble";
import { RedactedDivider } from "./RedactedDivider"; import { RedactedDivider } from "./RedactedDivider";
@@ -15,6 +15,25 @@ interface Props {
focusedIndex?: number; focusedIndex?: number;
} }
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
return (
<div
className="rounded-xl border border-border-muted bg-surface-raised p-4 space-y-3"
style={{ animationDelay: `${delay}ms` }}
>
<div className="flex items-center gap-2">
<div className="skeleton w-2 h-2 rounded-full" />
<div className="skeleton h-3 w-20" />
</div>
<div className="space-y-2">
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-5/6" />
<div className="skeleton h-4 w-3/4" />
</div>
</div>
);
}
export function SessionViewer({ export function SessionViewer({
messages, messages,
allMessages, allMessages,
@@ -26,97 +45,142 @@ export function SessionViewer({
autoRedactEnabled, autoRedactEnabled,
focusedIndex = -1, focusedIndex = -1,
}: Props) { }: Props) {
const focusedRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (focusedIndex >= 0 && focusedRef.current) { if (focusedIndex >= 0 && containerRef.current) {
focusedRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" }); const el = containerRef.current.querySelector(
`[data-msg-index="${focusedIndex}"]`
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
} }
}, [focusedIndex]); }, [focusedIndex]);
// Build display list with redacted dividers.
// Must be called before any early returns to satisfy Rules of Hooks.
const displayItems = useMemo(() => {
if (messages.length === 0) return [];
const visibleUuids = new Set(messages.map((m) => m.uuid));
const items: 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) {
items.push({
type: "redacted_divider",
key: `divider-${msg.uuid}`,
});
prevWasRedactedGap = false;
}
items.push({ type: "message", message: msg, messageIndex });
messageIndex++;
}
return items;
}, [messages, allMessages, redactedUuids]);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-full text-gray-500"> <div className="max-w-6xl mx-auto p-6 space-y-4">
<div className="text-center"> <div className="skeleton h-4 w-24 mb-2" />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-400 mx-auto mb-3"></div> {[...Array(4)].map((_, i) => (
Loading session... <MessageSkeleton key={i} delay={i * 100} />
</div> ))}
</div> </div>
); );
} }
if (messages.length === 0 && allMessages.length === 0) { if (messages.length === 0 && allMessages.length === 0) {
return ( return (
<div className="flex items-center justify-center h-full text-gray-500"> <div className="flex items-center justify-center h-full">
Select a session to view <div className="text-center max-w-sm animate-fade-in">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
>
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
</div>
<p className="text-subheading font-medium text-foreground">Select a session</p>
<p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p>
</div>
</div> </div>
); );
} }
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="flex items-center justify-center h-full text-gray-500"> <div className="flex items-center justify-center h-full">
No messages match current filters <div className="text-center max-w-sm animate-fade-in">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
>
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
</div>
<p className="text-subheading font-medium text-foreground">No matching messages</p>
<p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p>
</div>
</div> </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 ( return (
<div className="max-w-4xl mx-auto p-4 space-y-3"> <div className="max-w-6xl mx-auto px-6 py-5">
<div className="text-sm text-gray-500 mb-4"> <div className="flex items-center justify-between mb-4">
{messages.length} message{messages.length !== 1 ? "s" : ""} <span className="text-caption text-foreground-muted tabular-nums">
{messages.length} message{messages.length !== 1 ? "s" : ""}
</span>
</div>
<div ref={containerRef} className="space-y-3">
{displayItems.map((item, idx) => {
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;
const isFocused = item.messageIndex === focusedIndex;
return (
<div
key={msg.uuid}
data-msg-index={item.messageIndex}
className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`}
style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }}
>
<MessageBubble
message={msg}
searchQuery={searchQuery}
dimmed={!!isDimmed}
selectedForRedaction={selectedForRedaction.has(msg.uuid)}
onToggleRedactionSelection={() =>
onToggleRedactionSelection(msg.uuid)
}
autoRedactEnabled={autoRedactEnabled}
/>
</div>
);
})}
</div> </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> </div>
); );
} }

View File

@@ -5,7 +5,6 @@ import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../lib/types";
interface FilterState { interface FilterState {
enabledCategories: Set<MessageCategory>; enabledCategories: Set<MessageCategory>;
toggleCategory: (cat: MessageCategory) => void; toggleCategory: (cat: MessageCategory) => void;
setAllCategories: (enabled: boolean) => void;
filterMessages: (messages: ParsedMessage[]) => ParsedMessage[]; filterMessages: (messages: ParsedMessage[]) => ParsedMessage[];
searchQuery: string; searchQuery: string;
setSearchQuery: (q: string) => void; setSearchQuery: (q: string) => void;
@@ -14,7 +13,6 @@ interface FilterState {
toggleRedactionSelection: (uuid: string) => void; toggleRedactionSelection: (uuid: string) => void;
confirmRedaction: () => void; confirmRedaction: () => void;
clearRedactionSelection: () => void; clearRedactionSelection: () => void;
getMatchCount: (messages: ParsedMessage[]) => number;
autoRedactEnabled: boolean; autoRedactEnabled: boolean;
setAutoRedactEnabled: (enabled: boolean) => void; setAutoRedactEnabled: (enabled: boolean) => void;
} }
@@ -50,18 +48,6 @@ export function useFilters(): FilterState {
}); });
}, []); }, []);
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) => { const toggleRedactionSelection = useCallback((uuid: string) => {
setSelectedForRedaction((prev) => { setSelectedForRedaction((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -74,7 +60,8 @@ export function useFilters(): FilterState {
}); });
}, []); }, []);
// Fix #9: Use functional updater to avoid stale closure over selectedForRedaction // Uses functional updater to read latest selectedForRedaction without stale closure.
// Nested setState is safe in React 18+ with automatic batching.
const confirmRedaction = useCallback(() => { const confirmRedaction = useCallback(() => {
setSelectedForRedaction((currentSelected) => { setSelectedForRedaction((currentSelected) => {
setRedactedUuids((prev) => { setRedactedUuids((prev) => {
@@ -92,24 +79,6 @@ export function useFilters(): FilterState {
setSelectedForRedaction(new Set()); 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( const filterMessages = useCallback(
(messages: ParsedMessage[]): ParsedMessage[] => { (messages: ParsedMessage[]): ParsedMessage[] => {
return messages.filter( return messages.filter(
@@ -119,24 +88,10 @@ export function useFilters(): FilterState {
[enabledCategories, redactedUuids] [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( return useMemo(
() => ({ () => ({
enabledCategories, enabledCategories,
toggleCategory, toggleCategory,
setAllCategories,
setPreset,
filterMessages, filterMessages,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
@@ -145,18 +100,12 @@ export function useFilters(): FilterState {
toggleRedactionSelection, toggleRedactionSelection,
confirmRedaction, confirmRedaction,
clearRedactionSelection, clearRedactionSelection,
undoRedaction,
clearAllRedactions,
selectAllVisible,
getMatchCount,
autoRedactEnabled, autoRedactEnabled,
setAutoRedactEnabled, setAutoRedactEnabled,
}), }),
[ [
enabledCategories, enabledCategories,
toggleCategory, toggleCategory,
setAllCategories,
setPreset,
filterMessages, filterMessages,
searchQuery, searchQuery,
redactedUuids, redactedUuids,
@@ -164,11 +113,8 @@ export function useFilters(): FilterState {
toggleRedactionSelection, toggleRedactionSelection,
confirmRedaction, confirmRedaction,
clearRedactionSelection, clearRedactionSelection,
undoRedaction,
clearAllRedactions,
selectAllVisible,
getMatchCount,
autoRedactEnabled, autoRedactEnabled,
// setSearchQuery and setAutoRedactEnabled are useState setters (stable identity)
] ]
); );
} }

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Session Viewer</title> <title>Session Viewer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,13 +1,52 @@
import type { MessageCategory } from "./types"; import type { MessageCategory } from "./types";
export const CATEGORY_COLORS: Record<MessageCategory, { border: string; bg: string }> = { export const CATEGORY_COLORS: Record<
user_message: { border: "border-l-blue-500", bg: "bg-blue-50" }, MessageCategory,
assistant_text: { border: "border-l-emerald-500", bg: "bg-white" }, { dot: string; border: string; text: string }
thinking: { border: "border-l-violet-500", bg: "bg-violet-50" }, > = {
tool_call: { border: "border-l-amber-500", bg: "bg-amber-50" }, user_message: {
tool_result: { border: "border-l-indigo-500", bg: "bg-indigo-50" }, dot: "bg-category-user",
system_message: { border: "border-l-gray-500", bg: "bg-gray-100" }, border: "border-category-user-border",
hook_progress: { border: "border-l-gray-400", bg: "bg-gray-50" }, text: "text-category-user",
file_snapshot: { border: "border-l-pink-500", bg: "bg-pink-50" }, },
summary: { border: "border-l-teal-500", bg: "bg-teal-50" }, assistant_text: {
dot: "bg-category-assistant",
border: "border-category-assistant-border",
text: "text-category-assistant",
},
thinking: {
dot: "bg-category-thinking",
border: "border-category-thinking-border",
text: "text-category-thinking",
},
tool_call: {
dot: "bg-category-tool",
border: "border-category-tool-border",
text: "text-category-tool",
},
tool_result: {
dot: "bg-category-result",
border: "border-category-result-border",
text: "text-category-result",
},
system_message: {
dot: "bg-category-system",
border: "border-category-system-border",
text: "text-category-system",
},
hook_progress: {
dot: "bg-category-hook",
border: "border-category-hook-border",
text: "text-category-hook",
},
file_snapshot: {
dot: "bg-category-snapshot",
border: "border-category-snapshot-border",
text: "text-category-snapshot",
},
summary: {
dot: "bg-category-summary",
border: "border-category-summary-border",
text: "text-category-summary",
},
}; };

View File

@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { highlightSearchText } from "./markdown";
describe("highlightSearchText", () => {
it("highlights plain text matches", () => {
const result = highlightSearchText("<p>hello world</p>", "world");
expect(result).toBe(
'<p>hello <mark class="search-highlight">world</mark></p>'
);
});
it("returns html unchanged when query is empty", () => {
const html = "<p>hello</p>";
expect(highlightSearchText(html, "")).toBe(html);
});
it("does not match inside HTML tags", () => {
const html = '<a href="class-link">text</a>';
const result = highlightSearchText(html, "class");
// "class" appears in the href attribute but must not be highlighted there
expect(result).toBe('<a href="class-link">text</a>');
});
it("does not corrupt HTML entities when searching for entity content", () => {
const html = "<p>A &amp; B</p>";
const result = highlightSearchText(html, "amp");
// Must NOT produce &<mark>amp</mark>; — entity must remain intact
expect(result).toBe("<p>A &amp; B</p>");
});
it("does not corrupt &lt; entity", () => {
const html = "<p>a &lt; b</p>";
const result = highlightSearchText(html, "lt");
expect(result).toBe("<p>a &lt; b</p>");
});
it("does not corrupt &gt; entity", () => {
const html = "<p>a &gt; b</p>";
const result = highlightSearchText(html, "gt");
expect(result).toBe("<p>a &gt; b</p>");
});
it("does not corrupt numeric entities", () => {
const html = "<p>&#039;quoted&#039;</p>";
const result = highlightSearchText(html, "039");
expect(result).toBe("<p>&#039;quoted&#039;</p>");
});
it("highlights text adjacent to entities", () => {
const html = "<p>foo &amp; bar</p>";
const result = highlightSearchText(html, "foo");
expect(result).toBe(
'<p><mark class="search-highlight">foo</mark> &amp; bar</p>'
);
});
it("is case-insensitive", () => {
const result = highlightSearchText("<p>Hello World</p>", "hello");
expect(result).toContain('<mark class="search-highlight">Hello</mark>');
});
it("escapes regex special characters in query", () => {
const html = "<p>price is $100.00</p>";
const result = highlightSearchText(html, "$100.00");
expect(result).toContain('<mark class="search-highlight">$100.00</mark>');
});
});

View File

@@ -1,6 +1,8 @@
import { marked } from "marked"; import { marked } from "marked";
import hljs from "highlight.js"; import hljs from "highlight.js";
import { markedHighlight } from "marked-highlight"; import { markedHighlight } from "marked-highlight";
import "highlight.js/styles/github-dark.css";
import { escapeHtml } from "../../shared/escape-html";
marked.use( marked.use(
markedHighlight({ markedHighlight({
@@ -8,7 +10,10 @@ marked.use(
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value; return hljs.highlight(code, { language: lang }).value;
} }
return hljs.highlightAuto(code).value; // Plain-text fallback: highlightAuto tries every grammar (~6.7ms/block)
// vs explicit highlight (~0.04ms). With thousands of unlabeled blocks
// this dominates render time. Escaping is sufficient.
return escapeHtml(code);
}, },
}) })
); );
@@ -22,13 +27,6 @@ export function renderMarkdown(text: string): string {
} }
} }
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function highlightSearchText(html: string, query: string): string { export function highlightSearchText(html: string, query: string): string {
if (!query) return html; if (!query) return html;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -40,10 +38,19 @@ export function highlightSearchText(html: string, query: string): string {
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
// Even indices are text content, odd indices are tags // Even indices are text content, odd indices are tags
if (i % 2 === 0 && parts[i]) { if (i % 2 === 0 && parts[i]) {
parts[i] = parts[i].replace( // Further split on HTML entities (&amp; &lt; etc.) to avoid
regex, // matching inside them — e.g. searching "amp" must not corrupt &amp;
'<mark class="search-highlight">$1</mark>' const subParts = parts[i].split(/(&[a-zA-Z0-9#]+;)/);
); for (let j = 0; j < subParts.length; j++) {
// Odd indices are entities — skip them
if (j % 2 === 0 && subParts[j]) {
subParts[j] = subParts[j].replace(
regex,
'<mark class="search-highlight">$1</mark>'
);
}
}
parts[i] = subParts.join("");
} }
} }
return parts.join(""); return parts.join("");

View File

@@ -2,41 +2,495 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Custom scrollbar */ /* ═══════════════════════════════════════════════
Design System: CSS Custom Properties
Refined dark palette with proper depth hierarchy
═══════════════════════════════════════════════ */
@layer base {
:root {
/* Surfaces — clear elevation hierarchy */
--color-surface: #14181f;
--color-surface-raised: #1a1f2b;
--color-surface-overlay: #242a38;
--color-surface-inset: #0f1218;
/* Borders — subtle separation */
--color-border: #2a3140;
--color-border-muted: #1e2433;
/* Foreground — text hierarchy */
--color-foreground: #e8edf5;
--color-foreground-secondary: #8d96a8;
--color-foreground-muted: #505a6e;
/* Accent — refined blue with depth */
--color-accent: #5b9cf5;
--color-accent-light: #162544;
--color-accent-dark: #7db4ff;
/* Layout background — deepest layer */
--color-canvas: #0c1017;
/* Glow/highlight colors */
--color-glow-accent: rgba(91, 156, 245, 0.12);
--color-glow-success: rgba(63, 185, 80, 0.12);
/* Inter font from Google Fonts CDN */
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
/* Smooth transitions on all interactive elements */
button, a, input, [role="button"] {
transition-property: color, background-color, border-color, box-shadow, opacity, transform;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
}
/* ═══════════════════════════════════════════════
Custom scrollbar — thin, minimal, with fade
═══════════════════════════════════════════════ */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #d1d5db; background: var(--color-border);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover {
background: var(--color-foreground-muted);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
/* ═══════════════════════════════════════════════
Glass / frosted overlays
═══════════════════════════════════════════════ */
.glass {
background: rgba(26, 31, 43, 0.82);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
}
.glass-subtle {
background: rgba(20, 24, 31, 0.65);
backdrop-filter: blur(12px) saturate(160%);
-webkit-backdrop-filter: blur(12px) saturate(160%);
}
/* ═══════════════════════════════════════════════
Code blocks — refined syntax highlighting
═══════════════════════════════════════════════ */
/* Highlight.js overrides for client */
.hljs { .hljs {
background: #f6f8fa; background: var(--color-surface-inset);
padding: 1rem; padding: 1rem;
border-radius: 6px; border-radius: 0.5rem;
border: 1px solid var(--color-border-muted);
overflow-x: auto; overflow-x: auto;
font-size: 0.8125rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
} }
/* Search highlight */ /* Code block container with language label + copy button */
.code-block-wrapper {
position: relative;
margin: 0.75rem 0;
}
.code-block-wrapper .code-lang-label {
position: absolute;
top: 0;
right: 0;
padding: 0.25rem 0.625rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-foreground-muted);
background: var(--color-surface-inset);
border-bottom-left-radius: 0.375rem;
border-top-right-radius: 0.5rem;
border-left: 1px solid var(--color-border-muted);
border-bottom: 1px solid var(--color-border-muted);
}
.code-block-wrapper .code-copy-btn {
position: absolute;
top: 0.375rem;
right: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
color: var(--color-foreground-muted);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
cursor: pointer;
opacity: 0;
transition: opacity 150ms, background-color 150ms, color 150ms;
}
.code-block-wrapper:hover .code-copy-btn {
opacity: 1;
}
.code-block-wrapper .code-copy-btn:hover {
color: var(--color-foreground);
background: var(--color-surface-overlay);
}
/* ═══════════════════════════════════════════════
Search highlight — warm amber glow
═══════════════════════════════════════════════ */
mark.search-highlight { mark.search-highlight {
background: #fde68a; background: rgba(250, 204, 21, 0.2);
color: inherit; color: inherit;
padding: 0 2px; padding: 1px 3px;
border-radius: 2px; border-radius: 3px;
box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.35),
0 0 8px rgba(250, 204, 21, 0.08);
} }
/* Redaction selection indicator */ .search-match-focused {
outline: 2px solid #f59e0b;
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.15);
}
/* ═══════════════════════════════════════════════
Search minimap — scrollbar tick track
═══════════════════════════════════════════════ */
.search-minimap {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 100%;
pointer-events: none;
z-index: 10;
}
.search-minimap-tick {
position: absolute;
right: 0;
width: 8px;
height: 4px;
background: rgba(254, 240, 138, 0.7);
border: none;
padding: 0;
cursor: pointer;
pointer-events: auto;
border-radius: 1px;
transition: background-color 150ms, height 150ms, box-shadow 150ms;
}
.search-minimap-tick:hover {
background: #fde047;
box-shadow: 0 0 4px rgba(253, 224, 71, 0.4);
}
.search-minimap-viewport {
position: absolute;
right: 0;
width: 8px;
background: rgba(148, 163, 184, 0.18);
border-radius: 1px;
pointer-events: none;
transition: top 60ms linear, height 60ms linear;
min-height: 4px;
}
.search-minimap-tick-active {
background: #f59e0b;
height: 6px;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.5);
}
/* ═══════════════════════════════════════════════
Redaction selection indicator
═══════════════════════════════════════════════ */
.redaction-selected { .redaction-selected {
outline: 2px solid #ef4444; outline: 2px solid #ef4444;
outline-offset: 2px; outline-offset: 2px;
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(239, 68, 68, 0.06) 4px,
rgba(239, 68, 68, 0.06) 8px
);
} }
/* Message dimming for search */ /* ═══════════════════════════════════════════════
Message dimming for search
═══════════════════════════════════════════════ */
.message-dimmed { .message-dimmed {
opacity: 0.3; opacity: 0.2;
transition: opacity 200ms ease;
}
.message-dimmed:hover {
opacity: 0.45;
}
/* ═══════════════════════════════════════════════
Skeleton loading animation — refined shimmer
═══════════════════════════════════════════════ */
.skeleton {
background: linear-gradient(
90deg,
var(--color-border-muted) 0%,
var(--color-border) 40%,
var(--color-border-muted) 80%
);
background-size: 300% 100%;
animation: skeletonShimmer 1.8s ease-in-out infinite;
border-radius: 0.375rem;
}
@keyframes skeletonShimmer {
0% { background-position: 200% 0; }
100% { background-position: -100% 0; }
}
/* ═══════════════════════════════════════════════
Prose overrides for message content
═══════════════════════════════════════════════ */
.prose-message h1 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
color: var(--color-foreground);
}
.prose-message h2 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.375rem;
letter-spacing: -0.01em;
color: var(--color-foreground);
}
.prose-message h3 {
font-size: 1rem;
font-weight: 600;
margin-top: 0.875rem;
margin-bottom: 0.375rem;
color: var(--color-foreground);
}
.prose-message p {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
line-height: 1.625;
}
.prose-message p:first-child {
margin-top: 0;
}
.prose-message p:last-child {
margin-bottom: 0;
}
.prose-message ul,
.prose-message ol {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
padding-left: 1.25rem;
}
.prose-message li {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
line-height: 1.5;
}
.prose-message code:not(pre code) {
font-family: "JetBrains Mono", "Fira Code", "SF Mono", ui-monospace, monospace;
font-size: 0.8125em;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-surface-inset);
border: 1px solid var(--color-border-muted);
color: #c4a1ff;
font-weight: 500;
}
.prose-message pre {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
max-width: 100%;
overflow-x: auto;
}
.prose-message blockquote {
border-left: 3px solid var(--color-border);
padding-left: 0.75rem;
color: var(--color-foreground-secondary);
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.prose-message a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: rgba(91, 156, 245, 0.3);
transition: text-decoration-color 150ms;
}
.prose-message a:hover {
color: var(--color-accent-dark);
text-decoration-color: rgba(125, 180, 255, 0.6);
}
.prose-message table {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
margin-bottom: 0.75rem;
font-size: 0.8125rem;
}
.prose-message th,
.prose-message td {
border: 1px solid var(--color-border-muted);
padding: 0.375rem 0.75rem;
text-align: left;
}
.prose-message th {
background: var(--color-surface-inset);
font-weight: 600;
}
/* ═══════════════════════════════════════════════
Focus ring system
═══════════════════════════════════════════════ */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Custom checkbox styling */
.custom-checkbox {
appearance: none;
width: 1rem;
height: 1rem;
border: 1.5px solid var(--color-border);
border-radius: 0.25rem;
background: var(--color-surface);
cursor: pointer;
flex-shrink: 0;
transition: all 150ms;
position: relative;
}
.custom-checkbox:hover {
border-color: var(--color-foreground-muted);
}
.custom-checkbox:checked {
background: var(--color-accent);
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(91, 156, 245, 0.15);
}
.custom-checkbox:checked::after {
content: "";
position: absolute;
top: 1px;
left: 4px;
width: 5px;
height: 9px;
border: solid #0c1017;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.custom-checkbox.checkbox-danger:checked {
background: #ef4444;
border-color: #ef4444;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15);
}
/* ═══════════════════════════════════════════════
Button system
═══════════════════════════════════════════════ */
@layer components {
.btn {
@apply inline-flex items-center justify-center font-medium rounded-lg transition-all;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
@apply active:scale-[0.97];
--tw-ring-offset-color: var(--color-surface);
}
.btn-sm {
@apply px-3 py-1.5 text-caption;
}
.btn-md {
@apply px-4 py-2 text-body;
}
.btn-primary {
background: linear-gradient(135deg, #5b9cf5, #4a8be0);
@apply text-white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
@apply hover:brightness-110;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
@apply focus-visible:ring-accent;
}
.btn-secondary {
@apply bg-surface-raised text-foreground-secondary border border-border;
@apply hover:bg-surface-overlay hover:text-foreground hover:border-foreground-muted/30;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
@apply focus-visible:ring-accent;
}
.btn-ghost {
@apply text-foreground-secondary;
@apply hover:bg-surface-overlay hover:text-foreground;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
@apply focus-visible:ring-accent;
}
.btn-danger {
background: linear-gradient(135deg, #dc2626, #c42020);
@apply text-white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
@apply hover:brightness-110;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
@apply focus-visible:ring-red-500;
}
} }

View File

@@ -28,18 +28,12 @@ export function createApp() {
return app; return app;
} }
const TAILSCALE_IP = "100.84.4.113";
export function startServer() { export function startServer() {
const PORT = parseInt(process.env.PORT || "3848", 10); const PORT = parseInt(process.env.PORT || "3848", 10);
const app = createApp(); const app = createApp();
// Bind to both localhost and Tailscale — not the public interface const server = app.listen(PORT, "127.0.0.1", async () => {
const localServer = app.listen(PORT, "127.0.0.1", () => {
console.log(`Session Viewer API running on http://localhost:${PORT}`); console.log(`Session Viewer API running on http://localhost:${PORT}`);
});
const tsServer = app.listen(PORT, TAILSCALE_IP, async () => {
console.log(`Session Viewer API running on http://${TAILSCALE_IP}:${PORT}`);
if (process.env.SESSION_VIEWER_OPEN_BROWSER === "1") { if (process.env.SESSION_VIEWER_OPEN_BROWSER === "1") {
const { default: open } = await import("open"); const { default: open } = await import("open");
open(`http://localhost:${PORT}`); open(`http://localhost:${PORT}`);
@@ -48,13 +42,12 @@ export function startServer() {
// Graceful shutdown so tsx watch can restart cleanly // Graceful shutdown so tsx watch can restart cleanly
function shutdown() { function shutdown() {
localServer.close(); server.close();
tsServer.close();
} }
process.on("SIGINT", shutdown); process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown); process.on("SIGTERM", shutdown);
return tsServer; return server;
} }
// Only auto-start when run directly (not imported by tests) // Only auto-start when run directly (not imported by tests)

View File

@@ -4,6 +4,7 @@ import { markedHighlight } from "marked-highlight";
import type { ExportRequest, ParsedMessage } from "../../shared/types.js"; import type { ExportRequest, ParsedMessage } from "../../shared/types.js";
import { CATEGORY_LABELS } from "../../shared/types.js"; import { CATEGORY_LABELS } from "../../shared/types.js";
import { redactMessage } from "../../shared/sensitive-redactor.js"; import { redactMessage } from "../../shared/sensitive-redactor.js";
import { escapeHtml } from "../../shared/escape-html.js";
marked.use( marked.use(
markedHighlight({ markedHighlight({
@@ -11,11 +12,31 @@ marked.use(
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value; return hljs.highlight(code, { language: lang }).value;
} }
return hljs.highlightAuto(code).value; // Plain-text fallback: highlightAuto tries every grammar (~6.7ms/block)
// vs explicit highlight (~0.04ms). With thousands of unlabeled blocks
// this dominates export time (50+ seconds). Escaping is sufficient.
return escapeHtml(code);
}, },
}) })
); );
// Categories whose content is structured data (JSON, logs, snapshots) — not markdown.
// Rendered as preformatted text to avoid the cost of markdown parsing on large blobs.
const PREFORMATTED_CATEGORIES = new Set(["hook_progress", "tool_result", "file_snapshot"]);
// Category dot/border colors matching the client-side design
const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: string }> = {
user_message: { dot: "#58a6ff", border: "#1f3a5f", text: "#58a6ff" },
assistant_text: { dot: "#3fb950", border: "#1a4d2e", text: "#3fb950" },
thinking: { dot: "#bc8cff", border: "#3b2d6b", text: "#bc8cff" },
tool_call: { dot: "#d29922", border: "#4d3a15", text: "#d29922" },
tool_result: { dot: "#8b8cf8", border: "#2d2d60", text: "#8b8cf8" },
system_message: { dot: "#8b949e", border: "#30363d", text: "#8b949e" },
hook_progress: { dot: "#484f58", border: "#21262d", text: "#484f58" },
file_snapshot: { dot: "#f778ba", border: "#5c2242", text: "#f778ba" },
summary: { dot: "#2dd4bf", border: "#1a4d45", text: "#2dd4bf" },
};
export async function generateExportHtml( export async function generateExportHtml(
req: ExportRequest req: ExportRequest
): Promise<string> { ): Promise<string> {
@@ -40,9 +61,7 @@ export async function generateExportHtml(
continue; continue;
} }
if (lastWasRedacted) { if (lastWasRedacted) {
messageHtmlParts.push( messageHtmlParts.push(renderRedactedDivider());
'<div class="redacted-divider">··· content redacted ···</div>'
);
lastWasRedacted = false; lastWasRedacted = false;
} }
const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg; const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg;
@@ -71,9 +90,18 @@ ${hljsCss}
<header class="session-header"> <header class="session-header">
<h1>Session Export</h1> <h1>Session Export</h1>
<div class="meta"> <div class="meta">
<span class="project">Project: ${escapeHtml(session.project)}</span> <span class="meta-item">
<span class="date">Date: ${escapeHtml(dateStr)}</span> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"/></svg>
<span class="count">${messageCount} messages</span> ${escapeHtml(session.project)}
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/></svg>
${escapeHtml(dateStr)}
</span>
<span class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"/></svg>
${messageCount} message${messageCount !== 1 ? "s" : ""}
</span>
</div> </div>
</header> </header>
<main class="messages"> <main class="messages">
@@ -84,26 +112,55 @@ ${hljsCss}
</html>`; </html>`;
} }
function renderRedactedDivider(): string {
return `<div class="redacted-divider">
<div class="redacted-line"></div>
<div class="redacted-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"/></svg>
<span>content redacted</span>
</div>
<div class="redacted-line"></div>
</div>`;
}
function renderMessage(msg: ParsedMessage): string { function renderMessage(msg: ParsedMessage): string {
const categoryClass = msg.category.replace(/_/g, "-"); const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message;
const label = CATEGORY_LABELS[msg.category]; const label = CATEGORY_LABELS[msg.category];
let bodyHtml: string; let bodyHtml: string;
if (msg.category === "tool_call") { if (msg.category === "tool_call") {
const inputHtml = msg.toolInput const inputHtml = msg.toolInput
? `<pre class="tool-input"><code>${escapeHtml(msg.toolInput)}</code></pre>` ? `<pre class="hljs"><code>${escapeHtml(msg.toolInput)}</code></pre>`
: ""; : "";
bodyHtml = `<div class="tool-name">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`; bodyHtml = `<div class="tool-name" style="color: ${style.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
} else if (PREFORMATTED_CATEGORIES.has(msg.category)) {
// These categories contain structured data (JSON, logs, snapshots), not prose.
// Rendering them through marked is both incorrect and extremely slow on large
// content (370KB JSON blobs take ~300ms each in marked.parse).
bodyHtml = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
} else { } else {
bodyHtml = renderMarkdown(msg.content); bodyHtml = renderMarkdown(msg.content);
} }
return `<div class="message ${categoryClass}"> const timestamp = msg.timestamp ? formatTimestamp(msg.timestamp) : "";
<div class="message-label">${escapeHtml(label)}</div> const timestampHtml = timestamp
<div class="message-body">${bodyHtml}</div> ? `<span class="header-sep">&middot;</span><span class="message-time">${escapeHtml(timestamp)}</span>`
: "";
return `<div class="message" style="border-color: ${style.border}">
<div class="message-header">
<span class="message-dot" style="background: ${style.dot}"></span>
<span class="message-label">${escapeHtml(label)}</span>
${timestampHtml}
</div>
<div class="message-body prose-message">${bodyHtml}</div>
</div>`; </div>`;
} }
// marked.parse() is called synchronously here. In marked v14+ it can return
// Promise<string> if async extensions are configured. Our markedHighlight setup
// is synchronous, so the cast is safe — but do not add async extensions without
// updating this callsite.
function renderMarkdown(text: string): string { function renderMarkdown(text: string): string {
try { try {
return marked.parse(text) as string; return marked.parse(text) as string;
@@ -112,18 +169,23 @@ function renderMarkdown(text: string): string {
} }
} }
function escapeHtml(text: string): string { function formatTimestamp(ts: string): string {
return text try {
.replace(/&/g, "&amp;") const d = new Date(ts);
.replace(/</g, "&lt;") return d.toLocaleTimeString(undefined, {
.replace(/>/g, "&gt;") hour: "2-digit",
.replace(/"/g, "&quot;") minute: "2-digit",
.replace(/'/g, "&#039;"); second: "2-digit",
});
} catch {
return "";
}
} }
function getHighlightCss(): string { function getHighlightCss(): string {
// Dark theme highlight.js (GitHub Dark) matching the client
return ` return `
.hljs{color:#e6edf3;background:#161b22} .hljs{background:#0d1117;color:#e6edf3;padding:1rem;border-radius:0.5rem;border:1px solid #30363d;overflow-x:auto;font-size:0.8125rem;line-height:1.6;white-space:pre-wrap;word-break:break-word}
.hljs-comment,.hljs-quote{color:#8b949e;font-style:italic} .hljs-comment,.hljs-quote{color:#8b949e;font-style:italic}
.hljs-keyword,.hljs-selector-tag,.hljs-subst{color:#ff7b72;font-weight:bold} .hljs-keyword,.hljs-selector-tag,.hljs-subst{color:#ff7b72;font-weight:bold}
.hljs-number,.hljs-literal,.hljs-variable,.hljs-template-variable,.hljs-tag .hljs-attr{color:#79c0ff} .hljs-number,.hljs-literal,.hljs-variable,.hljs-template-variable,.hljs-tag .hljs-attr{color:#79c0ff}
@@ -144,59 +206,208 @@ function getHighlightCss(): string {
function getExportCss(): string { function getExportCss(): string {
return ` return `
/* Reset & base */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.6; background: #0d1117;
color: #e6edf3;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
} }
.session-export { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
/* Layout */
.session-export { max-width: 64rem; margin: 0 auto; padding: 1.25rem 1.5rem 3rem; }
/* Header */
.session-header { .session-header {
background: #161b22; color: #e6edf3; padding: 1.5rem 2rem; background: #1c2128;
border-radius: 12px; margin-bottom: 2rem; border: 1px solid #30363d; padding: 1.25rem 1.5rem;
border-radius: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #30363d;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
} }
.session-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; } .session-header h1 {
.session-header .meta { display: flex; gap: 1.5rem; font-size: 0.875rem; color: #8b949e; flex-wrap: wrap; } font-size: 1.125rem;
.messages { display: flex; flex-direction: column; gap: 1rem; } font-weight: 600;
letter-spacing: -0.02em;
color: #e6edf3;
margin-bottom: 0.5rem;
}
.meta {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #484f58;
line-height: 1rem;
letter-spacing: 0.01em;
}
.meta-item svg { color: #484f58; flex-shrink: 0; }
/* Messages */
.messages { display: flex; flex-direction: column; gap: 0.75rem; }
/* Message card */
.message { .message {
padding: 1rem 1.25rem; border-radius: 10px; background: #1c2128;
border-left: 4px solid #30363d; background: #161b22; border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.3); border: 1px solid #30363d;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
}
.message-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem 0.25rem;
}
.message-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
} }
.message-label { .message-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase; font-size: 0.75rem;
letter-spacing: 0.05em; margin-bottom: 0.5rem; color: #8b949e; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #484f58;
line-height: 1rem;
} }
.message-body { overflow-wrap: break-word; } .header-sep {
.message-body pre { color: #30363d;
background: #0d1117; padding: 1rem; border-radius: 6px; font-size: 0.75rem;
overflow-x: auto; font-size: 0.875rem; margin: 0.5rem 0;
border: 1px solid #30363d;
} }
.message-body code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.875em; } .message-time {
.message-body p { margin: 0.5em 0; } font-size: 0.75rem;
.message-body ul, .message-body ol { padding-left: 1.5em; margin: 0.5em 0; } color: #484f58;
.message-body h1,.message-body h2,.message-body h3 { margin: 0.75em 0 0.25em; color: #f0f6fc; } font-variant-numeric: tabular-nums;
.message-body a { color: #58a6ff; } line-height: 1rem;
.message-body table { border-collapse: collapse; margin: 0.5em 0; width: 100%; } letter-spacing: 0.01em;
.message-body th, .message-body td { border: 1px solid #30363d; padding: 0.4em 0.75em; text-align: left; } }
.message-body th { background: #1c2128; } .message-body {
.message-body blockquote { border-left: 3px solid #30363d; padding-left: 1em; color: #8b949e; margin: 0.5em 0; } padding: 0.25rem 1rem 1rem;
.message-body hr { border: none; border-top: 1px solid #30363d; margin: 1em 0; } overflow-wrap: break-word;
.user-message { border-left-color: #58a6ff; background: #121d2f; } font-size: 0.875rem;
.assistant-text { border-left-color: #3fb950; background: #161b22; } line-height: 1.5rem;
.thinking { border-left-color: #bc8cff; background: #1c1631; } }
.tool-call { border-left-color: #d29922; background: #1c1a10; }
.tool-result { border-left-color: #8b8cf8; background: #181830; } /* Tool name */
.system-message { border-left-color: #8b949e; background: #1c2128; font-size: 0.875rem; } .tool-name { font-weight: 500; margin-bottom: 0.5rem; }
.hook-progress { border-left-color: #484f58; background: #131820; font-size: 0.875rem; }
.file-snapshot { border-left-color: #f778ba; background: #241525; } /* redacted-divider */
.summary { border-left-color: #2dd4bf; background: #122125; }
.tool-name { font-weight: 600; color: #d29922; margin-bottom: 0.5rem; }
.tool-input { font-size: 0.8rem; }
.redacted-divider { .redacted-divider {
text-align: center; color: #484f58; font-size: 0.875rem; display: flex;
padding: 0.75rem 0; border-top: 1px dashed #30363d; border-bottom: 1px dashed #30363d; align-items: center;
margin: 0.5rem 0; gap: 0.75rem;
padding: 0.75rem 0;
}
.redacted-line {
flex: 1;
border-top: 1px dashed rgba(127, 29, 29, 0.4);
}
.redacted-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
color: #f87171;
line-height: 1rem;
}
.redacted-label svg { color: #f87171; flex-shrink: 0; }
/* ═══════════════════════════════════════════════
Prose — message content typography
═══════════════════════════════════════════════ */
.prose-message h1 {
font-size: 1.25rem; font-weight: 600;
margin-top: 1.25rem; margin-bottom: 0.5rem;
letter-spacing: -0.02em; color: #f0f6fc;
}
.prose-message h2 {
font-size: 1.125rem; font-weight: 600;
margin-top: 1rem; margin-bottom: 0.375rem;
letter-spacing: -0.01em; color: #f0f6fc;
}
.prose-message h3 {
font-size: 1rem; font-weight: 600;
margin-top: 0.875rem; margin-bottom: 0.375rem;
color: #f0f6fc;
}
.prose-message p {
margin-top: 0.5rem; margin-bottom: 0.5rem;
line-height: 1.625;
}
.prose-message p:first-child { margin-top: 0; }
.prose-message p:last-child { margin-bottom: 0; }
.prose-message ul, .prose-message ol {
margin-top: 0.5rem; margin-bottom: 0.5rem;
padding-left: 1.25rem;
}
.prose-message li {
margin-top: 0.25rem; margin-bottom: 0.25rem;
line-height: 1.5;
}
.prose-message code:not(pre code) {
font-family: "JetBrains Mono", "Fira Code", "SF Mono", ui-monospace, monospace;
font-size: 0.8125em;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: #0d1117;
border: 1px solid #21262d;
color: #bc8cff;
font-weight: 500;
}
.prose-message pre {
margin-top: 0.75rem; margin-bottom: 0.75rem;
}
.prose-message blockquote {
border-left: 3px solid #30363d;
padding-left: 0.75rem;
color: #8b949e;
margin-top: 0.5rem; margin-bottom: 0.5rem;
}
.prose-message a {
color: #58a6ff;
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-message a:hover { color: #79c0ff; }
.prose-message table {
width: 100%; border-collapse: collapse;
margin-top: 0.75rem; margin-bottom: 0.75rem;
font-size: 0.8125rem;
}
.prose-message th, .prose-message td {
border: 1px solid #30363d;
padding: 0.375rem 0.75rem;
text-align: left;
}
.prose-message th {
background: #161b22;
font-weight: 600;
}
.prose-message hr {
border: none;
border-top: 1px solid #30363d;
margin: 1rem 0;
}
/* Print-friendly */
@media print {
body { background: #1c2128; }
.session-export { padding: 0; max-width: 100%; }
.session-header, .message { box-shadow: none; break-inside: avoid; }
} }
`; `;
} }

View File

@@ -28,46 +28,70 @@ export async function discoverSessions(
return sessions; return sessions;
} }
for (const projectDir of projectDirs) { // Parallel I/O: stat + readFile for all project dirs concurrently
const projectPath = path.join(projectsDir, projectDir); const results = await Promise.all(
projectDirs.map(async (projectDir) => {
const projectPath = path.join(projectsDir, projectDir);
const entries: SessionEntry[] = [];
let stat; let stat;
try { try {
stat = await fs.stat(projectPath); stat = await fs.stat(projectPath);
} catch { } catch {
continue; return entries;
}
if (!stat.isDirectory()) continue;
const indexPath = path.join(projectPath, "sessions-index.json");
try {
const content = await fs.readFile(indexPath, "utf-8");
const parsed = JSON.parse(content);
// Handle both formats: raw array or { version, entries: [...] }
const entries: IndexEntry[] = Array.isArray(parsed)
? parsed
: parsed.entries ?? [];
for (const entry of entries) {
const sessionPath =
entry.fullPath ||
path.join(projectPath, `${entry.sessionId}.jsonl`);
sessions.push({
id: entry.sessionId,
summary: entry.summary || "",
firstPrompt: entry.firstPrompt || "",
project: projectDir,
created: entry.created || "",
modified: entry.modified || "",
messageCount: entry.messageCount || 0,
path: sessionPath,
});
} }
} catch { if (!stat.isDirectory()) return entries;
// Missing or corrupt index - skip
} const indexPath = path.join(projectPath, "sessions-index.json");
try {
const content = await fs.readFile(indexPath, "utf-8");
const parsed = JSON.parse(content);
// Handle both formats: raw array or { version, entries: [...] }
const rawEntries: IndexEntry[] = Array.isArray(parsed)
? parsed
: parsed.entries ?? [];
for (const entry of rawEntries) {
const sessionPath =
entry.fullPath ||
path.join(projectPath, `${entry.sessionId}.jsonl`);
// Validate: reject paths with traversal segments or non-JSONL extensions.
// Check the raw path for ".." before resolving (resolve normalizes them away).
if (sessionPath.includes("..") || !sessionPath.endsWith(".jsonl")) {
continue;
}
const resolved = path.resolve(sessionPath);
// Containment check: reject paths that escape the projects directory.
// A corrupted or malicious index could set fullPath to an arbitrary
// absolute path like "/etc/shadow.jsonl".
if (!resolved.startsWith(projectsDir + path.sep) && resolved !== projectsDir) {
continue;
}
entries.push({
id: entry.sessionId,
summary: entry.summary || "",
firstPrompt: entry.firstPrompt || "",
project: projectDir,
created: entry.created || "",
modified: entry.modified || "",
messageCount: entry.messageCount || 0,
path: resolved,
});
}
} catch {
// Missing or corrupt index - skip
}
return entries;
})
);
for (const entries of results) {
sessions.push(...entries);
} }
sessions.sort((a, b) => { sessions.sort((a, b) => {

18
src/shared/escape-html.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* HTML-escape a string for safe interpolation into HTML content and attributes.
* Escapes the 5 characters that have special meaning in HTML: & < > " '
*
* Single-pass implementation: one regex scan with a lookup map instead of
* five chained .replace() calls.
*/
const ESC_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
export function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => ESC_MAP[ch]);
}

View File

@@ -316,7 +316,7 @@ export const SENSITIVE_PATTERNS: SensitivePattern[] = [
keywords: ["postgres", "mysql", "mongodb", "redis", "amqp", "mssql"], keywords: ["postgres", "mysql", "mongodb", "redis", "amqp", "mssql"],
}, },
// #30 URLs with credentials // #30 URLs with credentials (user:pass@host pattern)
{ {
id: "url_with_creds", id: "url_with_creds",
label: "[URL_WITH_CREDS]", label: "[URL_WITH_CREDS]",
@@ -328,7 +328,7 @@ export const SENSITIVE_PATTERNS: SensitivePattern[] = [
{ {
id: "email", id: "email",
label: "[EMAIL]", label: "[EMAIL]",
regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
keywords: ["@"], keywords: ["@"],
falsePositiveCheck: isAllowlistedEmail, falsePositiveCheck: isAllowlistedEmail,
}, },
@@ -339,7 +339,7 @@ export const SENSITIVE_PATTERNS: SensitivePattern[] = [
label: "[IP_ADDR]", label: "[IP_ADDR]",
regex: regex:
/\b(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\b/g, /\b(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\b/g,
keywords: ["."], keywords: ["0.", "1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.", "9."],
falsePositiveCheck: isAllowlistedIp, falsePositiveCheck: isAllowlistedIp,
}, },
@@ -366,10 +366,22 @@ export const SENSITIVE_PATTERNS: SensitivePattern[] = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Check if any keyword from the pattern appears in the content (case-sensitive * Check if any keyword from the pattern appears in the content.
* for most patterns, lowered for a cheap pre-check). * Case-sensitive by default; pass caseInsensitive=true for patterns
* with case-insensitive regexes.
*/ */
function hasKeyword(content: string, keywords: string[]): boolean { function hasKeyword(
content: string,
keywords: string[],
caseInsensitive = false
): boolean {
if (caseInsensitive) {
const lower = content.toLowerCase();
for (const kw of keywords) {
if (lower.includes(kw.toLowerCase())) return true;
}
return false;
}
for (const kw of keywords) { for (const kw of keywords) {
if (content.includes(kw)) return true; if (content.includes(kw)) return true;
} }
@@ -390,8 +402,10 @@ export function redactSensitiveContent(input: string): RedactionResult {
const matchedCategories = new Set<string>(); const matchedCategories = new Set<string>();
for (const pattern of SENSITIVE_PATTERNS) { for (const pattern of SENSITIVE_PATTERNS) {
// Keyword pre-filter: skip expensive regex if no keyword found // Keyword pre-filter: skip expensive regex if no keyword found.
if (!hasKeyword(result, pattern.keywords)) { // Use case-insensitive matching when the regex has the /i flag.
const isCaseInsensitive = pattern.regex.flags.includes("i");
if (!hasKeyword(result, pattern.keywords, isCaseInsensitive)) {
continue; continue;
} }

View File

@@ -2,7 +2,131 @@
export default { export default {
content: ["./src/client/**/*.{html,tsx,ts}"], content: ["./src/client/**/*.{html,tsx,ts}"],
theme: { theme: {
extend: {}, extend: {
colors: {
// Semantic surface colors via CSS variables (dark-mode ready)
surface: {
DEFAULT: "var(--color-surface)",
raised: "var(--color-surface-raised)",
overlay: "var(--color-surface-overlay)",
inset: "var(--color-surface-inset)",
},
border: {
DEFAULT: "var(--color-border)",
muted: "var(--color-border-muted)",
},
foreground: {
DEFAULT: "var(--color-foreground)",
secondary: "var(--color-foreground-secondary)",
muted: "var(--color-foreground-muted)",
},
accent: {
DEFAULT: "var(--color-accent)",
light: "var(--color-accent-light)",
dark: "var(--color-accent-dark)",
},
// Cohesive message category colors — unified saturation/luminance band
category: {
user: { DEFAULT: "#5b9cf5", light: "#111e35", border: "#1e3560" },
assistant: { DEFAULT: "#4ebe68", light: "#0f2519", border: "#1b4a30" },
thinking: { DEFAULT: "#b78ef5", light: "#1a1432", border: "#362868" },
tool: { DEFAULT: "#d4a030", light: "#1a1810", border: "#4a3818" },
result: { DEFAULT: "#8890f5", light: "#161830", border: "#2a2c5e" },
system: { DEFAULT: "#8996a8", light: "#161a22", border: "#2a3140" },
hook: { DEFAULT: "#586070", light: "#12161e", border: "#222830" },
snapshot: { DEFAULT: "#f07ab5", light: "#221428", border: "#552040" },
summary: { DEFAULT: "#38d4b8", light: "#102024", border: "#1a4a42" },
},
},
fontFamily: {
sans: [
"Inter",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"sans-serif",
],
mono: [
"JetBrains Mono",
"Fira Code",
"SF Mono",
"Cascadia Code",
"ui-monospace",
"SFMono-Regular",
"monospace",
],
},
fontSize: {
// Typography scale: caption, body, subheading, heading
caption: ["0.75rem", { lineHeight: "1rem", letterSpacing: "0.01em" }],
body: ["0.875rem", { lineHeight: "1.5rem", letterSpacing: "0" }],
subheading: ["0.9375rem", { lineHeight: "1.5rem", letterSpacing: "-0.01em" }],
heading: ["1.125rem", { lineHeight: "1.75rem", letterSpacing: "-0.02em" }],
"heading-lg": ["1.5rem", { lineHeight: "2rem", letterSpacing: "-0.025em" }],
},
spacing: {
// Refined spacing for layout rhythm
0.5: "0.125rem",
1.5: "0.375rem",
2.5: "0.625rem",
4.5: "1.125rem",
18: "4.5rem",
},
borderRadius: {
DEFAULT: "0.375rem",
lg: "0.5rem",
xl: "0.75rem",
"2xl": "1rem",
},
boxShadow: {
"xs": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"sm": "0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.05)",
"DEFAULT": "0 2px 8px -2px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.06)",
"md": "0 4px 12px -2px rgb(0 0 0 / 0.1), 0 2px 6px -2px rgb(0 0 0 / 0.06)",
"lg": "0 12px 28px -6px rgb(0 0 0 / 0.15), 0 4px 12px -4px rgb(0 0 0 / 0.08)",
"card": "0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04)",
"card-hover": "0 6px 16px -4px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.06)",
"glow-accent": "0 0 16px rgba(91, 156, 245, 0.12)",
"glow-success": "0 0 16px rgba(78, 190, 104, 0.12)",
},
transitionDuration: {
DEFAULT: "150ms",
},
transitionTimingFunction: {
DEFAULT: "cubic-bezier(0.4, 0, 0.2, 1)",
},
animation: {
"fade-in": "fadeIn 250ms ease-out",
"slide-in": "slideIn 250ms ease-out",
"slide-up": "slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1)",
"skeleton": "skeleton 1.5s ease-in-out infinite",
"pulse-subtle": "pulseSubtle 2s ease-in-out infinite",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0", transform: "translateY(6px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
slideIn: {
"0%": { opacity: "0", transform: "translateX(-8px)" },
"100%": { opacity: "1", transform: "translateX(0)" },
},
slideUp: {
"0%": { opacity: "0", transform: "translateY(12px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
skeleton: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.4" },
},
pulseSubtle: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.7" },
},
},
},
}, },
plugins: [], plugins: [],
}; };

View File

@@ -114,6 +114,8 @@ describe("html-exporter", () => {
const html = await generateExportHtml(makeExportRequest([msg])); const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain("test-project"); expect(html).toContain("test-project");
expect(html).toContain("Session Export"); expect(html).toContain("Session Export");
expect(html).toContain("1 messages"); expect(html).toContain("1 message");
// Verify singular — should NOT contain "1 messages"
expect(html).not.toMatch(/\b1 messages\b/);
}); });
}); });

View File

@@ -251,6 +251,18 @@ describe("sensitive-redactor", () => {
const result = redactSensitiveContent(input); const result = redactSensitiveContent(input);
expect(result.redactionCount).toBeGreaterThan(0); expect(result.redactionCount).toBeGreaterThan(0);
}); });
it("redacts mixed-case key assignments (case-insensitive keyword matching)", () => {
const input = 'ApiKey = "abcdefghijklmnopqrst"';
const result = redactSensitiveContent(input);
expect(result.redactionCount).toBeGreaterThan(0);
});
it("redacts UPPER_CASE key assignments via generic pattern", () => {
const input = 'AUTH_TOKEN: SuperSecretVal1234';
const result = redactSensitiveContent(input);
expect(result.redactionCount).toBeGreaterThan(0);
});
}); });
// --- Tier 2: PII/System Info --- // --- Tier 2: PII/System Info ---
@@ -387,7 +399,9 @@ describe("sensitive-redactor", () => {
it("redacts SECRET_KEY assignments", () => { it("redacts SECRET_KEY assignments", () => {
const input = "SECRET_KEY=abcdefghij1234567890"; const input = "SECRET_KEY=abcdefghij1234567890";
const result = redactSensitiveContent(input); const result = redactSensitiveContent(input);
expect(result.sanitized).toContain("[ENV_SECRET]"); // May be matched by generic_api_key or env_var_secret depending on order
expect(result.redactionCount).toBeGreaterThan(0);
expect(result.sanitized).not.toContain("abcdefghij1234567890");
}); });
it("redacts DATABASE_PASSWORD assignments", () => { it("redacts DATABASE_PASSWORD assignments", () => {

View File

@@ -15,12 +15,13 @@ describe("session-discovery", () => {
const projectDir = path.join(tmpDir, "test-project"); const projectDir = path.join(tmpDir, "test-project");
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
const sessionPath = path.join(projectDir, "sess-001.jsonl");
await fs.writeFile( await fs.writeFile(
path.join(projectDir, "sessions-index.json"), path.join(projectDir, "sessions-index.json"),
makeIndex([ makeIndex([
{ {
sessionId: "sess-001", sessionId: "sess-001",
fullPath: "/tmp/sess-001.jsonl", fullPath: sessionPath,
summary: "Test session", summary: "Test session",
firstPrompt: "Hello", firstPrompt: "Hello",
created: "2025-10-15T10:00:00Z", created: "2025-10-15T10:00:00Z",
@@ -36,7 +37,7 @@ describe("session-discovery", () => {
expect(sessions[0].summary).toBe("Test session"); expect(sessions[0].summary).toBe("Test session");
expect(sessions[0].project).toBe("test-project"); expect(sessions[0].project).toBe("test-project");
expect(sessions[0].messageCount).toBe(5); expect(sessions[0].messageCount).toBe(5);
expect(sessions[0].path).toBe("/tmp/sess-001.jsonl"); expect(sessions[0].path).toBe(sessionPath);
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
}); });
@@ -111,17 +112,30 @@ describe("session-discovery", () => {
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
}); });
it("uses fullPath from index entry", async () => { it("rejects paths with traversal segments", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-fp-${Date.now()}`); const tmpDir = path.join(os.tmpdir(), `sv-test-traversal-${Date.now()}`);
const projectDir = path.join(tmpDir, "fp-project"); const projectDir = path.join(tmpDir, "traversal-project");
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
const goodPath = path.join(projectDir, "good-001.jsonl");
await fs.writeFile( await fs.writeFile(
path.join(projectDir, "sessions-index.json"), path.join(projectDir, "sessions-index.json"),
makeIndex([ makeIndex([
{ {
sessionId: "fp-001", sessionId: "evil-001",
fullPath: "/home/ubuntu/.claude/projects/xyz/fp-001.jsonl", fullPath: "/home/ubuntu/../../../etc/passwd",
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
},
{
sessionId: "evil-002",
fullPath: "/home/ubuntu/sessions/not-a-jsonl.txt",
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
},
{
sessionId: "good-001",
fullPath: goodPath,
created: "2025-10-15T10:00:00Z", created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z", modified: "2025-10-15T11:00:00Z",
}, },
@@ -129,10 +143,62 @@ describe("session-discovery", () => {
); );
const sessions = await discoverSessions(tmpDir); const sessions = await discoverSessions(tmpDir);
expect(sessions[0].path).toBe( expect(sessions).toHaveLength(1);
"/home/ubuntu/.claude/projects/xyz/fp-001.jsonl" expect(sessions[0].id).toBe("good-001");
await fs.rm(tmpDir, { recursive: true });
});
it("rejects absolute paths outside the projects directory", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-containment-${Date.now()}`);
const projectDir = path.join(tmpDir, "contained-project");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(
path.join(projectDir, "sessions-index.json"),
makeIndex([
{
sessionId: "escaped-001",
fullPath: "/etc/shadow.jsonl",
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
},
{
sessionId: "escaped-002",
fullPath: "/tmp/other-dir/secret.jsonl",
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
},
])
); );
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(0);
await fs.rm(tmpDir, { recursive: true });
});
it("uses fullPath from index entry", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-fp-${Date.now()}`);
const projectDir = path.join(tmpDir, "fp-project");
await fs.mkdir(projectDir, { recursive: true });
const sessionPath = path.join(projectDir, "fp-001.jsonl");
await fs.writeFile(
path.join(projectDir, "sessions-index.json"),
makeIndex([
{
sessionId: "fp-001",
fullPath: sessionPath,
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
},
])
);
const sessions = await discoverSessions(tmpDir);
expect(sessions[0].path).toBe(sessionPath);
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
}); });
}); });

View File

@@ -12,10 +12,10 @@ export default defineConfig({
}, },
server: { server: {
port: 3847, port: 3847,
// Vite only supports one host. Use Tailscale IP so it's reachable host: "127.0.0.1",
// from both the local machine (via the TS IP) and the tailnet. hmr: {
// localhost:3847 won't work for the Vite dev server — use the TS IP. overlay: false,
host: "100.84.4.113", },
proxy: { proxy: {
"/api": { "/api": {
target: "http://127.0.0.1:3848", target: "http://127.0.0.1:3848",

View File

@@ -12,7 +12,7 @@ export default defineConfig({
environment: "node", environment: "node",
include: [ include: [
"tests/unit/**/*.test.ts", "tests/unit/**/*.test.ts",
"src/client/components/**/*.test.tsx", "src/client/**/*.test.{ts,tsx}",
], ],
}, },
}); });