Files
session-viewer/src/client/components/SessionList.tsx
teernisse 007cbbcb69 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>
2026-02-28 00:53:43 -05:00

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">&middot;</span>
<span className="tabular-nums">{session.messageCount} msgs</span>
{session.duration && session.duration > 0 && (
<>
<span className="text-border">&middot;</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">&middot;</span>
<span>{formatRelativeTime(latest.modified || latest.created)}</span>
<span className="text-border">&middot;</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" });
}