Compare commits
11 Commits
6d2a687259
...
69857fa825
| Author | SHA1 | Date | |
|---|---|---|---|
| 69857fa825 | |||
| baaf2fca4c | |||
| 0e89924685 | |||
| 9716091ecc | |||
| 6a4e22f1f8 | |||
| 15a312d98c | |||
| 40b3ccf33e | |||
| 0e5a36f0d1 | |||
| eb8001dbf1 | |||
| 96da009086 | |||
| 8e713b9c50 |
@@ -1,12 +1,14 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||
import { SessionList } from "./components/SessionList";
|
||||
import { SessionViewer } from "./components/SessionViewer";
|
||||
import { FilterPanel } from "./components/FilterPanel";
|
||||
import { SearchBar } from "./components/SearchBar";
|
||||
import { SearchMinimap } from "./components/SearchMinimap";
|
||||
import { ExportButton } from "./components/ExportButton";
|
||||
import { useSession } from "./hooks/useSession";
|
||||
import { useFilters } from "./hooks/useFilters";
|
||||
|
||||
|
||||
export function App() {
|
||||
const {
|
||||
sessions,
|
||||
@@ -23,23 +25,91 @@ export function App() {
|
||||
return filters.filterMessages(currentSession.messages);
|
||||
}, [currentSession, filters.filterMessages]);
|
||||
|
||||
// Derive match count from filtered messages - no setState during render
|
||||
const matchCount = useMemo(
|
||||
() => filters.getMatchCount(filteredMessages),
|
||||
[filters.getMatchCount, filteredMessages]
|
||||
);
|
||||
// Track which filtered-message indices match the search query
|
||||
const matchIndices = useMemo(() => {
|
||||
if (!filters.searchQuery) return [];
|
||||
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(
|
||||
() => filteredMessages.map((m) => m.uuid),
|
||||
[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 (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<div className="flex h-screen" style={{ background: "var(--color-canvas)" }}>
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h1 className="text-lg font-bold text-gray-900">Session Viewer</h1>
|
||||
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col">
|
||||
<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-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 className="flex-1 overflow-y-auto">
|
||||
<SessionList
|
||||
@@ -49,62 +119,85 @@ export function App() {
|
||||
onSelect={loadSession}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<FilterPanel
|
||||
enabledCategories={filters.enabledCategories}
|
||||
onToggle={filters.toggleCategory}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
||||
/>
|
||||
</div>
|
||||
<FilterPanel
|
||||
enabledCategories={filters.enabledCategories}
|
||||
onToggle={filters.toggleCategory}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-3 p-3 border-b border-gray-200 bg-white">
|
||||
<SearchBar
|
||||
query={filters.searchQuery}
|
||||
onQueryChange={filters.setSearchQuery}
|
||||
matchCount={matchCount}
|
||||
/>
|
||||
{filters.selectedForRedaction.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600 font-medium">
|
||||
{filters.selectedForRedaction.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={filters.confirmRedaction}
|
||||
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Redact Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={filters.clearRedactionSelection}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{currentSession && (
|
||||
<ExportButton
|
||||
session={currentSession}
|
||||
visibleMessageUuids={visibleUuids}
|
||||
redactedMessageUuids={[...filters.redactedUuids]}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
<div className="glass flex items-center px-5 py-2.5 border-b border-border z-10">
|
||||
{/* Left spacer — mirrors right side width to keep search centered */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{/* Center — search bar + contextual redaction controls */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<SearchBar
|
||||
query={filters.searchQuery}
|
||||
onQueryChange={filters.setSearchQuery}
|
||||
matchCount={matchCount}
|
||||
currentMatchPosition={currentMatchPosition}
|
||||
onNext={goToNextMatch}
|
||||
onPrev={goToPrevMatch}
|
||||
/>
|
||||
)}
|
||||
{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 className="flex-1 overflow-y-auto">
|
||||
<SessionViewer
|
||||
messages={filteredMessages}
|
||||
allMessages={currentSession?.messages || []}
|
||||
redactedUuids={filters.redactedUuids}
|
||||
loading={sessionLoading}
|
||||
searchQuery={filters.searchQuery}
|
||||
selectedForRedaction={filters.selectedForRedaction}
|
||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
{/* Scroll area wrapper — relative so minimap can position over the right edge */}
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto">
|
||||
<SessionViewer
|
||||
messages={filteredMessages}
|
||||
allMessages={currentSession?.messages || []}
|
||||
redactedUuids={filters.redactedUuids}
|
||||
loading={sessionLoading}
|
||||
searchQuery={filters.searchQuery}
|
||||
selectedForRedaction={filters.selectedForRedaction}
|
||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||
autoRedactEnabled={filters.autoRedactEnabled}
|
||||
focusedIndex={focusedMessageIndex}
|
||||
/>
|
||||
</div>
|
||||
<SearchMinimap
|
||||
matchIndices={matchIndices}
|
||||
totalMessages={filteredMessages.length}
|
||||
currentPosition={currentMatchPosition}
|
||||
onClickMatch={setCurrentMatchPosition}
|
||||
viewportTop={viewportTop}
|
||||
viewportHeight={viewportHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import type { SessionDetailResponse } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
@@ -14,10 +14,17 @@ export function ExportButton({
|
||||
redactedMessageUuids,
|
||||
autoRedactEnabled,
|
||||
}: 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() {
|
||||
setExporting(true);
|
||||
setState("exporting");
|
||||
try {
|
||||
const res = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
@@ -40,21 +47,67 @@ export function ExportButton({
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setState("success");
|
||||
timerRef.current = setTimeout(() => setState("idle"), 2000);
|
||||
} catch (err) {
|
||||
console.error("Export failed:", err);
|
||||
alert("Export failed. Check console for details.");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
setState("error");
|
||||
timerRef.current = setTimeout(() => setState("idle"), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="px-4 py-1.5 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 disabled:opacity-50 flex-shrink-0"
|
||||
disabled={state === "exporting"}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import type { MessageCategory } from "../lib/types";
|
||||
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
|
||||
import { CATEGORY_COLORS } from "../lib/constants";
|
||||
|
||||
interface Props {
|
||||
enabledCategories: Set<MessageCategory>;
|
||||
@@ -10,38 +11,79 @@ interface Props {
|
||||
}
|
||||
|
||||
export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const enabledCount = enabledCategories.size;
|
||||
const totalCount = ALL_CATEGORIES.length;
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Filters
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ALL_CATEGORIES.map((cat) => (
|
||||
<label
|
||||
key={cat}
|
||||
className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledCategories.has(cat)}
|
||||
onChange={() => onToggle(cat)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>{CATEGORY_LABELS[cat]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRedactEnabled}
|
||||
onChange={(e) => onAutoRedactToggle(e.target.checked)}
|
||||
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span>Auto-redact sensitive info</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-t border-border" style={{ background: "linear-gradient(0deg, var(--color-surface-raised) 0%, var(--color-surface) 100%)" }}>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-overlay/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-caption font-semibold text-foreground-muted uppercase tracking-wider">
|
||||
Filters
|
||||
</span>
|
||||
<span className="text-caption text-foreground-muted bg-surface-inset px-2 py-0.5 rounded-full tabular-nums border border-border-muted">
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-foreground-muted transition-transform duration-200 ${collapsed ? "" : "rotate-180"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-4 pb-3 animate-fade-in">
|
||||
<div className="space-y-0.5">
|
||||
{ALL_CATEGORIES.map((cat) => {
|
||||
const colors = CATEGORY_COLORS[cat];
|
||||
const isEnabled = enabledCategories.has(cat);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CATEGORY_LABELS } from "../lib/types";
|
||||
import { CATEGORY_COLORS } from "../lib/constants";
|
||||
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
|
||||
import { redactMessage } from "../../shared/sensitive-redactor";
|
||||
import { escapeHtml } from "../../shared/escape-html";
|
||||
|
||||
interface Props {
|
||||
message: ParsedMessage;
|
||||
@@ -16,9 +17,10 @@ interface Props {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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({
|
||||
message,
|
||||
@@ -36,41 +38,77 @@ export function MessageBubble({
|
||||
|
||||
if (msg.category === "tool_call") {
|
||||
const inputHtml = msg.toolInput
|
||||
? `<pre class="bg-gray-50 p-3 rounded text-xs overflow-x-auto mt-2"><code>${escapeHtml(msg.toolInput)}</code></pre>`
|
||||
? `<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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
||||
}, [message, searchQuery, autoRedactEnabled]);
|
||||
}, [message, searchQuery, autoRedactEnabled, colors.text]);
|
||||
|
||||
const timestamp = message.timestamp
|
||||
? formatTimestamp(message.timestamp)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onToggleRedactionSelection}
|
||||
className={`
|
||||
border-l-4 rounded-lg p-4 shadow-sm cursor-pointer transition-all
|
||||
${colors.border} ${colors.bg}
|
||||
group rounded-xl border bg-surface-raised cursor-pointer
|
||||
transition-all duration-200 relative overflow-hidden
|
||||
${colors.border}
|
||||
${dimmed ? "message-dimmed" : ""}
|
||||
${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">
|
||||
{label}
|
||||
{/* Category accent strip */}
|
||||
<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>
|
||||
{/* Content from local user-owned JSONL files, not external/untrusted input */}
|
||||
|
||||
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
||||
<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 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import React from "react";
|
||||
|
||||
export function RedactedDivider() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 text-gray-400 text-sm">
|
||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||
<span>··· content redacted ···</span>
|
||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||
<div className="flex items-center gap-3 py-3 text-foreground-muted">
|
||||
<div className="flex-1 border-t border-dashed border-red-900/40" />
|
||||
<div className="flex items-center gap-1.5 text-caption">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,22 @@ interface Props {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
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 [localQuery, setLocalQuery] = useState(query);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// 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);
|
||||
}, [localQuery, query, onQueryChange]);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// "/" to focus search
|
||||
if (
|
||||
e.key === "/" &&
|
||||
!e.ctrlKey &&
|
||||
@@ -35,40 +48,154 @@ export function SearchBar({ query, onQueryChange, matchCount }: Props) {
|
||||
) {
|
||||
e.preventDefault();
|
||||
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);
|
||||
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 (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<div className="w-80 sm:w-96">
|
||||
{/* 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
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={localQuery}
|
||||
onChange={(e) => setLocalQuery(e.target.value)}
|
||||
placeholder='Search messages... (press "/" to focus)'
|
||||
className="w-full pl-3 pr-8 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onKeyDown={handleInputKeyDown}
|
||||
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
|
||||
onClick={() => {
|
||||
setLocalQuery("");
|
||||
onQueryChange("");
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right-side controls — all inside the unified bar */}
|
||||
<div className="flex items-center gap-0.5 pr-2 flex-shrink-0">
|
||||
{/* Match count badge */}
|
||||
{query && (
|
||||
<div className={`
|
||||
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"
|
||||
: "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>
|
||||
{query && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{matchCount} match{matchCount !== 1 ? "es" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
113
src/client/components/SearchMinimap.test.tsx
Normal file
113
src/client/components/SearchMinimap.test.tsx
Normal 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%");
|
||||
});
|
||||
});
|
||||
50
src/client/components/SearchMinimap.tsx
Normal file
50
src/client/components/SearchMinimap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -94,11 +94,13 @@ describe("SessionList", () => {
|
||||
expect(screen.getByText(/all projects/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state", () => {
|
||||
render(
|
||||
it("shows loading state with skeleton placeholders", () => {
|
||||
const { container } = render(
|
||||
<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", () => {
|
||||
@@ -106,6 +108,7 @@ describe("SessionList", () => {
|
||||
<SessionList sessions={[]} loading={false} onSelect={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText("No sessions found")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sessions will appear here once created")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelect when clicking a session", () => {
|
||||
|
||||
@@ -31,56 +31,83 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-gray-500">Loading sessions...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-gray-500">No sessions found</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 2: Session list for selected project
|
||||
if (selectedProject !== null) {
|
||||
const projectSessions = grouped.get(selectedProject) || [];
|
||||
return (
|
||||
<div className="py-2">
|
||||
<button
|
||||
onClick={() => setSelectedProject(null)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-blue-600 hover:text-blue-800 hover:bg-gray-50 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>All Projects</span>
|
||||
</button>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50">
|
||||
{formatProjectName(selectedProject)}
|
||||
</div>
|
||||
{projectSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => onSelect(session.id)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors ${
|
||||
selectedId === session.id ? "bg-blue-50 border-l-2 border-l-blue-500" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{session.summary || session.firstPrompt || "Untitled Session"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
||||
<span>{formatDate(session.modified || session.created)}</span>
|
||||
<span>·</span>
|
||||
<span>{session.messageCount} msgs</span>
|
||||
</div>
|
||||
</button>
|
||||
<div 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" />
|
||||
<div className="skeleton h-3 w-1/2" />
|
||||
</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 (
|
||||
<div className="py-2">
|
||||
<div className="py-1 animate-fade-in">
|
||||
{[...grouped.entries()].map(([project, projectSessions]) => {
|
||||
const latest = projectSessions.reduce((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
|
||||
key={project}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
className="w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
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">
|
||||
{formatProjectName(project)}
|
||||
<div className="flex items-center justify-between">
|
||||
<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 className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
||||
<span>{count} {count === 1 ? "session" : "sessions"}</span>
|
||||
<span>·</span>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
|
||||
<span className="tabular-nums">{count} {count === 1 ? "session" : "sessions"}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>{formatDate(latest.modified || latest.created)}</span>
|
||||
</div>
|
||||
</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 {
|
||||
if (project.startsWith("-")) {
|
||||
return project.replace(/^-/, "/").replace(/-/g, "/");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import type { ParsedMessage } from "../lib/types";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { RedactedDivider } from "./RedactedDivider";
|
||||
@@ -15,6 +15,25 @@ interface Props {
|
||||
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({
|
||||
messages,
|
||||
allMessages,
|
||||
@@ -26,97 +45,142 @@ export function SessionViewer({
|
||||
autoRedactEnabled,
|
||||
focusedIndex = -1,
|
||||
}: Props) {
|
||||
const focusedRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedIndex >= 0 && focusedRef.current) {
|
||||
focusedRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
if (focusedIndex >= 0 && containerRef.current) {
|
||||
const el = containerRef.current.querySelector(
|
||||
`[data-msg-index="${focusedIndex}"]`
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-400 mx-auto mb-3"></div>
|
||||
Loading session...
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-4">
|
||||
<div className="skeleton h-4 w-24 mb-2" />
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<MessageSkeleton key={i} delay={i * 100} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0 && allMessages.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
Select a session to view
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No messages match current filters
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Build display list with redacted dividers
|
||||
const visibleUuids = new Set(messages.map((m) => m.uuid));
|
||||
const displayItems: Array<
|
||||
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
||||
| { type: "redacted_divider"; key: string }
|
||||
> = [];
|
||||
|
||||
let prevWasRedactedGap = false;
|
||||
let messageIndex = 0;
|
||||
for (const msg of allMessages) {
|
||||
if (redactedUuids.has(msg.uuid)) {
|
||||
prevWasRedactedGap = true;
|
||||
continue;
|
||||
}
|
||||
if (!visibleUuids.has(msg.uuid)) {
|
||||
continue;
|
||||
}
|
||||
if (prevWasRedactedGap) {
|
||||
displayItems.push({
|
||||
type: "redacted_divider",
|
||||
key: `divider-${msg.uuid}`,
|
||||
});
|
||||
prevWasRedactedGap = false;
|
||||
}
|
||||
displayItems.push({ type: "message", message: msg, messageIndex });
|
||||
messageIndex++;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4 space-y-3">
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
||||
<div className="max-w-6xl mx-auto px-6 py-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../lib/types";
|
||||
interface FilterState {
|
||||
enabledCategories: Set<MessageCategory>;
|
||||
toggleCategory: (cat: MessageCategory) => void;
|
||||
setAllCategories: (enabled: boolean) => void;
|
||||
filterMessages: (messages: ParsedMessage[]) => ParsedMessage[];
|
||||
searchQuery: string;
|
||||
setSearchQuery: (q: string) => void;
|
||||
@@ -14,7 +13,6 @@ interface FilterState {
|
||||
toggleRedactionSelection: (uuid: string) => void;
|
||||
confirmRedaction: () => void;
|
||||
clearRedactionSelection: () => void;
|
||||
getMatchCount: (messages: ParsedMessage[]) => number;
|
||||
autoRedactEnabled: boolean;
|
||||
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) => {
|
||||
setSelectedForRedaction((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(() => {
|
||||
setSelectedForRedaction((currentSelected) => {
|
||||
setRedactedUuids((prev) => {
|
||||
@@ -92,24 +79,6 @@ export function useFilters(): FilterState {
|
||||
setSelectedForRedaction(new Set());
|
||||
}, []);
|
||||
|
||||
const undoRedaction = useCallback((uuid: string) => {
|
||||
setRedactedUuids((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(uuid);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllRedactions = useCallback(() => {
|
||||
setRedactedUuids(new Set());
|
||||
}, []);
|
||||
|
||||
const selectAllVisible = useCallback((uuids: string[]) => {
|
||||
setSelectedForRedaction(new Set(uuids));
|
||||
}, []);
|
||||
|
||||
// Fix #1: filterMessages is now a pure function - no setState calls during render.
|
||||
// Match count is computed separately via getMatchCount.
|
||||
const filterMessages = useCallback(
|
||||
(messages: ParsedMessage[]): ParsedMessage[] => {
|
||||
return messages.filter(
|
||||
@@ -119,24 +88,10 @@ export function useFilters(): FilterState {
|
||||
[enabledCategories, redactedUuids]
|
||||
);
|
||||
|
||||
// Derive match count from filtered messages + search query without setState
|
||||
const getMatchCount = useCallback(
|
||||
(messages: ParsedMessage[]): number => {
|
||||
if (!searchQuery) return 0;
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
return messages.filter((m) =>
|
||||
m.content.toLowerCase().includes(lowerQuery)
|
||||
).length;
|
||||
},
|
||||
[searchQuery]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
enabledCategories,
|
||||
toggleCategory,
|
||||
setAllCategories,
|
||||
setPreset,
|
||||
filterMessages,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
@@ -145,18 +100,12 @@ export function useFilters(): FilterState {
|
||||
toggleRedactionSelection,
|
||||
confirmRedaction,
|
||||
clearRedactionSelection,
|
||||
undoRedaction,
|
||||
clearAllRedactions,
|
||||
selectAllVisible,
|
||||
getMatchCount,
|
||||
autoRedactEnabled,
|
||||
setAutoRedactEnabled,
|
||||
}),
|
||||
[
|
||||
enabledCategories,
|
||||
toggleCategory,
|
||||
setAllCategories,
|
||||
setPreset,
|
||||
filterMessages,
|
||||
searchQuery,
|
||||
redactedUuids,
|
||||
@@ -164,11 +113,8 @@ export function useFilters(): FilterState {
|
||||
toggleRedactionSelection,
|
||||
confirmRedaction,
|
||||
clearRedactionSelection,
|
||||
undoRedaction,
|
||||
clearAllRedactions,
|
||||
selectAllVisible,
|
||||
getMatchCount,
|
||||
autoRedactEnabled,
|
||||
// setSearchQuery and setAutoRedactEnabled are useState setters (stable identity)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
import type { MessageCategory } from "./types";
|
||||
|
||||
export const CATEGORY_COLORS: Record<MessageCategory, { border: string; bg: string }> = {
|
||||
user_message: { border: "border-l-blue-500", bg: "bg-blue-50" },
|
||||
assistant_text: { border: "border-l-emerald-500", bg: "bg-white" },
|
||||
thinking: { border: "border-l-violet-500", bg: "bg-violet-50" },
|
||||
tool_call: { border: "border-l-amber-500", bg: "bg-amber-50" },
|
||||
tool_result: { border: "border-l-indigo-500", bg: "bg-indigo-50" },
|
||||
system_message: { border: "border-l-gray-500", bg: "bg-gray-100" },
|
||||
hook_progress: { border: "border-l-gray-400", bg: "bg-gray-50" },
|
||||
file_snapshot: { border: "border-l-pink-500", bg: "bg-pink-50" },
|
||||
summary: { border: "border-l-teal-500", bg: "bg-teal-50" },
|
||||
export const CATEGORY_COLORS: Record<
|
||||
MessageCategory,
|
||||
{ dot: string; border: string; text: string }
|
||||
> = {
|
||||
user_message: {
|
||||
dot: "bg-category-user",
|
||||
border: "border-category-user-border",
|
||||
text: "text-category-user",
|
||||
},
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
68
src/client/lib/markdown.test.ts
Normal file
68
src/client/lib/markdown.test.ts
Normal 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 & B</p>";
|
||||
const result = highlightSearchText(html, "amp");
|
||||
// Must NOT produce &<mark>amp</mark>; — entity must remain intact
|
||||
expect(result).toBe("<p>A & B</p>");
|
||||
});
|
||||
|
||||
it("does not corrupt < entity", () => {
|
||||
const html = "<p>a < b</p>";
|
||||
const result = highlightSearchText(html, "lt");
|
||||
expect(result).toBe("<p>a < b</p>");
|
||||
});
|
||||
|
||||
it("does not corrupt > entity", () => {
|
||||
const html = "<p>a > b</p>";
|
||||
const result = highlightSearchText(html, "gt");
|
||||
expect(result).toBe("<p>a > b</p>");
|
||||
});
|
||||
|
||||
it("does not corrupt numeric entities", () => {
|
||||
const html = "<p>'quoted'</p>";
|
||||
const result = highlightSearchText(html, "039");
|
||||
expect(result).toBe("<p>'quoted'</p>");
|
||||
});
|
||||
|
||||
it("highlights text adjacent to entities", () => {
|
||||
const html = "<p>foo & bar</p>";
|
||||
const result = highlightSearchText(html, "foo");
|
||||
expect(result).toBe(
|
||||
'<p><mark class="search-highlight">foo</mark> & 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>');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { marked } from "marked";
|
||||
import hljs from "highlight.js";
|
||||
import { markedHighlight } from "marked-highlight";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { escapeHtml } from "../../shared/escape-html";
|
||||
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
@@ -8,7 +10,10 @@ marked.use(
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export function highlightSearchText(html: string, query: string): string {
|
||||
if (!query) return html;
|
||||
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++) {
|
||||
// Even indices are text content, odd indices are tags
|
||||
if (i % 2 === 0 && parts[i]) {
|
||||
parts[i] = parts[i].replace(
|
||||
regex,
|
||||
'<mark class="search-highlight">$1</mark>'
|
||||
);
|
||||
// Further split on HTML entities (& < etc.) to avoid
|
||||
// matching inside them — e.g. searching "amp" must not corrupt &
|
||||
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("");
|
||||
|
||||
@@ -2,41 +2,495 @@
|
||||
@tailwind components;
|
||||
@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 {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
background: var(--color-border);
|
||||
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 {
|
||||
background: #f6f8fa;
|
||||
background: var(--color-surface-inset);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border-muted);
|
||||
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 {
|
||||
background: #fde68a;
|
||||
background: rgba(250, 204, 21, 0.2);
|
||||
color: inherit;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
padding: 1px 3px;
|
||||
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 {
|
||||
outline: 2px solid #ef4444;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,18 +28,12 @@ export function createApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
const TAILSCALE_IP = "100.84.4.113";
|
||||
|
||||
export function startServer() {
|
||||
const PORT = parseInt(process.env.PORT || "3848", 10);
|
||||
const app = createApp();
|
||||
|
||||
// Bind to both localhost and Tailscale — not the public interface
|
||||
const localServer = app.listen(PORT, "127.0.0.1", () => {
|
||||
const server = app.listen(PORT, "127.0.0.1", async () => {
|
||||
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") {
|
||||
const { default: open } = await import("open");
|
||||
open(`http://localhost:${PORT}`);
|
||||
@@ -48,13 +42,12 @@ export function startServer() {
|
||||
|
||||
// Graceful shutdown so tsx watch can restart cleanly
|
||||
function shutdown() {
|
||||
localServer.close();
|
||||
tsServer.close();
|
||||
server.close();
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
return tsServer;
|
||||
return server;
|
||||
}
|
||||
|
||||
// Only auto-start when run directly (not imported by tests)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { markedHighlight } from "marked-highlight";
|
||||
import type { ExportRequest, ParsedMessage } from "../../shared/types.js";
|
||||
import { CATEGORY_LABELS } from "../../shared/types.js";
|
||||
import { redactMessage } from "../../shared/sensitive-redactor.js";
|
||||
import { escapeHtml } from "../../shared/escape-html.js";
|
||||
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
@@ -11,11 +12,31 @@ marked.use(
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
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(
|
||||
req: ExportRequest
|
||||
): Promise<string> {
|
||||
@@ -40,9 +61,7 @@ export async function generateExportHtml(
|
||||
continue;
|
||||
}
|
||||
if (lastWasRedacted) {
|
||||
messageHtmlParts.push(
|
||||
'<div class="redacted-divider">··· content redacted ···</div>'
|
||||
);
|
||||
messageHtmlParts.push(renderRedactedDivider());
|
||||
lastWasRedacted = false;
|
||||
}
|
||||
const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg;
|
||||
@@ -71,9 +90,18 @@ ${hljsCss}
|
||||
<header class="session-header">
|
||||
<h1>Session Export</h1>
|
||||
<div class="meta">
|
||||
<span class="project">Project: ${escapeHtml(session.project)}</span>
|
||||
<span class="date">Date: ${escapeHtml(dateStr)}</span>
|
||||
<span class="count">${messageCount} messages</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="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>
|
||||
${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>
|
||||
</header>
|
||||
<main class="messages">
|
||||
@@ -84,26 +112,55 @@ ${hljsCss}
|
||||
</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 {
|
||||
const categoryClass = msg.category.replace(/_/g, "-");
|
||||
const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message;
|
||||
const label = CATEGORY_LABELS[msg.category];
|
||||
let bodyHtml: string;
|
||||
|
||||
if (msg.category === "tool_call") {
|
||||
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 {
|
||||
bodyHtml = renderMarkdown(msg.content);
|
||||
}
|
||||
|
||||
return `<div class="message ${categoryClass}">
|
||||
<div class="message-label">${escapeHtml(label)}</div>
|
||||
<div class="message-body">${bodyHtml}</div>
|
||||
const timestamp = msg.timestamp ? formatTimestamp(msg.timestamp) : "";
|
||||
const timestampHtml = timestamp
|
||||
? `<span class="header-sep">·</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>`;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
try {
|
||||
return marked.parse(text) as string;
|
||||
@@ -112,18 +169,23 @@ function renderMarkdown(text: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getHighlightCss(): string {
|
||||
// Dark theme highlight.js (GitHub Dark) matching the client
|
||||
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-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}
|
||||
@@ -144,59 +206,208 @@ function getHighlightCss(): string {
|
||||
|
||||
function getExportCss(): string {
|
||||
return `
|
||||
/* Reset & base */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117; color: #e6edf3; line-height: 1.6;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
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 {
|
||||
background: #161b22; color: #e6edf3; padding: 1.5rem 2rem;
|
||||
border-radius: 12px; margin-bottom: 2rem; border: 1px solid #30363d;
|
||||
background: #1c2128;
|
||||
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 .meta { display: flex; gap: 1.5rem; font-size: 0.875rem; color: #8b949e; flex-wrap: wrap; }
|
||||
.messages { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.session-header h1 {
|
||||
font-size: 1.125rem;
|
||||
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 {
|
||||
padding: 1rem 1.25rem; border-radius: 10px;
|
||||
border-left: 4px solid #30363d; background: #161b22;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
background: #1c2128;
|
||||
border-radius: 0.75rem;
|
||||
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 {
|
||||
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; margin-bottom: 0.5rem; color: #8b949e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #484f58;
|
||||
line-height: 1rem;
|
||||
}
|
||||
.message-body { overflow-wrap: break-word; }
|
||||
.message-body pre {
|
||||
background: #0d1117; padding: 1rem; border-radius: 6px;
|
||||
overflow-x: auto; font-size: 0.875rem; margin: 0.5rem 0;
|
||||
border: 1px solid #30363d;
|
||||
.header-sep {
|
||||
color: #30363d;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.message-body code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.875em; }
|
||||
.message-body p { margin: 0.5em 0; }
|
||||
.message-body ul, .message-body ol { padding-left: 1.5em; margin: 0.5em 0; }
|
||||
.message-body h1,.message-body h2,.message-body h3 { margin: 0.75em 0 0.25em; color: #f0f6fc; }
|
||||
.message-body a { color: #58a6ff; }
|
||||
.message-body table { border-collapse: collapse; margin: 0.5em 0; width: 100%; }
|
||||
.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 blockquote { border-left: 3px solid #30363d; padding-left: 1em; color: #8b949e; margin: 0.5em 0; }
|
||||
.message-body hr { border: none; border-top: 1px solid #30363d; margin: 1em 0; }
|
||||
.user-message { border-left-color: #58a6ff; background: #121d2f; }
|
||||
.assistant-text { border-left-color: #3fb950; background: #161b22; }
|
||||
.thinking { border-left-color: #bc8cff; background: #1c1631; }
|
||||
.tool-call { border-left-color: #d29922; background: #1c1a10; }
|
||||
.tool-result { border-left-color: #8b8cf8; background: #181830; }
|
||||
.system-message { border-left-color: #8b949e; background: #1c2128; font-size: 0.875rem; }
|
||||
.hook-progress { border-left-color: #484f58; background: #131820; font-size: 0.875rem; }
|
||||
.file-snapshot { border-left-color: #f778ba; background: #241525; }
|
||||
.summary { border-left-color: #2dd4bf; background: #122125; }
|
||||
.tool-name { font-weight: 600; color: #d29922; margin-bottom: 0.5rem; }
|
||||
.tool-input { font-size: 0.8rem; }
|
||||
.message-time {
|
||||
font-size: 0.75rem;
|
||||
color: #484f58;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.message-body {
|
||||
padding: 0.25rem 1rem 1rem;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
/* Tool name */
|
||||
.tool-name { font-weight: 500; margin-bottom: 0.5rem; }
|
||||
|
||||
/* redacted-divider */
|
||||
.redacted-divider {
|
||||
text-align: center; color: #484f58; font-size: 0.875rem;
|
||||
padding: 0.75rem 0; border-top: 1px dashed #30363d; border-bottom: 1px dashed #30363d;
|
||||
margin: 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -28,46 +28,70 @@ export async function discoverSessions(
|
||||
return sessions;
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
const projectPath = path.join(projectsDir, projectDir);
|
||||
// Parallel I/O: stat + readFile for all project dirs concurrently
|
||||
const results = await Promise.all(
|
||||
projectDirs.map(async (projectDir) => {
|
||||
const projectPath = path.join(projectsDir, projectDir);
|
||||
const entries: SessionEntry[] = [];
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(projectPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
});
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(projectPath);
|
||||
} catch {
|
||||
return entries;
|
||||
}
|
||||
} catch {
|
||||
// Missing or corrupt index - skip
|
||||
}
|
||||
if (!stat.isDirectory()) return entries;
|
||||
|
||||
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) => {
|
||||
|
||||
18
src/shared/escape-html.ts
Normal file
18
src/shared/escape-html.ts
Normal 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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
export function escapeHtml(text: string): string {
|
||||
return text.replace(/[&<>"']/g, (ch) => ESC_MAP[ch]);
|
||||
}
|
||||
@@ -316,7 +316,7 @@ export const SENSITIVE_PATTERNS: SensitivePattern[] = [
|
||||
keywords: ["postgres", "mysql", "mongodb", "redis", "amqp", "mssql"],
|
||||
},
|
||||
|
||||
// #30 URLs with credentials
|
||||
// #30 URLs with credentials (user:pass@host pattern)
|
||||
{
|
||||
id: "url_with_creds",
|
||||
label: "[URL_WITH_CREDS]",
|
||||
@@ -328,7 +328,7 @@ export const SENSITIVE_PATTERNS: SensitivePattern[] = [
|
||||
{
|
||||
id: "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: ["@"],
|
||||
falsePositiveCheck: isAllowlistedEmail,
|
||||
},
|
||||
@@ -339,7 +339,7 @@ export const SENSITIVE_PATTERNS: SensitivePattern[] = [
|
||||
label: "[IP_ADDR]",
|
||||
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,
|
||||
keywords: ["."],
|
||||
keywords: ["0.", "1.", "2.", "3.", "4.", "5.", "6.", "7.", "8.", "9."],
|
||||
falsePositiveCheck: isAllowlistedIp,
|
||||
},
|
||||
|
||||
@@ -366,10 +366,22 @@ export const SENSITIVE_PATTERNS: SensitivePattern[] = [
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if any keyword from the pattern appears in the content (case-sensitive
|
||||
* for most patterns, lowered for a cheap pre-check).
|
||||
* Check if any keyword from the pattern appears in the content.
|
||||
* 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) {
|
||||
if (content.includes(kw)) return true;
|
||||
}
|
||||
@@ -390,8 +402,10 @@ export function redactSensitiveContent(input: string): RedactionResult {
|
||||
const matchedCategories = new Set<string>();
|
||||
|
||||
for (const pattern of SENSITIVE_PATTERNS) {
|
||||
// Keyword pre-filter: skip expensive regex if no keyword found
|
||||
if (!hasKeyword(result, pattern.keywords)) {
|
||||
// Keyword pre-filter: skip expensive regex if no keyword found.
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,131 @@
|
||||
export default {
|
||||
content: ["./src/client/**/*.{html,tsx,ts}"],
|
||||
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: [],
|
||||
};
|
||||
|
||||
@@ -114,6 +114,8 @@ describe("html-exporter", () => {
|
||||
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||
expect(html).toContain("test-project");
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,6 +251,18 @@ describe("sensitive-redactor", () => {
|
||||
const result = redactSensitiveContent(input);
|
||||
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 ---
|
||||
@@ -387,7 +399,9 @@ describe("sensitive-redactor", () => {
|
||||
it("redacts SECRET_KEY assignments", () => {
|
||||
const input = "SECRET_KEY=abcdefghij1234567890";
|
||||
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", () => {
|
||||
|
||||
@@ -15,12 +15,13 @@ describe("session-discovery", () => {
|
||||
const projectDir = path.join(tmpDir, "test-project");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const sessionPath = path.join(projectDir, "sess-001.jsonl");
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "sessions-index.json"),
|
||||
makeIndex([
|
||||
{
|
||||
sessionId: "sess-001",
|
||||
fullPath: "/tmp/sess-001.jsonl",
|
||||
fullPath: sessionPath,
|
||||
summary: "Test session",
|
||||
firstPrompt: "Hello",
|
||||
created: "2025-10-15T10:00:00Z",
|
||||
@@ -36,7 +37,7 @@ describe("session-discovery", () => {
|
||||
expect(sessions[0].summary).toBe("Test session");
|
||||
expect(sessions[0].project).toBe("test-project");
|
||||
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 });
|
||||
});
|
||||
@@ -111,17 +112,30 @@ describe("session-discovery", () => {
|
||||
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");
|
||||
it("rejects paths with traversal segments", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `sv-test-traversal-${Date.now()}`);
|
||||
const projectDir = path.join(tmpDir, "traversal-project");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const goodPath = path.join(projectDir, "good-001.jsonl");
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "sessions-index.json"),
|
||||
makeIndex([
|
||||
{
|
||||
sessionId: "fp-001",
|
||||
fullPath: "/home/ubuntu/.claude/projects/xyz/fp-001.jsonl",
|
||||
sessionId: "evil-001",
|
||||
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",
|
||||
modified: "2025-10-15T11:00:00Z",
|
||||
},
|
||||
@@ -129,10 +143,62 @@ describe("session-discovery", () => {
|
||||
);
|
||||
|
||||
const sessions = await discoverSessions(tmpDir);
|
||||
expect(sessions[0].path).toBe(
|
||||
"/home/ubuntu/.claude/projects/xyz/fp-001.jsonl"
|
||||
expect(sessions).toHaveLength(1);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,10 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 3847,
|
||||
// Vite only supports one host. Use Tailscale IP so it's reachable
|
||||
// from both the local machine (via the TS IP) and the tailnet.
|
||||
// localhost:3847 won't work for the Vite dev server — use the TS IP.
|
||||
host: "100.84.4.113",
|
||||
host: "127.0.0.1",
|
||||
hmr: {
|
||||
overlay: false,
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3848",
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
include: [
|
||||
"tests/unit/**/*.test.ts",
|
||||
"src/client/components/**/*.test.tsx",
|
||||
"src/client/**/*.test.{ts,tsx}",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user