From 007cbbcb691e00d2235db6a7dc3431c17342f366 Mon Sep 17 00:00:00 2001 From: teernisse Date: Sat, 28 Feb 2026 00:53:30 -0500 Subject: [PATCH] Enhance UI with density toggle and mobile responsiveness Add density toggle, mobile sidebar behavior, and various polish improvements to the session viewer interface. Density toggle (comfortable/compact): - Persisted to localStorage as "session-viewer-density" - Compact mode reduces message padding and spacing - Toggle button in nav bar with LayoutRows icon - Visual indicator when compact mode is active Mobile sidebar: - Sidebar slides in/out with transform transition - Hamburger menu button visible on mobile (md:hidden) - Backdrop overlay when sidebar is open - Auto-close sidebar after selecting a session Session info improvements: - Show project/session title in nav bar when viewing session - Add session ID display with copy button in SessionViewer - Copy button shows check icon for 1.5s after copying SessionList enhancements: - Relative time format: "5m ago", "2h ago", "3d ago" - Total message count per project in project list - Truncated project names showing last 2 path segments - Full path available in title attribute MessageBubble: - Add compact prop for reduced padding - Add accentBorder property to category colors - Migrate inline SVGs to shared Icons SessionViewer: - Sticky session info header with glass effect - Add compact prop that propagates to MessageBubble - Keyboard shortcut hints in empty state Co-Authored-By: Claude Opus 4.5 --- src/client/app.tsx | 136 +++++++++++++++++++++--- src/client/components/MessageBubble.tsx | 74 ++++++------- src/client/components/SessionList.tsx | 78 +++++++++----- src/client/components/SessionViewer.tsx | 90 +++++++++++++--- src/client/lib/constants.ts | 11 +- 5 files changed, 292 insertions(+), 97 deletions(-) diff --git a/src/client/app.tsx b/src/client/app.tsx index bd6ac7e..74ef6d0 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -6,10 +6,54 @@ import { SearchBar } from "./components/SearchBar"; import { SearchMinimap } from "./components/SearchMinimap"; import { ExportButton } from "./components/ExportButton"; import { ErrorBoundary } from "./components/ErrorBoundary"; +import { Menu, LayoutRows } from "./components/Icons"; import { useSession } from "./hooks/useSession"; import { useFilters } from "./hooks/useFilters"; import { countSensitiveMessages } from "../shared/sensitive-redactor"; +import type { SessionEntry } from "./lib/types"; +type Density = "comfortable" | "compact"; + +function useDensity(): [Density, (d: Density) => void] { + const [density, setDensityState] = useState(() => { + try { + const stored = localStorage.getItem("session-viewer-density"); + if (stored === "compact" || stored === "comfortable") return stored; + } catch { /* localStorage unavailable */ } + return "comfortable"; + }); + + const setDensity = useCallback((d: Density) => { + setDensityState(d); + try { localStorage.setItem("session-viewer-density", d); } catch { /* noop */ } + }, []); + + return [density, setDensity]; +} + +function NavSessionInfo({ sessionId, project, sessions }: { + sessionId: string; + project: string; + sessions: SessionEntry[]; +}): React.ReactElement { + const entry = sessions.find((s) => s.id === sessionId); + const title = entry?.summary || entry?.firstPrompt || "Session"; + return ( +
+ {project && ( + + {project} + + )} + {project && ( + / + )} + + {title} + +
+ ); +} export function App() { const { @@ -22,6 +66,16 @@ export function App() { } = useSession(); const filters = useFilters(); + const [density, setDensity] = useDensity(); + const [sidebarOpen, setSidebarOpen] = useState(true); + + // Close sidebar on mobile after selecting a session + const handleSelectSession = useCallback((id: string) => { + loadSession(id); + if (window.innerWidth < 768) { + setSidebarOpen(false); + } + }, [loadSession]); // URL-driven session selection: sync session ID with URL search params const hasRestoredFromUrl = useRef(false); @@ -57,7 +111,6 @@ export function App() { const progressEnabled = filters.enabledCategories.has("hook_progress"); // Count across all session messages (not just filtered) — recompute only on session change. - // This avoids re-running 37 regex patterns whenever filter toggles change. const sensitiveCount = useMemo( () => countSensitiveMessages(currentSession?.messages || []), [currentSession?.messages] @@ -182,10 +235,28 @@ export function App() { updateViewport(); }, [filteredMessages, updateViewport]); + const isCompact = density === "compact"; + return (
+ {/* Sidebar backdrop — visible on mobile when sidebar is open */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + {/* Sidebar */} -
+

@@ -214,23 +285,44 @@ export function App() { sessions={sessions} loading={sessionsLoading} selectedId={currentSession?.id} - onSelect={loadSession} + onSelect={handleSelectSession} + /> +

+
+
-
{/* Main */}
-
- {/* Left spacer — mirrors right side width to keep search centered */} -
+
+ {/* Mobile sidebar toggle */} + + + {/* Left — session info or app title */} +
+ {currentSession ? ( + + ) : ( + Session Viewer + )} +
{/* Center — search bar + contextual redaction controls */}
@@ -263,8 +355,19 @@ export function App() { )}
- {/* Right — export button, right-justified */} -
+ {/* Right — density toggle + export button */} +
+ {currentSession && (
diff --git a/src/client/components/MessageBubble.tsx b/src/client/components/MessageBubble.tsx index 8566e53..decb17e 100644 --- a/src/client/components/MessageBubble.tsx +++ b/src/client/components/MessageBubble.tsx @@ -6,6 +6,7 @@ import { renderMarkdown, highlightSearchText } from "../lib/markdown"; import { redactMessage } from "../../shared/sensitive-redactor"; import { escapeHtml } from "../../shared/escape-html"; import { ProgressBadge } from "./ProgressBadge"; +import { ChevronRight, Copy, Check, EyeSlash } from "./Icons"; interface Props { message: ParsedMessage; @@ -16,6 +17,7 @@ interface Props { autoRedactEnabled: boolean; progressEvents?: ParsedMessage[]; progressEnabled?: boolean; + compact?: boolean; } /** @@ -34,6 +36,7 @@ export function MessageBubble({ autoRedactEnabled, progressEvents, progressEnabled, + compact = false, }: Props) { const colors = CATEGORY_COLORS[message.category]; const label = CATEGORY_LABELS[message.category]; @@ -58,7 +61,6 @@ export function MessageBubble({ } // 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 === "file_snapshot") { const html = `
${escapeHtml(tryPrettyJson(msg.content))}
`; return searchQuery ? highlightSearchText(html, searchQuery) : html; @@ -99,11 +101,28 @@ export function MessageBubble({ ? formatTimestamp(message.timestamp) : null; + // Content is sourced from local user-owned JSONL files (~/.claude/projects/), not untrusted input + const contentEl = !collapsed ? ( +
+ ) : null; + + const collapsedPreviewEl = collapsed && message.category === "thinking" && collapsedPreview ? ( +
+
{collapsedPreview.preview}
+
+ ) : null; + return (
- {/* Category accent strip */} -
- {/* Header bar */} -
+
{isCollapsible && ( )} @@ -136,7 +151,7 @@ export function MessageBubble({ {timestamp && ( <> - · + · {timestamp} @@ -144,7 +159,7 @@ export function MessageBubble({ )} {isCollapsible && collapsed && collapsedPreview && ( <> - · + · {message.category === "thinking" && collapsedPreview.totalLines > 2 ? `${collapsedPreview.totalLines} lines` @@ -170,13 +185,9 @@ export function MessageBubble({ title="Copy message content" > {contentCopied ? ( - - - + ) : ( - - - + )}
- {/* Content — sourced from local user-owned JSONL files, not external/untrusted input */} - {!collapsed && ( -
- )} - {collapsed && message.category === "thinking" && collapsedPreview && ( -
-
{collapsedPreview.preview}
-
- )} + {contentEl} + {collapsedPreviewEl} {message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && ( )} @@ -230,8 +229,6 @@ function isDiffContent(content: string): boolean { diffLines++; } } - // Require at least one hunk header AND some +/- lines to avoid false positives - // on YAML lists, markdown lists, or other content with leading dashes return hunkHeaders >= 1 && diffLines >= 2; } @@ -269,7 +266,6 @@ function formatTimestamp(ts: string): string { }); } -/** If the string is valid JSON, return it pretty-printed; otherwise return as-is. */ function tryPrettyJson(text: string): string { const trimmed = text.trimStart(); if (trimmed[0] !== "{" && trimmed[0] !== "[") return text; diff --git a/src/client/components/SessionList.tsx b/src/client/components/SessionList.tsx index 375cd05..0234db5 100644 --- a/src/client/components/SessionList.tsx +++ b/src/client/components/SessionList.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo } from "react"; import type { SessionEntry } from "../lib/types"; +import { ChevronRight, ChevronLeft, ChatBubble } from "./Icons"; interface Props { sessions: SessionEntry[]; @@ -49,9 +50,7 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props) return (
- - - +

No sessions found

Sessions will appear here once created

@@ -68,13 +67,15 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props) 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" > - - - + All Projects -
- {formatProjectName(selectedProject)} +
+ {truncateProjectName(selectedProject)}
{projectSessions.map((session, idx) => { @@ -90,18 +91,18 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props) : "hover:bg-surface-overlay" } `} - style={{ animationDelay: `${idx * 30}ms` }} + style={{ animationDelay: `${Math.min(idx, 15) * 30}ms` }} >
{session.summary || session.firstPrompt || "Untitled Session"}
- {formatDate(session.modified || session.created)} - · + {formatRelativeTime(session.modified || session.created)} + · {session.messageCount} msgs {session.duration && session.duration > 0 && ( <> - · + · {formatSessionDuration(session.duration)} )} @@ -122,24 +123,26 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props) (a.modified || a.created) > (b.modified || b.created) ? a : b ); const count = projectSessions.length; + const totalMessages = projectSessions.reduce((sum, s) => sum + s.messageCount, 0); return ( ); @@ -150,10 +153,6 @@ 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("-")) { @@ -162,6 +161,14 @@ function formatProjectName(project: string): string { return project; } +/** Show last 2 path segments for compact display. */ +function truncateProjectName(project: string): string { + const full = formatProjectName(project); + const segments = full.split("/").filter(Boolean); + if (segments.length <= 2) return full; + return segments.slice(-2).join("/"); +} + function formatSessionDuration(ms: number): string { const minutes = Math.floor(ms / 60000); if (minutes < 1) return "<1m"; @@ -172,14 +179,27 @@ function formatSessionDuration(ms: number): string { return `${hours}h ${rem}m`; } -function formatDate(dateStr: string): string { +function formatRelativeTime(dateStr: string): string { if (!dateStr) return ""; const d = new Date(dateStr); if (isNaN(d.getTime())) return dateStr; - return d.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + + const now = Date.now(); + const diffMs = now - d.getTime(); + + if (diffMs < 0) return "just now"; + if (diffMs < 60_000) return "just now"; + if (diffMs < 3_600_000) { + const mins = Math.floor(diffMs / 60_000); + return `${mins}m ago`; + } + if (diffMs < 86_400_000) { + const hours = Math.floor(diffMs / 3_600_000); + return `${hours}h ago`; + } + if (diffMs < 604_800_000) { + const days = Math.floor(diffMs / 86_400_000); + return `${days}d ago`; + } + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } diff --git a/src/client/components/SessionViewer.tsx b/src/client/components/SessionViewer.tsx index 86d8107..0e76179 100644 --- a/src/client/components/SessionViewer.tsx +++ b/src/client/components/SessionViewer.tsx @@ -1,7 +1,8 @@ -import React, { useRef, useEffect, useMemo } from "react"; +import React, { useRef, useEffect, useMemo, useState } from "react"; import type { ParsedMessage } from "../lib/types"; import { MessageBubble } from "./MessageBubble"; import { RedactedDivider } from "./RedactedDivider"; +import { Chat, Filter } from "./Icons"; interface Props { messages: ParsedMessage[]; @@ -15,6 +16,9 @@ interface Props { focusedIndex?: number; toolProgress?: Record; progressEnabled?: boolean; + sessionId?: string; + project?: string; + compact?: boolean; } function MessageSkeleton({ delay = 0 }: { delay?: number }) { @@ -48,6 +52,9 @@ export function SessionViewer({ focusedIndex = -1, toolProgress, progressEnabled, + sessionId, + project, + compact = false, }: Props) { const containerRef = useRef(null); @@ -162,12 +169,25 @@ export function SessionViewer({ 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))" }} > - - - +

Select a session

Choose a session from the sidebar to view its messages

+
+
+ j + k + Navigate messages +
+
+ / + Search +
+
+ Esc + Clear search +
+
); @@ -181,9 +201,7 @@ export function SessionViewer({ 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))" }} > - - - +

No matching messages

Try adjusting your filters or search query

@@ -194,12 +212,28 @@ export function SessionViewer({ return (
-
- - {messages.length} message{messages.length !== 1 ? "s" : ""} - +
+
+
+ {project && ( + <> + {project} + / + + )} + {sessionId && ( +
+ {sessionId} + +
+ )} +
+ + {messages.length} message{messages.length !== 1 ? "s" : ""} + +
-
+
{displayItems.map((item, idx) => { if (item.type === "redacted_divider") { return ; @@ -234,7 +268,7 @@ export function SessionViewer({ id={`msg-${msg.uuid}`} data-msg-index={item.messageIndex} className={`${idx < 20 ? "animate-fade-in" : ""} ${isFocused ? "search-match-focused rounded-xl" : ""}`} - style={idx < 20 ? { animationDelay: `${idx * 20}ms`, animationFillMode: "backwards" } : undefined} + style={idx < 20 ? { animationDelay: `${Math.min(idx, 15) * 20}ms`, animationFillMode: "backwards" } : undefined} >
); @@ -256,6 +291,35 @@ export function SessionViewer({ ); } +function CopyIdButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + function handleCopy(): void { + navigator.clipboard.writeText(value).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + + return ( + + ); +} + function formatDuration(ms: number): string { const minutes = Math.floor(ms / 60000); if (minutes < 60) return `${minutes}m`; diff --git a/src/client/lib/constants.ts b/src/client/lib/constants.ts index b80423b..413ef0e 100644 --- a/src/client/lib/constants.ts +++ b/src/client/lib/constants.ts @@ -2,51 +2,60 @@ import type { MessageCategory } from "./types"; export const CATEGORY_COLORS: Record< MessageCategory, - { dot: string; border: string; text: string } + { dot: string; border: string; text: string; accentBorder: string } > = { user_message: { dot: "bg-category-user", border: "border-category-user-border", text: "text-category-user", + accentBorder: "border-l-category-user", }, assistant_text: { dot: "bg-category-assistant", border: "border-category-assistant-border", text: "text-category-assistant", + accentBorder: "border-l-category-assistant", }, thinking: { dot: "bg-category-thinking", border: "border-category-thinking-border", text: "text-category-thinking", + accentBorder: "border-l-category-thinking", }, tool_call: { dot: "bg-category-tool", border: "border-category-tool-border", text: "text-category-tool", + accentBorder: "border-l-category-tool", }, tool_result: { dot: "bg-category-result", border: "border-category-result-border", text: "text-category-result", + accentBorder: "border-l-category-result", }, system_message: { dot: "bg-category-system", border: "border-category-system-border", text: "text-category-system", + accentBorder: "border-l-category-system", }, hook_progress: { dot: "bg-category-hook", border: "border-category-hook-border", text: "text-category-hook", + accentBorder: "border-l-category-hook", }, file_snapshot: { dot: "bg-category-snapshot", border: "border-category-snapshot-border", text: "text-category-snapshot", + accentBorder: "border-l-category-snapshot", }, summary: { dot: "bg-category-summary", border: "border-category-summary-border", text: "text-category-summary", + accentBorder: "border-l-category-summary", }, };