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 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-28 00:53:30 -05:00
parent b67c302464
commit 007cbbcb69
5 changed files with 292 additions and 97 deletions

View File

@@ -6,10 +6,54 @@ import { SearchBar } from "./components/SearchBar";
import { SearchMinimap } from "./components/SearchMinimap"; import { SearchMinimap } from "./components/SearchMinimap";
import { ExportButton } from "./components/ExportButton"; import { ExportButton } from "./components/ExportButton";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { Menu, LayoutRows } from "./components/Icons";
import { useSession } from "./hooks/useSession"; import { useSession } from "./hooks/useSession";
import { useFilters } from "./hooks/useFilters"; import { useFilters } from "./hooks/useFilters";
import { countSensitiveMessages } from "../shared/sensitive-redactor"; 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<Density>(() => {
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 (
<div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
{project && (
<span className="text-caption text-foreground-muted whitespace-nowrap truncate max-w-[200px]" title={project}>
{project}
</span>
)}
{project && (
<span className="text-foreground-muted opacity-40 flex-shrink-0">/</span>
)}
<span className="text-body font-medium text-foreground truncate">
{title}
</span>
</div>
);
}
export function App() { export function App() {
const { const {
@@ -22,6 +66,16 @@ export function App() {
} = useSession(); } = useSession();
const filters = useFilters(); 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 // URL-driven session selection: sync session ID with URL search params
const hasRestoredFromUrl = useRef(false); const hasRestoredFromUrl = useRef(false);
@@ -57,7 +111,6 @@ export function App() {
const progressEnabled = filters.enabledCategories.has("hook_progress"); const progressEnabled = filters.enabledCategories.has("hook_progress");
// Count across all session messages (not just filtered) — recompute only on session change. // 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( const sensitiveCount = useMemo(
() => countSensitiveMessages(currentSession?.messages || []), () => countSensitiveMessages(currentSession?.messages || []),
[currentSession?.messages] [currentSession?.messages]
@@ -182,10 +235,28 @@ export function App() {
updateViewport(); updateViewport();
}, [filteredMessages, updateViewport]); }, [filteredMessages, updateViewport]);
const isCompact = density === "compact";
return ( return (
<div className="flex h-screen" style={{ background: "var(--color-canvas)" }}> <div className="flex h-screen" style={{ background: "var(--color-canvas)" }}>
{/* Sidebar backdrop — visible on mobile when sidebar is open */}
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/50 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */} {/* Sidebar */}
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col"> <div
className={`
flex-shrink-0 border-r border-border bg-surface-raised flex flex-col
fixed inset-y-0 left-0 z-30 w-80
transform transition-transform duration-200 ease-out
md:relative md:translate-x-0
${sidebarOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
<div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}> <div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-heading font-semibold text-foreground tracking-tight"> <h1 className="text-heading font-semibold text-foreground tracking-tight">
@@ -214,9 +285,10 @@ export function App() {
sessions={sessions} sessions={sessions}
loading={sessionsLoading} loading={sessionsLoading}
selectedId={currentSession?.id} selectedId={currentSession?.id}
onSelect={loadSession} onSelect={handleSelectSession}
/> />
</div> </div>
<div className="flex-shrink-0">
<FilterPanel <FilterPanel
enabledCategories={filters.enabledCategories} enabledCategories={filters.enabledCategories}
onToggle={filters.toggleCategory} onToggle={filters.toggleCategory}
@@ -225,12 +297,32 @@ export function App() {
sensitiveCount={sensitiveCount} sensitiveCount={sensitiveCount}
/> />
</div> </div>
</div>
{/* Main */} {/* Main */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<div className="glass flex items-center px-5 py-4 border-b border-border z-10"> <div className="glass flex items-center px-5 py-3 border-b border-border z-10 gap-3">
{/* Left spacer — mirrors right side width to keep search centered */} {/* Mobile sidebar toggle */}
<div className="flex-1 min-w-0" /> <button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center justify-center w-8 h-8 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors md:hidden flex-shrink-0"
aria-label="Toggle sidebar"
>
<Menu />
</button>
{/* Left — session info or app title */}
<div className="flex-1 min-w-0">
{currentSession ? (
<NavSessionInfo
sessionId={currentSession.id}
project={currentSession.project}
sessions={sessions}
/>
) : (
<span className="text-body font-medium text-foreground-secondary hidden md:block">Session Viewer</span>
)}
</div>
{/* Center — search bar + contextual redaction controls */} {/* Center — search bar + contextual redaction controls */}
<div className="flex items-center gap-3 flex-shrink-0"> <div className="flex items-center gap-3 flex-shrink-0">
@@ -263,8 +355,19 @@ export function App() {
)} )}
</div> </div>
{/* Right — export button, right-justified */} {/* Right — density toggle + export button */}
<div className="flex-1 min-w-0 flex justify-end"> <div className="flex-1 min-w-0 flex items-center justify-end gap-2">
<button
onClick={() => setDensity(isCompact ? "comfortable" : "compact")}
className={`flex items-center justify-center w-8 h-8 rounded-md transition-colors flex-shrink-0 ${
isCompact
? "text-accent bg-accent-light"
: "text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60"
}`}
title={isCompact ? "Switch to comfortable density" : "Switch to compact density"}
>
<LayoutRows size="w-4 h-4" />
</button>
{currentSession && ( {currentSession && (
<ExportButton <ExportButton
session={currentSession} session={currentSession}
@@ -291,6 +394,9 @@ export function App() {
focusedIndex={activeFocusIndex} focusedIndex={activeFocusIndex}
toolProgress={currentSession?.toolProgress} toolProgress={currentSession?.toolProgress}
progressEnabled={progressEnabled} progressEnabled={progressEnabled}
sessionId={currentSession?.id}
project={currentSession?.project}
compact={isCompact}
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { renderMarkdown, highlightSearchText } from "../lib/markdown";
import { redactMessage } from "../../shared/sensitive-redactor"; import { redactMessage } from "../../shared/sensitive-redactor";
import { escapeHtml } from "../../shared/escape-html"; import { escapeHtml } from "../../shared/escape-html";
import { ProgressBadge } from "./ProgressBadge"; import { ProgressBadge } from "./ProgressBadge";
import { ChevronRight, Copy, Check, EyeSlash } from "./Icons";
interface Props { interface Props {
message: ParsedMessage; message: ParsedMessage;
@@ -16,6 +17,7 @@ interface Props {
autoRedactEnabled: boolean; autoRedactEnabled: boolean;
progressEvents?: ParsedMessage[]; progressEvents?: ParsedMessage[];
progressEnabled?: boolean; progressEnabled?: boolean;
compact?: boolean;
} }
/** /**
@@ -34,6 +36,7 @@ export function MessageBubble({
autoRedactEnabled, autoRedactEnabled,
progressEvents, progressEvents,
progressEnabled, progressEnabled,
compact = false,
}: Props) { }: Props) {
const colors = CATEGORY_COLORS[message.category]; const colors = CATEGORY_COLORS[message.category];
const label = CATEGORY_LABELS[message.category]; const label = CATEGORY_LABELS[message.category];
@@ -58,7 +61,6 @@ export function MessageBubble({
} }
// Structured data categories: render as preformatted text, not markdown. // 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") { if (msg.category === "hook_progress" || msg.category === "file_snapshot") {
const html = `<pre class="hljs"><code>${escapeHtml(tryPrettyJson(msg.content))}</code></pre>`; const html = `<pre class="hljs"><code>${escapeHtml(tryPrettyJson(msg.content))}</code></pre>`;
return searchQuery ? highlightSearchText(html, searchQuery) : html; return searchQuery ? highlightSearchText(html, searchQuery) : html;
@@ -99,11 +101,28 @@ export function MessageBubble({
? formatTimestamp(message.timestamp) ? formatTimestamp(message.timestamp)
: null; : null;
// Content is sourced from local user-owned JSONL files (~/.claude/projects/), not untrusted input
const contentEl = !collapsed ? (
<div
className={`prose-message text-body text-foreground max-w-none break-words overflow-hidden ${
compact ? "px-3 pb-2 pt-0.5" : "px-5 pb-4 pt-1"
}`}
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
) : null;
const collapsedPreviewEl = collapsed && message.category === "thinking" && collapsedPreview ? (
<div className={compact ? "px-3 pb-2 pt-0.5" : "px-5 pb-3 pt-1"}>
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
</div>
) : null;
return ( return (
<div <div
className={` className={`
group rounded-xl border bg-surface-raised group rounded-xl border-l-[3px] border bg-surface-raised
transition-all duration-200 relative overflow-hidden transition-all duration-200
${colors.accentBorder}
${colors.border} ${colors.border}
${dimmed ? "message-dimmed" : ""} ${dimmed ? "message-dimmed" : ""}
${selectedForRedaction ? "redaction-selected" : ""} ${selectedForRedaction ? "redaction-selected" : ""}
@@ -111,23 +130,19 @@ export function MessageBubble({
shadow-card shadow-card
`} `}
> >
{/* Category accent strip */}
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
{/* Header bar */} {/* Header bar */}
<div className="flex items-center gap-1.5 px-5 min-h-10 py-2.5"> <div className={`flex items-center gap-1.5 ${compact ? "px-3 min-h-8 py-1.5" : "px-5 min-h-10 py-2.5"}`}>
{isCollapsible && ( {isCollapsible && (
<button <button
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }} onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
className="flex items-center justify-center w-5 h-5 text-foreground-muted hover:text-foreground transition-colors flex-shrink-0" className="flex items-center justify-center w-5 h-5 text-foreground-muted hover:text-foreground transition-colors flex-shrink-0"
aria-label={collapsed ? "Expand" : "Collapse"} aria-label={collapsed ? "Expand" : "Collapse"}
> >
<svg <ChevronRight
className={`w-3.5 h-3.5 transition-transform duration-150 ${collapsed ? "" : "rotate-90"}`} size="w-3.5 h-3.5"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5} strokeWidth={2.5}
> className={`transition-transform duration-150 ${collapsed ? "" : "rotate-90"}`}
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> />
</svg>
</button> </button>
)} )}
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} /> <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} />
@@ -136,7 +151,7 @@ export function MessageBubble({
</span> </span>
{timestamp && ( {timestamp && (
<> <>
<span className="text-border leading-none">·</span> <span className="text-border leading-none">&middot;</span>
<span className="text-caption text-foreground-muted tabular-nums leading-none"> <span className="text-caption text-foreground-muted tabular-nums leading-none">
{timestamp} {timestamp}
</span> </span>
@@ -144,7 +159,7 @@ export function MessageBubble({
)} )}
{isCollapsible && collapsed && collapsedPreview && ( {isCollapsible && collapsed && collapsedPreview && (
<> <>
<span className="text-border leading-none">·</span> <span className="text-border leading-none">&middot;</span>
<span className="text-caption text-foreground-muted truncate max-w-[300px] leading-none"> <span className="text-caption text-foreground-muted truncate max-w-[300px] leading-none">
{message.category === "thinking" && collapsedPreview.totalLines > 2 {message.category === "thinking" && collapsedPreview.totalLines > 2
? `${collapsedPreview.totalLines} lines` ? `${collapsedPreview.totalLines} lines`
@@ -170,13 +185,9 @@ export function MessageBubble({
title="Copy message content" title="Copy message content"
> >
{contentCopied ? ( {contentCopied ? (
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <Check size="w-4 h-4" className="text-green-400" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : ( ) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <Copy />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m0 0a2.625 2.625 0 115.25 0H12m-3.75 0h3.75" />
</svg>
)} )}
</button> </button>
<button <button
@@ -191,25 +202,13 @@ export function MessageBubble({
}`} }`}
title={selectedForRedaction ? "Deselect for redaction" : "Select for redaction"} title={selectedForRedaction ? "Deselect for redaction" : "Select for redaction"}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <EyeSlash />
<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>
</button> </button>
</div> </div>
</div> </div>
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */} {contentEl}
{!collapsed && ( {collapsedPreviewEl}
<div
className="prose-message text-body text-foreground px-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
)}
{collapsed && message.category === "thinking" && collapsedPreview && (
<div className="px-5 pb-3 pt-1">
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
</div>
)}
{message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && ( {message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && (
<ProgressBadge events={progressEvents} /> <ProgressBadge events={progressEvents} />
)} )}
@@ -230,8 +229,6 @@ function isDiffContent(content: string): boolean {
diffLines++; 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; 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 { function tryPrettyJson(text: string): string {
const trimmed = text.trimStart(); const trimmed = text.trimStart();
if (trimmed[0] !== "{" && trimmed[0] !== "[") return text; if (trimmed[0] !== "{" && trimmed[0] !== "[") return text;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import type { SessionEntry } from "../lib/types"; import type { SessionEntry } from "../lib/types";
import { ChevronRight, ChevronLeft, ChatBubble } from "./Icons";
interface Props { interface Props {
sessions: SessionEntry[]; sessions: SessionEntry[];
@@ -49,9 +50,7 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
return ( return (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center"> <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"> <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}> <ChatBubble size="w-5 h-5" className="text-foreground-muted" />
<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> </div>
<p className="text-body font-medium text-foreground-secondary">No sessions found</p> <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> <p className="text-caption text-foreground-muted mt-1">Sessions will appear here once created</p>
@@ -68,13 +67,15 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
onClick={() => setSelectedProject(null)} 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" 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}> <ChevronLeft size="w-3.5 h-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
<span>All Projects</span> <span>All Projects</span>
</button> </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)" }}> <div
{formatProjectName(selectedProject)} 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)" }}
title={formatProjectName(selectedProject)}
>
{truncateProjectName(selectedProject)}
</div> </div>
<div className="py-1 px-2"> <div className="py-1 px-2">
{projectSessions.map((session, idx) => { {projectSessions.map((session, idx) => {
@@ -90,18 +91,18 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
: "hover:bg-surface-overlay" : "hover:bg-surface-overlay"
} }
`} `}
style={{ animationDelay: `${idx * 30}ms` }} style={{ animationDelay: `${Math.min(idx, 15) * 30}ms` }}
> >
<div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}> <div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}>
{session.summary || session.firstPrompt || "Untitled Session"} {session.summary || session.firstPrompt || "Untitled Session"}
</div> </div>
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted"> <div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
<span>{formatDate(session.modified || session.created)}</span> <span>{formatRelativeTime(session.modified || session.created)}</span>
<span className="text-border">·</span> <span className="text-border">&middot;</span>
<span className="tabular-nums">{session.messageCount} msgs</span> <span className="tabular-nums">{session.messageCount} msgs</span>
{session.duration && session.duration > 0 && ( {session.duration && session.duration > 0 && (
<> <>
<span className="text-border">·</span> <span className="text-border">&middot;</span>
<span className="tabular-nums">{formatSessionDuration(session.duration)}</span> <span className="tabular-nums">{formatSessionDuration(session.duration)}</span>
</> </>
)} )}
@@ -122,24 +123,26 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
(a.modified || a.created) > (b.modified || b.created) ? a : b (a.modified || a.created) > (b.modified || b.created) ? a : b
); );
const count = projectSessions.length; const count = projectSessions.length;
const totalMessages = projectSessions.reduce((sum, s) => sum + s.messageCount, 0);
return ( return (
<button <button
key={project} key={project}
onClick={() => setSelectedProject(project)} onClick={() => setSelectedProject(project)}
className="w-full text-left my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group" className="w-full text-left my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
title={formatProjectName(project)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-body font-medium text-foreground truncate"> <div className="text-body font-medium text-foreground truncate">
{formatProjectName(project)} {truncateProjectName(project)}
</div> </div>
<svg className="w-4 h-4 text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <ChevronRight size="w-4 h-4" className="text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div> </div>
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted"> <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="tabular-nums">{count} {count === 1 ? "session" : "sessions"}</span>
<span className="text-border">·</span> <span className="text-border">&middot;</span>
<span>{formatDate(latest.modified || latest.created)}</span> <span>{formatRelativeTime(latest.modified || latest.created)}</span>
<span className="text-border">&middot;</span>
<span className="tabular-nums">{totalMessages} msgs</span>
</div> </div>
</button> </button>
); );
@@ -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. * 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("-")) {
@@ -162,6 +161,14 @@ function formatProjectName(project: string): string {
return project; 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 { function formatSessionDuration(ms: number): string {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
if (minutes < 1) return "<1m"; if (minutes < 1) return "<1m";
@@ -172,14 +179,27 @@ function formatSessionDuration(ms: number): string {
return `${hours}h ${rem}m`; return `${hours}h ${rem}m`;
} }
function formatDate(dateStr: string): string { function formatRelativeTime(dateStr: string): string {
if (!dateStr) return ""; if (!dateStr) return "";
const d = new Date(dateStr); const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr; if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(undefined, {
month: "short", const now = Date.now();
day: "numeric", const diffMs = now - d.getTime();
hour: "2-digit",
minute: "2-digit", 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" });
} }

View File

@@ -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 type { ParsedMessage } from "../lib/types";
import { MessageBubble } from "./MessageBubble"; import { MessageBubble } from "./MessageBubble";
import { RedactedDivider } from "./RedactedDivider"; import { RedactedDivider } from "./RedactedDivider";
import { Chat, Filter } from "./Icons";
interface Props { interface Props {
messages: ParsedMessage[]; messages: ParsedMessage[];
@@ -15,6 +16,9 @@ interface Props {
focusedIndex?: number; focusedIndex?: number;
toolProgress?: Record<string, ParsedMessage[]>; toolProgress?: Record<string, ParsedMessage[]>;
progressEnabled?: boolean; progressEnabled?: boolean;
sessionId?: string;
project?: string;
compact?: boolean;
} }
function MessageSkeleton({ delay = 0 }: { delay?: number }) { function MessageSkeleton({ delay = 0 }: { delay?: number }) {
@@ -48,6 +52,9 @@ export function SessionViewer({
focusedIndex = -1, focusedIndex = -1,
toolProgress, toolProgress,
progressEnabled, progressEnabled,
sessionId,
project,
compact = false,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(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" 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))" }} style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
> >
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <Chat size="w-7 h-7" className="text-foreground-muted" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
</div> </div>
<p className="text-subheading font-medium text-foreground">Select a session</p> <p className="text-subheading font-medium text-foreground">Select a session</p>
<p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p> <p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p>
<div className="mt-5 flex flex-col gap-2 text-caption text-foreground-muted">
<div className="flex items-center justify-center gap-2">
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">j</kbd>
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">k</kbd>
<span>Navigate messages</span>
</div>
<div className="flex items-center justify-center gap-2">
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">/</kbd>
<span>Search</span>
</div>
<div className="flex items-center justify-center gap-2">
<kbd className="inline-flex items-center justify-center px-1.5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">Esc</kbd>
<span>Clear search</span>
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -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" 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))" }} style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
> >
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <Filter size="w-7 h-7" className="text-foreground-muted" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
</div> </div>
<p className="text-subheading font-medium text-foreground">No matching messages</p> <p className="text-subheading font-medium text-foreground">No matching messages</p>
<p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p> <p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p>
@@ -194,12 +212,28 @@ export function SessionViewer({
return ( return (
<div className="max-w-6xl mx-auto px-6 py-6"> <div className="max-w-6xl mx-auto px-6 py-6">
<div className="flex items-center justify-between mb-6"> <div className="sticky top-0 z-10 -mx-6 px-6 py-3 mb-4 glass-subtle border-b border-border-muted">
<span className="text-caption text-foreground-muted tabular-nums"> <div className="flex items-center justify-between gap-4 min-w-0">
<div className="flex items-center gap-2 min-w-0 overflow-x-auto scrollbar-none">
{project && (
<>
<span className="text-caption text-foreground-muted whitespace-nowrap">{project}</span>
<span className="text-foreground-muted opacity-40 flex-shrink-0">/</span>
</>
)}
{sessionId && (
<div className="flex items-center gap-1.5 min-w-0">
<code className="text-caption text-foreground-muted font-mono whitespace-nowrap">{sessionId}</code>
<CopyIdButton value={sessionId} />
</div>
)}
</div>
<span className="text-caption text-foreground-muted tabular-nums whitespace-nowrap flex-shrink-0">
{messages.length} message{messages.length !== 1 ? "s" : ""} {messages.length} message{messages.length !== 1 ? "s" : ""}
</span> </span>
</div> </div>
<div ref={containerRef} className="space-y-3"> </div>
<div ref={containerRef} className={compact ? "space-y-1.5" : "space-y-3"}>
{displayItems.map((item, idx) => { {displayItems.map((item, idx) => {
if (item.type === "redacted_divider") { if (item.type === "redacted_divider") {
return <RedactedDivider key={item.key} />; return <RedactedDivider key={item.key} />;
@@ -234,7 +268,7 @@ export function SessionViewer({
id={`msg-${msg.uuid}`} id={`msg-${msg.uuid}`}
data-msg-index={item.messageIndex} data-msg-index={item.messageIndex}
className={`${idx < 20 ? "animate-fade-in" : ""} ${isFocused ? "search-match-focused rounded-xl" : ""}`} 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}
> >
<MessageBubble <MessageBubble
message={msg} message={msg}
@@ -247,6 +281,7 @@ export function SessionViewer({
autoRedactEnabled={autoRedactEnabled} autoRedactEnabled={autoRedactEnabled}
progressEvents={progressEvents} progressEvents={progressEvents}
progressEnabled={progressEnabled} progressEnabled={progressEnabled}
compact={compact}
/> />
</div> </div>
); );
@@ -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 (
<button
onClick={handleCopy}
className="flex-shrink-0 p-0.5 rounded text-foreground-muted opacity-50 hover:opacity-100 transition-opacity"
title="Copy session ID"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-category-assistant" 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>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
)}
</button>
);
}
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
if (minutes < 60) return `${minutes}m`; if (minutes < 60) return `${minutes}m`;

View File

@@ -2,51 +2,60 @@ import type { MessageCategory } from "./types";
export const CATEGORY_COLORS: Record< export const CATEGORY_COLORS: Record<
MessageCategory, MessageCategory,
{ dot: string; border: string; text: string } { dot: string; border: string; text: string; accentBorder: string }
> = { > = {
user_message: { user_message: {
dot: "bg-category-user", dot: "bg-category-user",
border: "border-category-user-border", border: "border-category-user-border",
text: "text-category-user", text: "text-category-user",
accentBorder: "border-l-category-user",
}, },
assistant_text: { assistant_text: {
dot: "bg-category-assistant", dot: "bg-category-assistant",
border: "border-category-assistant-border", border: "border-category-assistant-border",
text: "text-category-assistant", text: "text-category-assistant",
accentBorder: "border-l-category-assistant",
}, },
thinking: { thinking: {
dot: "bg-category-thinking", dot: "bg-category-thinking",
border: "border-category-thinking-border", border: "border-category-thinking-border",
text: "text-category-thinking", text: "text-category-thinking",
accentBorder: "border-l-category-thinking",
}, },
tool_call: { tool_call: {
dot: "bg-category-tool", dot: "bg-category-tool",
border: "border-category-tool-border", border: "border-category-tool-border",
text: "text-category-tool", text: "text-category-tool",
accentBorder: "border-l-category-tool",
}, },
tool_result: { tool_result: {
dot: "bg-category-result", dot: "bg-category-result",
border: "border-category-result-border", border: "border-category-result-border",
text: "text-category-result", text: "text-category-result",
accentBorder: "border-l-category-result",
}, },
system_message: { system_message: {
dot: "bg-category-system", dot: "bg-category-system",
border: "border-category-system-border", border: "border-category-system-border",
text: "text-category-system", text: "text-category-system",
accentBorder: "border-l-category-system",
}, },
hook_progress: { hook_progress: {
dot: "bg-category-hook", dot: "bg-category-hook",
border: "border-category-hook-border", border: "border-category-hook-border",
text: "text-category-hook", text: "text-category-hook",
accentBorder: "border-l-category-hook",
}, },
file_snapshot: { file_snapshot: {
dot: "bg-category-snapshot", dot: "bg-category-snapshot",
border: "border-category-snapshot-border", border: "border-category-snapshot-border",
text: "text-category-snapshot", text: "text-category-snapshot",
accentBorder: "border-l-category-snapshot",
}, },
summary: { summary: {
dot: "bg-category-summary", dot: "bg-category-summary",
border: "border-category-summary-border", border: "border-category-summary-border",
text: "text-category-summary", text: "text-category-summary",
accentBorder: "border-l-category-summary",
}, },
}; };