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>
206 lines
7.6 KiB
TypeScript
206 lines
7.6 KiB
TypeScript
import React, { useState, useEffect, useMemo } from "react";
|
|
import type { SessionEntry } from "../lib/types";
|
|
import { ChevronRight, ChevronLeft, ChatBubble } from "./Icons";
|
|
|
|
interface Props {
|
|
sessions: SessionEntry[];
|
|
loading: boolean;
|
|
selectedId?: string;
|
|
onSelect: (id: string) => void;
|
|
}
|
|
|
|
export function SessionList({ sessions, loading, selectedId, onSelect }: Props) {
|
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
|
|
|
// Group by project (memoized to avoid recomputing on unrelated rerenders)
|
|
const grouped = useMemo(() => {
|
|
const map = new Map<string, SessionEntry[]>();
|
|
for (const session of sessions) {
|
|
const group = map.get(session.project) || [];
|
|
group.push(session);
|
|
map.set(session.project, group);
|
|
}
|
|
return map;
|
|
}, [sessions]);
|
|
|
|
// Auto-select project when selectedId changes
|
|
useEffect(() => {
|
|
if (selectedId) {
|
|
const match = sessions.find((s) => s.id === selectedId);
|
|
if (match) {
|
|
setSelectedProject(match.project);
|
|
}
|
|
}
|
|
}, [selectedId, sessions]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<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>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sessions.length === 0) {
|
|
return (
|
|
<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">
|
|
<ChatBubble size="w-5 h-5" className="text-foreground-muted" />
|
|
</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"
|
|
>
|
|
<ChevronLeft size="w-3.5 h-3.5" />
|
|
<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)" }}
|
|
title={formatProjectName(selectedProject)}
|
|
>
|
|
{truncateProjectName(selectedProject)}
|
|
</div>
|
|
<div className="py-1 px-2">
|
|
{projectSessions.map((session, idx) => {
|
|
const isSelected = selectedId === session.id;
|
|
return (
|
|
<button
|
|
key={session.id}
|
|
onClick={() => onSelect(session.id)}
|
|
className={`
|
|
w-full text-left 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={{ animationDelay: `${Math.min(idx, 15) * 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>{formatRelativeTime(session.modified || session.created)}</span>
|
|
<span className="text-border">·</span>
|
|
<span className="tabular-nums">{session.messageCount} msgs</span>
|
|
{session.duration && session.duration > 0 && (
|
|
<>
|
|
<span className="text-border">·</span>
|
|
<span className="tabular-nums">{formatSessionDuration(session.duration)}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Project list
|
|
return (
|
|
<div className="py-1 px-2 animate-fade-in">
|
|
{[...grouped.entries()].map(([project, projectSessions]) => {
|
|
const latest = projectSessions.reduce((a, b) =>
|
|
(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 (
|
|
<button
|
|
key={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"
|
|
title={formatProjectName(project)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-body font-medium text-foreground truncate">
|
|
{truncateProjectName(project)}
|
|
</div>
|
|
<ChevronRight size="w-4 h-4" className="text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
|
</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>{formatRelativeTime(latest.modified || latest.created)}</span>
|
|
<span className="text-border">·</span>
|
|
<span className="tabular-nums">{totalMessages} msgs</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Best-effort decode of Claude Code's project directory name back to a path.
|
|
*/
|
|
function formatProjectName(project: string): string {
|
|
if (project.startsWith("-")) {
|
|
return project.replace(/^-/, "/").replace(/-/g, "/");
|
|
}
|
|
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";
|
|
if (minutes < 60) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const rem = minutes % 60;
|
|
if (rem === 0) return `${hours}h`;
|
|
return `${hours}h ${rem}m`;
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
if (!dateStr) return "";
|
|
const d = new Date(dateStr);
|
|
if (isNaN(d.getTime())) return dateStr;
|
|
|
|
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" });
|
|
}
|