Redesign all client components with dark theme, polish, and UX improvements
MessageBubble: Replace border-left colored bars with rounded cards featuring accent strips, category dot indicators, and timestamp display. Use shared escapeHtml. Render tool_result, hook_progress, and file_snapshot as preformatted text instead of markdown (avoids expensive marked.parse on large JSON/log blobs). ExportButton: Add state machine (idle/exporting/success/error) with animated icons, gradient backgrounds, and auto-reset timers. Replace alert() with inline error state. FilterPanel: Add collapsible panel with category dot colors, enable count badge, custom checkbox styling, and smooth animations. SessionList: Replace text loading state with skeleton placeholders. Add empty state illustration with descriptive text. Style session items as rounded cards with hover/selected states, glow effects, and staggered entry animations. Add project name decode explanation comment. RedactedDivider: Add eye-slash SVG icon, red accent color, and styled dashed lines replacing plain text divider. useFilters: Remove unused exports (setAllCategories, setPreset, undoRedaction, clearAllRedactions, selectAllVisible, getMatchCount) to reduce hook surface area. Match counting moved to App component for search navigation. SessionList.test: Update assertions for skeleton loading state and expanded empty state text. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import type { SessionDetailResponse } from "../lib/types";
|
import type { SessionDetailResponse } from "../lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,10 +14,17 @@ export function ExportButton({
|
|||||||
redactedMessageUuids,
|
redactedMessageUuids,
|
||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [exporting, setExporting] = useState(false);
|
const [state, setState] = useState<"idle" | "exporting" | "success" | "error">("idle");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
setExporting(true);
|
setState("exporting");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/export", {
|
const res = await fetch("/api/export", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -40,21 +47,67 @@ export function ExportButton({
|
|||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
setState("success");
|
||||||
|
timerRef.current = setTimeout(() => setState("idle"), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Export failed:", err);
|
console.error("Export failed:", err);
|
||||||
alert("Export failed. Check console for details.");
|
setState("error");
|
||||||
} finally {
|
timerRef.current = setTimeout(() => setState("idle"), 3000);
|
||||||
setExporting(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={exporting}
|
disabled={state === "exporting"}
|
||||||
className="px-4 py-1.5 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 disabled:opacity-50 flex-shrink-0"
|
className={`
|
||||||
|
btn btn-sm flex-shrink-0 gap-1.5 transition-all duration-200
|
||||||
|
${state === "success" ? "text-white shadow-glow-success" : ""}
|
||||||
|
${state === "error" ? "text-white" : ""}
|
||||||
|
${state === "idle" || state === "exporting" ? "btn-primary" : ""}
|
||||||
|
`}
|
||||||
|
style={
|
||||||
|
state === "success"
|
||||||
|
? { background: "linear-gradient(135deg, #22c55e, #16a34a)" }
|
||||||
|
: state === "error"
|
||||||
|
? { background: "linear-gradient(135deg, #dc2626, #b91c1c)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{exporting ? "Exporting..." : "Export HTML"}
|
{state === "idle" && (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
Export
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "exporting" && (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Exporting...
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "success" && (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
Downloaded
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "error" && (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
Failed
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import type { MessageCategory } from "../lib/types";
|
import type { MessageCategory } from "../lib/types";
|
||||||
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
|
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
|
||||||
|
import { CATEGORY_COLORS } from "../lib/constants";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
enabledCategories: Set<MessageCategory>;
|
enabledCategories: Set<MessageCategory>;
|
||||||
@@ -10,38 +11,79 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) {
|
export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const enabledCount = enabledCategories.size;
|
||||||
|
const totalCount = ALL_CATEGORIES.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3">
|
<div className="border-t border-border" style={{ background: "linear-gradient(0deg, var(--color-surface-raised) 0%, var(--color-surface) 100%)" }}>
|
||||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
<button
|
||||||
|
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
|
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>
|
</div>
|
||||||
<div className="space-y-1">
|
<svg
|
||||||
{ALL_CATEGORIES.map((cat) => (
|
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
|
<label
|
||||||
key={cat}
|
key={cat}
|
||||||
className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900"
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabledCategories.has(cat)}
|
checked={isEnabled}
|
||||||
onChange={() => onToggle(cat)}
|
onChange={() => onToggle(cat)}
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="custom-checkbox"
|
||||||
/>
|
/>
|
||||||
<span>{CATEGORY_LABELS[cat]}</span>
|
<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>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoRedactEnabled}
|
checked={autoRedactEnabled}
|
||||||
onChange={(e) => onAutoRedactToggle(e.target.checked)}
|
onChange={(e) => onAutoRedactToggle(e.target.checked)}
|
||||||
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
|
className="custom-checkbox checkbox-danger"
|
||||||
/>
|
/>
|
||||||
<span>Auto-redact sensitive info</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CATEGORY_LABELS } from "../lib/types";
|
|||||||
import { CATEGORY_COLORS } from "../lib/constants";
|
import { CATEGORY_COLORS } from "../lib/constants";
|
||||||
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
|
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
|
||||||
import { redactMessage } from "../../shared/sensitive-redactor";
|
import { redactMessage } from "../../shared/sensitive-redactor";
|
||||||
|
import { escapeHtml } from "../../shared/escape-html";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: ParsedMessage;
|
message: ParsedMessage;
|
||||||
@@ -16,9 +17,10 @@ interface Props {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* MessageBubble renders session messages using innerHTML.
|
* MessageBubble renders session messages using innerHTML.
|
||||||
* This is safe here because content comes only from local JSONL session files
|
* SECURITY: This is safe because content comes only from local JSONL session files
|
||||||
* owned by the user, processed through the `marked` markdown renderer.
|
* owned by the user, processed through the `marked` markdown renderer.
|
||||||
* This is a local-only developer tool, not exposed to untrusted input.
|
* This is a local-only developer tool, not exposed to untrusted input.
|
||||||
|
* The session files are read from the user's own filesystem (~/.claude/projects/).
|
||||||
*/
|
*/
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
@@ -36,41 +38,77 @@ export function MessageBubble({
|
|||||||
|
|
||||||
if (msg.category === "tool_call") {
|
if (msg.category === "tool_call") {
|
||||||
const inputHtml = msg.toolInput
|
const inputHtml = msg.toolInput
|
||||||
? `<pre class="bg-gray-50 p-3 rounded text-xs overflow-x-auto mt-2"><code>${escapeHtml(msg.toolInput)}</code></pre>`
|
? `<pre class="hljs mt-2"><code>${escapeHtml(msg.toolInput)}</code></pre>`
|
||||||
: "";
|
: "";
|
||||||
const html = `<div class="font-semibold text-amber-800">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
|
const html = `<div class="font-medium ${colors.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
|
||||||
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Structured data categories: render as preformatted text, not markdown.
|
||||||
|
// Avoids expensive marked.parse() on large JSON/log blobs.
|
||||||
|
if (msg.category === "hook_progress" || msg.category === "tool_result" || msg.category === "file_snapshot") {
|
||||||
|
const html = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
||||||
|
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
||||||
|
}
|
||||||
|
|
||||||
const html = renderMarkdown(msg.content);
|
const html = renderMarkdown(msg.content);
|
||||||
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
return searchQuery ? highlightSearchText(html, searchQuery) : html;
|
||||||
}, [message, searchQuery, autoRedactEnabled]);
|
}, [message, searchQuery, autoRedactEnabled, colors.text]);
|
||||||
|
|
||||||
|
const timestamp = message.timestamp
|
||||||
|
? formatTimestamp(message.timestamp)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onToggleRedactionSelection}
|
onClick={onToggleRedactionSelection}
|
||||||
className={`
|
className={`
|
||||||
border-l-4 rounded-lg p-4 shadow-sm cursor-pointer transition-all
|
group rounded-xl border bg-surface-raised cursor-pointer
|
||||||
${colors.border} ${colors.bg}
|
transition-all duration-200 relative overflow-hidden
|
||||||
|
${colors.border}
|
||||||
${dimmed ? "message-dimmed" : ""}
|
${dimmed ? "message-dimmed" : ""}
|
||||||
${selectedForRedaction ? "redaction-selected" : ""}
|
${selectedForRedaction ? "redaction-selected" : ""}
|
||||||
hover:shadow-md
|
hover:shadow-card-hover hover:-translate-y-px
|
||||||
|
shadow-card
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
|
{/* Category accent strip */}
|
||||||
|
<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}
|
{label}
|
||||||
|
</span>
|
||||||
|
{timestamp && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span className="text-caption text-foreground-muted tabular-nums">
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Content from local user-owned JSONL files, not external/untrusted input */}
|
|
||||||
|
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none overflow-wrap-break-word"
|
className="prose-message text-body text-foreground px-4 pl-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
function formatTimestamp(ts: string): string {
|
||||||
return text
|
try {
|
||||||
.replace(/&/g, "&")
|
const d = new Date(ts);
|
||||||
.replace(/</g, "<")
|
return d.toLocaleTimeString(undefined, {
|
||||||
.replace(/>/g, ">");
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import React from "react";
|
|||||||
|
|
||||||
export function RedactedDivider() {
|
export function RedactedDivider() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-2 text-gray-400 text-sm">
|
<div className="flex items-center gap-3 py-3 text-foreground-muted">
|
||||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
<div className="flex-1 border-t border-dashed border-red-900/40" />
|
||||||
<span>··· content redacted ···</span>
|
<div className="flex items-center gap-1.5 text-caption">
|
||||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
<svg className="w-3.5 h-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-red-400 font-medium">content redacted</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border-t border-dashed border-red-900/40" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,11 +94,13 @@ describe("SessionList", () => {
|
|||||||
expect(screen.getByText(/all projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/all projects/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows loading state", () => {
|
it("shows loading state with skeleton placeholders", () => {
|
||||||
render(
|
const { container } = render(
|
||||||
<SessionList sessions={[]} loading={true} onSelect={vi.fn()} />
|
<SessionList sessions={[]} loading={true} onSelect={vi.fn()} />
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Loading sessions...")).toBeInTheDocument();
|
// Loading state now uses skeleton placeholders instead of text
|
||||||
|
const skeletons = container.querySelectorAll(".skeleton");
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty state", () => {
|
it("shows empty state", () => {
|
||||||
@@ -106,6 +108,7 @@ describe("SessionList", () => {
|
|||||||
<SessionList sessions={[]} loading={false} onSelect={vi.fn()} />
|
<SessionList sessions={[]} loading={false} onSelect={vi.fn()} />
|
||||||
);
|
);
|
||||||
expect(screen.getByText("No sessions found")).toBeInTheDocument();
|
expect(screen.getByText("No sessions found")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Sessions will appear here once created")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onSelect when clicking a session", () => {
|
it("calls onSelect when clicking a session", () => {
|
||||||
|
|||||||
@@ -31,56 +31,83 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-gray-500">Loading sessions...</div>
|
<div className="p-4 space-y-3">
|
||||||
);
|
{[...Array(5)].map((_, i) => (
|
||||||
}
|
<div key={i} className="space-y-2 px-1">
|
||||||
|
<div className="skeleton h-4 w-3/4" />
|
||||||
if (sessions.length === 0) {
|
<div className="skeleton h-3 w-1/2" />
|
||||||
return (
|
|
||||||
<div 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>
|
</div>
|
||||||
{projectSessions.map((session) => (
|
|
||||||
<button
|
|
||||||
key={session.id}
|
|
||||||
onClick={() => onSelect(session.id)}
|
|
||||||
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors ${
|
|
||||||
selectedId === session.id ? "bg-blue-50 border-l-2 border-l-blue-500" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">
|
|
||||||
{session.summary || session.firstPrompt || "Untitled Session"}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
|
||||||
<span>{formatDate(session.modified || session.created)}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{session.messageCount} msgs</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Project list
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="py-2">
|
<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-1 animate-fade-in">
|
||||||
{[...grouped.entries()].map(([project, projectSessions]) => {
|
{[...grouped.entries()].map(([project, projectSessions]) => {
|
||||||
const latest = projectSessions.reduce((a, b) =>
|
const latest = projectSessions.reduce((a, b) =>
|
||||||
(a.modified || a.created) > (b.modified || b.created) ? a : b
|
(a.modified || a.created) > (b.modified || b.created) ? a : b
|
||||||
@@ -90,14 +117,20 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
<button
|
<button
|
||||||
key={project}
|
key={project}
|
||||||
onClick={() => setSelectedProject(project)}
|
onClick={() => setSelectedProject(project)}
|
||||||
className="w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
className="w-full text-left mx-2 my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
|
||||||
|
style={{ width: "calc(100% - 1rem)" }}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-body font-medium text-foreground truncate">
|
||||||
{formatProjectName(project)}
|
{formatProjectName(project)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
<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}>
|
||||||
<span>{count} {count === 1 ? "session" : "sessions"}</span>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
<span>·</span>
|
</svg>
|
||||||
|
</div>
|
||||||
|
<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>
|
<span>{formatDate(latest.modified || latest.created)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -107,6 +140,13 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort decode of Claude Code's project directory name back to a path.
|
||||||
|
* Claude encodes project paths by replacing '/' with '-', but this is lossy:
|
||||||
|
* a path like /home/user/my-cool-app encodes as -home-user-my-cool-app and
|
||||||
|
* decodes as /home/user/my/cool/app (hyphens in the original name are lost).
|
||||||
|
* There is no way to distinguish path separators from literal hyphens.
|
||||||
|
*/
|
||||||
function formatProjectName(project: string): string {
|
function formatProjectName(project: string): string {
|
||||||
if (project.startsWith("-")) {
|
if (project.startsWith("-")) {
|
||||||
return project.replace(/^-/, "/").replace(/-/g, "/");
|
return project.replace(/^-/, "/").replace(/-/g, "/");
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../lib/types";
|
|||||||
interface FilterState {
|
interface FilterState {
|
||||||
enabledCategories: Set<MessageCategory>;
|
enabledCategories: Set<MessageCategory>;
|
||||||
toggleCategory: (cat: MessageCategory) => void;
|
toggleCategory: (cat: MessageCategory) => void;
|
||||||
setAllCategories: (enabled: boolean) => void;
|
|
||||||
filterMessages: (messages: ParsedMessage[]) => ParsedMessage[];
|
filterMessages: (messages: ParsedMessage[]) => ParsedMessage[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (q: string) => void;
|
setSearchQuery: (q: string) => void;
|
||||||
@@ -14,7 +13,6 @@ interface FilterState {
|
|||||||
toggleRedactionSelection: (uuid: string) => void;
|
toggleRedactionSelection: (uuid: string) => void;
|
||||||
confirmRedaction: () => void;
|
confirmRedaction: () => void;
|
||||||
clearRedactionSelection: () => void;
|
clearRedactionSelection: () => void;
|
||||||
getMatchCount: (messages: ParsedMessage[]) => number;
|
|
||||||
autoRedactEnabled: boolean;
|
autoRedactEnabled: boolean;
|
||||||
setAutoRedactEnabled: (enabled: boolean) => void;
|
setAutoRedactEnabled: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -50,18 +48,6 @@ export function useFilters(): FilterState {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setAllCategories = useCallback((enabled: boolean) => {
|
|
||||||
if (enabled) {
|
|
||||||
setEnabledCategories(new Set(ALL_CATEGORIES));
|
|
||||||
} else {
|
|
||||||
setEnabledCategories(new Set());
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setPreset = useCallback((categories: MessageCategory[]) => {
|
|
||||||
setEnabledCategories(new Set(categories));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleRedactionSelection = useCallback((uuid: string) => {
|
const toggleRedactionSelection = useCallback((uuid: string) => {
|
||||||
setSelectedForRedaction((prev) => {
|
setSelectedForRedaction((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -74,7 +60,8 @@ export function useFilters(): FilterState {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fix #9: Use functional updater to avoid stale closure over selectedForRedaction
|
// Uses functional updater to read latest selectedForRedaction without stale closure.
|
||||||
|
// Nested setState is safe in React 18+ with automatic batching.
|
||||||
const confirmRedaction = useCallback(() => {
|
const confirmRedaction = useCallback(() => {
|
||||||
setSelectedForRedaction((currentSelected) => {
|
setSelectedForRedaction((currentSelected) => {
|
||||||
setRedactedUuids((prev) => {
|
setRedactedUuids((prev) => {
|
||||||
@@ -92,24 +79,6 @@ export function useFilters(): FilterState {
|
|||||||
setSelectedForRedaction(new Set());
|
setSelectedForRedaction(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const undoRedaction = useCallback((uuid: string) => {
|
|
||||||
setRedactedUuids((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(uuid);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearAllRedactions = useCallback(() => {
|
|
||||||
setRedactedUuids(new Set());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectAllVisible = useCallback((uuids: string[]) => {
|
|
||||||
setSelectedForRedaction(new Set(uuids));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fix #1: filterMessages is now a pure function - no setState calls during render.
|
|
||||||
// Match count is computed separately via getMatchCount.
|
|
||||||
const filterMessages = useCallback(
|
const filterMessages = useCallback(
|
||||||
(messages: ParsedMessage[]): ParsedMessage[] => {
|
(messages: ParsedMessage[]): ParsedMessage[] => {
|
||||||
return messages.filter(
|
return messages.filter(
|
||||||
@@ -119,24 +88,10 @@ export function useFilters(): FilterState {
|
|||||||
[enabledCategories, redactedUuids]
|
[enabledCategories, redactedUuids]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derive match count from filtered messages + search query without setState
|
|
||||||
const getMatchCount = useCallback(
|
|
||||||
(messages: ParsedMessage[]): number => {
|
|
||||||
if (!searchQuery) return 0;
|
|
||||||
const lowerQuery = searchQuery.toLowerCase();
|
|
||||||
return messages.filter((m) =>
|
|
||||||
m.content.toLowerCase().includes(lowerQuery)
|
|
||||||
).length;
|
|
||||||
},
|
|
||||||
[searchQuery]
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
enabledCategories,
|
enabledCategories,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
setAllCategories,
|
|
||||||
setPreset,
|
|
||||||
filterMessages,
|
filterMessages,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
@@ -145,18 +100,12 @@ export function useFilters(): FilterState {
|
|||||||
toggleRedactionSelection,
|
toggleRedactionSelection,
|
||||||
confirmRedaction,
|
confirmRedaction,
|
||||||
clearRedactionSelection,
|
clearRedactionSelection,
|
||||||
undoRedaction,
|
|
||||||
clearAllRedactions,
|
|
||||||
selectAllVisible,
|
|
||||||
getMatchCount,
|
|
||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
setAutoRedactEnabled,
|
setAutoRedactEnabled,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
enabledCategories,
|
enabledCategories,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
setAllCategories,
|
|
||||||
setPreset,
|
|
||||||
filterMessages,
|
filterMessages,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
redactedUuids,
|
redactedUuids,
|
||||||
@@ -164,11 +113,8 @@ export function useFilters(): FilterState {
|
|||||||
toggleRedactionSelection,
|
toggleRedactionSelection,
|
||||||
confirmRedaction,
|
confirmRedaction,
|
||||||
clearRedactionSelection,
|
clearRedactionSelection,
|
||||||
undoRedaction,
|
|
||||||
clearAllRedactions,
|
|
||||||
selectAllVisible,
|
|
||||||
getMatchCount,
|
|
||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
|
// setSearchQuery and setAutoRedactEnabled are useState setters (stable identity)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user