Redesign all client components with dark theme, polish, and UX improvements

MessageBubble: Replace border-left colored bars with rounded cards featuring
accent strips, category dot indicators, and timestamp display. Use shared
escapeHtml. Render tool_result, hook_progress, and file_snapshot as
preformatted text instead of markdown (avoids expensive marked.parse on
large JSON/log blobs).

ExportButton: Add state machine (idle/exporting/success/error) with animated
icons, gradient backgrounds, and auto-reset timers. Replace alert() with
inline error state.

FilterPanel: Add collapsible panel with category dot colors, enable count
badge, custom checkbox styling, and smooth animations.

SessionList: Replace text loading state with skeleton placeholders. Add
empty state illustration with descriptive text. Style session items as
rounded cards with hover/selected states, glow effects, and staggered
entry animations. Add project name decode explanation comment.

RedactedDivider: Add eye-slash SVG icon, red accent color, and styled
dashed lines replacing plain text divider.

useFilters: Remove unused exports (setAllCategories, setPreset, undoRedaction,
clearAllRedactions, selectAllVisible, getMatchCount) to reduce hook surface
area. Match counting moved to App component for search navigation.

SessionList.test: Update assertions for skeleton loading state and expanded
empty state text.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 01:09:41 -05:00
parent 40b3ccf33e
commit 15a312d98c
7 changed files with 298 additions and 171 deletions

View File

@@ -31,56 +31,83 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
if (loading) {
return (
<div className="p-4 text-sm text-gray-500">Loading sessions...</div>
);
}
if (sessions.length === 0) {
return (
<div className="p-4 text-sm text-gray-500">No sessions found</div>
);
}
// Phase 2: Session list for selected project
if (selectedProject !== null) {
const projectSessions = grouped.get(selectedProject) || [];
return (
<div className="py-2">
<button
onClick={() => setSelectedProject(null)}
className="w-full text-left px-4 py-2 text-sm text-blue-600 hover:text-blue-800 hover:bg-gray-50 transition-colors flex items-center gap-1"
>
<span></span>
<span>All Projects</span>
</button>
<div className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50">
{formatProjectName(selectedProject)}
</div>
{projectSessions.map((session) => (
<button
key={session.id}
onClick={() => onSelect(session.id)}
className={`w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors ${
selectedId === session.id ? "bg-blue-50 border-l-2 border-l-blue-500" : ""
}`}
>
<div className="text-sm font-medium text-gray-900 truncate">
{session.summary || session.firstPrompt || "Untitled Session"}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{formatDate(session.modified || session.created)}</span>
<span>·</span>
<span>{session.messageCount} msgs</span>
</div>
</button>
<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>
);
}
// Phase 1: Project list
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">
<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>
</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"
>
<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>
<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>
<div className="py-1">
{projectSessions.map((session, idx) => {
const isSelected = selectedId === session.id;
return (
<button
key={session.id}
onClick={() => onSelect(session.id)}
className={`
w-full text-left mx-2 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={{ width: "calc(100% - 1rem)", animationDelay: `${idx * 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 className="tabular-nums">{session.messageCount} msgs</span>
</div>
</button>
);
})}
</div>
</div>
);
}
// Project list
return (
<div className="py-2">
<div className="py-1 animate-fade-in">
{[...grouped.entries()].map(([project, projectSessions]) => {
const latest = projectSessions.reduce((a, b) =>
(a.modified || a.created) > (b.modified || b.created) ? a : b
@@ -90,14 +117,20 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
<button
key={project}
onClick={() => setSelectedProject(project)}
className="w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 transition-colors"
className="w-full text-left mx-2 my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
style={{ width: "calc(100% - 1rem)" }}
>
<div className="text-sm font-medium text-gray-900 truncate">
{formatProjectName(project)}
<div className="flex items-center justify-between">
<div className="text-body font-medium text-foreground truncate">
{formatProjectName(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>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{count} {count === 1 ? "session" : "sessions"}</span>
<span>·</span>
<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>
</div>
</button>
@@ -107,6 +140,13 @@ 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("-")) {
return project.replace(/^-/, "/").replace(/-/g, "/");