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:
2026-01-30 01:09:41 -05:00
parent 40b3ccf33e
commit 15a312d98c
7 changed files with 298 additions and 171 deletions

View File

@@ -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>
);
}

View File

@@ -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">
<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>
<div className="space-y-1">
{ALL_CATEGORIES.map((cat) => (
<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 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
type="checkbox"
checked={enabledCategories.has(cat)}
checked={isEnabled}
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>
))}
);
})}
</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
type="checkbox"
checked={autoRedactEnabled}
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>
</div>
</div>
)}
</div>
);
}

View File

@@ -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">
{/* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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 "";
}
}

View File

@@ -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>
);
}

View File

@@ -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", () => {

View File

@@ -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 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>
{projectSessions.map((session) => (
<button
key={session.id}
onClick={() => onSelect(session.id)}
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors ${
selectedId === session.id ? "bg-blue-50 border-l-2 border-l-blue-500" : ""
}`}
>
<div className="text-sm font-medium text-gray-900 truncate">
{session.summary || session.firstPrompt || "Untitled Session"}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{formatDate(session.modified || session.created)}</span>
<span>·</span>
<span>{session.messageCount} msgs</span>
</div>
</button>
))}
</div>
);
}
// Phase 1: Project list
if (sessions.length === 0) {
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]) => {
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">
<div className="flex items-center justify-between">
<div className="text-body font-medium text-foreground truncate">
{formatProjectName(project)}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{count} {count === 1 ? "session" : "sessions"}</span>
<span>·</span>
<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-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, "/");

View File

@@ -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)
]
);
}