From 15a312d98c4abda7dc59bce9de33ef462a5b66dd Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 01:09:41 -0500 Subject: [PATCH] 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 --- src/client/components/ExportButton.tsx | 71 +++++++++-- src/client/components/FilterPanel.tsx | 106 +++++++++++----- src/client/components/MessageBubble.tsx | 70 ++++++++--- src/client/components/RedactedDivider.tsx | 13 +- src/client/components/SessionList.test.tsx | 9 +- src/client/components/SessionList.tsx | 140 +++++++++++++-------- src/client/hooks/useFilters.ts | 60 +-------- 7 files changed, 298 insertions(+), 171 deletions(-) diff --git a/src/client/components/ExportButton.tsx b/src/client/components/ExportButton.tsx index 3940966..6a3f5de 100644 --- a/src/client/components/ExportButton.tsx +++ b/src/client/components/ExportButton.tsx @@ -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>(); + + 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 ( ); } diff --git a/src/client/components/FilterPanel.tsx b/src/client/components/FilterPanel.tsx index 11fdbdb..e3877cb 100644 --- a/src/client/components/FilterPanel.tsx +++ b/src/client/components/FilterPanel.tsx @@ -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; @@ -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 ( -
-
- Filters -
-
- {ALL_CATEGORIES.map((cat) => ( - - ))} -
-
- -
+
+ + + {!collapsed && ( +
+
+ {ALL_CATEGORIES.map((cat) => { + const colors = CATEGORY_COLORS[cat]; + const isEnabled = enabledCategories.has(cat); + return ( + + ); + })} +
+ +
+ +
+
+ )}
); } diff --git a/src/client/components/MessageBubble.tsx b/src/client/components/MessageBubble.tsx index fb9001c..a6ec589 100644 --- a/src/client/components/MessageBubble.tsx +++ b/src/client/components/MessageBubble.tsx @@ -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 - ? `
${escapeHtml(msg.toolInput)}
` + ? `
${escapeHtml(msg.toolInput)}
` : ""; - const html = `
${escapeHtml(msg.toolName || "Unknown Tool")}
${inputHtml}`; + const html = `
${escapeHtml(msg.toolName || "Unknown Tool")}
${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 = `
${escapeHtml(msg.content)}
`; + 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 (
-
- {label} + {/* Category accent strip */} +
+ + {/* Header bar */} +
+ + + {label} + + {timestamp && ( + <> + · + + {timestamp} + + + )}
- {/* Content from local user-owned JSONL files, not external/untrusted input */} + + {/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
); } -function escapeHtml(text: string): string { - return text - .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 ""; + } } diff --git a/src/client/components/RedactedDivider.tsx b/src/client/components/RedactedDivider.tsx index d18d49c..57818ed 100644 --- a/src/client/components/RedactedDivider.tsx +++ b/src/client/components/RedactedDivider.tsx @@ -2,10 +2,15 @@ import React from "react"; export function RedactedDivider() { return ( -
-
- ··· content redacted ··· -
+
+
+
+ + + + content redacted +
+
); } diff --git a/src/client/components/SessionList.test.tsx b/src/client/components/SessionList.test.tsx index fbd97dc..c65502d 100644 --- a/src/client/components/SessionList.test.tsx +++ b/src/client/components/SessionList.test.tsx @@ -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( ); - 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", () => { ); expect(screen.getByText("No sessions found")).toBeInTheDocument(); + expect(screen.getByText("Sessions will appear here once created")).toBeInTheDocument(); }); it("calls onSelect when clicking a session", () => { diff --git a/src/client/components/SessionList.tsx b/src/client/components/SessionList.tsx index c507761..eb4f90a 100644 --- a/src/client/components/SessionList.tsx +++ b/src/client/components/SessionList.tsx @@ -31,56 +31,83 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props) if (loading) { return ( -
Loading sessions...
- ); - } - - if (sessions.length === 0) { - return ( -
No sessions found
- ); - } - - // Phase 2: Session list for selected project - if (selectedProject !== null) { - const projectSessions = grouped.get(selectedProject) || []; - return ( -
- -
- {formatProjectName(selectedProject)} -
- {projectSessions.map((session) => ( - +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
))}
); } - // Phase 1: Project list + if (sessions.length === 0) { + return ( +
+
+ + + +
+

No sessions found

+

Sessions will appear here once created

+
+ ); + } + + // Session list for selected project + if (selectedProject !== null) { + const projectSessions = grouped.get(selectedProject) || []; + return ( +
+ +
+ {formatProjectName(selectedProject)} +
+
+ {projectSessions.map((session, idx) => { + const isSelected = selectedId === session.id; + return ( + + ); + })} +
+
+ ); + } + + // Project list return ( -
+
{[...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) @@ -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, "/"); diff --git a/src/client/hooks/useFilters.ts b/src/client/hooks/useFilters.ts index 36d89f1..77a0c51 100644 --- a/src/client/hooks/useFilters.ts +++ b/src/client/hooks/useFilters.ts @@ -5,7 +5,6 @@ import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../lib/types"; interface FilterState { enabledCategories: Set; 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) ] ); }