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

@@ -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 (
<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">
<svg className="w-5 h-5 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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>
<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>
@@ -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"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
<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)" }}>
{formatProjectName(selectedProject)}
<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) => {
@@ -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` }}
>
<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>{formatDate(session.modified || session.created)}</span>
<span className="text-border">·</span>
<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">·</span>
<span className="text-border">&middot;</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
);
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">
{formatProjectName(project)}
{truncateProjectName(project)}
</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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<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>{formatDate(latest.modified || latest.created)}</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>
);
@@ -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" });
}