From 69315eebc2b3e5dbf37361551ffd946472786f4c Mon Sep 17 00:00:00 2001 From: emefienem Date: Mon, 4 May 2026 15:22:10 +0100 Subject: [PATCH] feat: improve Sessions empty state with guidance and troubleshooting hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace generic "No sessions found" message with a contextual empty state that guides users to run a session. Adds a secondary hint to check CAST connection or database configuration if sessions have already been run. Search/filter empty state ("No matching sessions") remains unchanged. No logic changes — purely presentational. --- src/views/SessionsView.tsx | 735 ++++++++++++++++++++++--------------- 1 file changed, 441 insertions(+), 294 deletions(-) diff --git a/src/views/SessionsView.tsx b/src/views/SessionsView.tsx index bead4ef..fa44d6e 100644 --- a/src/views/SessionsView.tsx +++ b/src/views/SessionsView.tsx @@ -1,63 +1,87 @@ -import { useState, useMemo, useRef } from 'react' -import { useNavigate } from 'react-router-dom' -import { Search, Trash2, Radio, AlertTriangle, CheckCircle } from 'lucide-react' -import { useVirtualizer } from '@tanstack/react-virtual' -import { useQueryClient } from '@tanstack/react-query' -import { useSessions } from '../api/useSessions' -import { timeAgo, formatDuration } from '../utils/time' -import { estimateCost, formatTokens, formatCost } from '../utils/costEstimate' -import type { Session } from '../types' -import { useRoutingEventsByType } from '../api/useRoutingEventsByType' -import { useHookEventsStream } from '../api/useHookEvents' -import type { HookEvent } from '../api/useHookEvents' -import { useUnstagedWarnings } from '../api/useUnstagedWarnings' +import { useState, useMemo, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Search, + Trash2, + Radio, + AlertTriangle, + CheckCircle, +} from "lucide-react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useQueryClient } from "@tanstack/react-query"; +import { useSessions } from "../api/useSessions"; +import { timeAgo, formatDuration } from "../utils/time"; +import { estimateCost, formatTokens, formatCost } from "../utils/costEstimate"; +import type { Session } from "../types"; +import { useRoutingEventsByType } from "../api/useRoutingEventsByType"; +import { useHookEventsStream } from "../api/useHookEvents"; +import type { HookEvent } from "../api/useHookEvents"; +import { useUnstagedWarnings } from "../api/useUnstagedWarnings"; function extractProjectName(projectPath: string): string { - if (!projectPath) return 'Unknown' - const segments = projectPath.replace(/\/+$/, '').split('/') - return segments[segments.length - 1] || 'Unknown' + if (!projectPath) return "Unknown"; + const segments = projectPath.replace(/\/+$/, "").split("/"); + return segments[segments.length - 1] || "Unknown"; } // ── Hook Events Live Feed ───────────────────────────────────────────────────── function resultColor(result: string | null): string { - if (!result) return 'text-[var(--text-muted)]' - const r = result.toLowerCase() - if (r === 'allow' || r === 'ok' || r === 'success') return 'text-emerald-400' - if (r === 'block' || r === 'error' || r === 'fail') return 'text-rose-400' - return 'text-[var(--text-secondary)]' + if (!result) return "text-[var(--text-muted)]"; + const r = result.toLowerCase(); + if (r === "allow" || r === "ok" || r === "success") return "text-emerald-400"; + if (r === "block" || r === "error" || r === "fail") return "text-rose-400"; + return "text-[var(--text-secondary)]"; } function HookEventRow({ event }: { event: HookEvent }) { - const time = new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + const time = new Date(event.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); return (
- {time} - {event.hook_type} - {event.tool_name ?? '—'} - - {event.result ?? '—'} + + {time} + + + {event.hook_type} + + + {event.tool_name ?? "—"} + + + {event.result ?? "—"} - {event.duration_ms != null ? `${event.duration_ms}ms` : '—'} + {event.duration_ms != null ? `${event.duration_ms}ms` : "—"}
- ) + ); } function HookEventsFeed() { - const { events, connected } = useHookEventsStream(30) + const { events, connected } = useHookEventsStream(30); return (
-

Live Hook Events

+

+ Live Hook Events +

- - {connected ? 'connected' : 'disconnected'} + + + {connected ? "connected" : "disconnected"} +
@@ -67,59 +91,80 @@ function HookEventsFeed() {
) : (
- {events.map(ev => ( + {events.map((ev) => ( ))}
)} - ) + ); } // ── Unstaged File Warnings Card ─────────────────────────────────────────────── function UnstagedWarningsCard() { - const { data } = useUnstagedWarnings() - const warnings = data?.warnings ?? [] - const count = warnings.length - const preview = warnings.slice(0, 5) + const { data } = useUnstagedWarnings(); + const warnings = data?.warnings ?? []; + const count = warnings.length; + const preview = warnings.slice(0, 5); function fmtTime(ts: string) { - return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + return new Date(ts).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); } return (
- {count > 0 - ? - : } -

Unstaged File Warnings

+ {count > 0 ? ( + + ) : ( + + )} +

+ Unstaged File Warnings +

- 0 - ? 'bg-rose-500/20 text-rose-300' - : 'bg-emerald-500/20 text-emerald-300' - }`}> - {count > 0 ? count : 'Clear'} + 0 + ? "bg-rose-500/20 text-rose-300" + : "bg-emerald-500/20 text-emerald-300" + }`} + > + {count > 0 ? count : "Clear"}
{preview.length === 0 ? ( -
No unstaged file warnings — all clear
+
+ No unstaged file warnings — all clear +
) : (
- {preview.map(w => ( -
- {fmtTime(w.timestamp)} - {w.unstaged_files ?? '—'} - {w.commit_sha ? w.commit_sha.slice(0, 7) : '—'} + {preview.map((w) => ( +
+ + {fmtTime(w.timestamp)} + + + {w.unstaged_files ?? "—"} + + + {w.commit_sha ? w.commit_sha.slice(0, 7) : "—"} +
))}
)}
- ) + ); } function SkeletonRow() { @@ -131,120 +176,153 @@ function SkeletonRow() { ))} - ) + ); } function ModelBadge({ model }: { model?: string }) { - if (!model) return - const lower = model.toLowerCase() - const label = lower.includes('opus') ? 'Opus' - : lower.includes('haiku') ? 'Haiku' - : lower.includes('sonnet') ? 'Sonnet' - : model - const color = lower.includes('opus') - ? 'bg-purple-500/20 text-purple-300' - : lower.includes('haiku') - ? 'bg-blue-500/20 text-blue-300' - : lower.includes('sonnet') - ? 'bg-emerald-500/20 text-emerald-300' - : 'bg-[var(--bg-secondary)] text-[var(--text-muted)]' + if (!model) + return ; + const lower = model.toLowerCase(); + const label = lower.includes("opus") + ? "Opus" + : lower.includes("haiku") + ? "Haiku" + : lower.includes("sonnet") + ? "Sonnet" + : model; + const color = lower.includes("opus") + ? "bg-purple-500/20 text-purple-300" + : lower.includes("haiku") + ? "bg-blue-500/20 text-blue-300" + : lower.includes("sonnet") + ? "bg-emerald-500/20 text-emerald-300" + : "bg-[var(--bg-secondary)] text-[var(--text-muted)]"; return ( - + {label} - ) + ); } const COL_HEADERS = [ - { label: 'Project', align: 'text-left' }, - { label: 'Started', align: 'text-left' }, - { label: 'Duration', align: 'text-left' }, - { label: 'Messages', align: 'text-right' }, - { label: 'Tools', align: 'text-right' }, - { label: 'Tokens', align: 'text-right' }, - { label: 'Cost', align: 'text-right' }, - { label: 'Model', align: 'text-left' }, - { label: '', align: 'text-right' }, -] as const + { label: "Project", align: "text-left" }, + { label: "Started", align: "text-left" }, + { label: "Duration", align: "text-left" }, + { label: "Messages", align: "text-right" }, + { label: "Tools", align: "text-right" }, + { label: "Tokens", align: "text-right" }, + { label: "Cost", align: "text-right" }, + { label: "Model", align: "text-left" }, + { label: "", align: "text-right" }, +] as const; export default function SessionsView() { - const navigate = useNavigate() - const queryClient = useQueryClient() - const { data: sessions, isLoading, error } = useSessions(undefined, 500) - const parentRef = useRef(null) - const [searchQuery, setSearchQuery] = useState('') - const [projectFilter, setProjectFilter] = useState('') - const [deletingId, setDeletingId] = useState(null) - - const { data: compactedEvents } = useRoutingEventsByType('context_compacted', 500) + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { data: sessions, isLoading, error } = useSessions(undefined, 500); + const parentRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(""); + const [projectFilter, setProjectFilter] = useState(""); + const [deletingId, setDeletingId] = useState(null); + + const { data: compactedEvents } = useRoutingEventsByType( + "context_compacted", + 500, + ); const compactedSessionIds = useMemo(() => { - const ids = new Set() + const ids = new Set(); if (compactedEvents) { for (const ev of compactedEvents) { - if (ev.session_id) ids.add(ev.session_id) + if (ev.session_id) ids.add(ev.session_id); } } - return ids - }, [compactedEvents]) + return ids; + }, [compactedEvents]); async function handleDelete(e: React.MouseEvent, session: Session) { - e.preventDefault() - e.stopPropagation() - if (!window.confirm(`Delete session ${session.id.slice(0, 8)}…? This cannot be undone.`)) return - setDeletingId(session.id) + e.preventDefault(); + e.stopPropagation(); + if ( + !window.confirm( + `Delete session ${session.id.slice(0, 8)}…? This cannot be undone.`, + ) + ) + return; + setDeletingId(session.id); try { - const res = await fetch(`/api/sessions/${session.projectEncoded}/${session.id}`, { method: 'DELETE' }) - if (!res.ok) throw new Error('Delete failed') - queryClient.invalidateQueries({ queryKey: ['sessions'] }) + const res = await fetch( + `/api/sessions/${session.projectEncoded}/${session.id}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Delete failed"); + queryClient.invalidateQueries({ queryKey: ["sessions"] }); } catch { - alert('Failed to delete session') + alert("Failed to delete session"); } finally { - setDeletingId(null) + setDeletingId(null); } } const sorted = useMemo(() => { - if (!sessions) return [] + if (!sessions) return []; return [...sessions].sort( - (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime() - ) - }, [sessions]) + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ); + }, [sessions]); // Unique project names for filter dropdown const projects = useMemo(() => { - const names = new Set(sorted.map(s => extractProjectName(s.projectPath))) - return Array.from(names).sort() - }, [sorted]) + const names = new Set(sorted.map((s) => extractProjectName(s.projectPath))); + return Array.from(names).sort(); + }, [sorted]); // Apply filters const filtered = useMemo(() => { - let result = sorted + let result = sorted; if (projectFilter) { - result = result.filter(s => extractProjectName(s.projectPath) === projectFilter) + result = result.filter( + (s) => extractProjectName(s.projectPath) === projectFilter, + ); } if (searchQuery.length >= 2) { - const q = searchQuery.toLowerCase() - result = result.filter(s => - extractProjectName(s.projectPath).toLowerCase().includes(q) || - (s.slug?.toLowerCase().includes(q)) || - s.id.toLowerCase().includes(q) - ) + const q = searchQuery.toLowerCase(); + result = result.filter( + (s) => + extractProjectName(s.projectPath).toLowerCase().includes(q) || + s.slug?.toLowerCase().includes(q) || + s.id.toLowerCase().includes(q), + ); } - return result - }, [sorted, projectFilter, searchQuery]) + return result; + }, [sorted, projectFilter, searchQuery]); // Aggregate stats for filtered sessions - const totalTokens = filtered.reduce((sum, s) => sum + (s.inputTokens || 0) + (s.outputTokens || 0), 0) - const totalCost = filtered.reduce((sum, s) => sum + estimateCost( - s.inputTokens || 0, s.outputTokens || 0, s.cacheCreationTokens || 0, s.cacheReadTokens || 0, s.model || '' - ), 0) + const totalTokens = filtered.reduce( + (sum, s) => sum + (s.inputTokens || 0) + (s.outputTokens || 0), + 0, + ); + const totalCost = filtered.reduce( + (sum, s) => + sum + + estimateCost( + s.inputTokens || 0, + s.outputTokens || 0, + s.cacheCreationTokens || 0, + s.cacheReadTokens || 0, + s.model || "", + ), + 0, + ); const virtualizer = useVirtualizer({ count: filtered.length, getScrollElement: () => parentRef.current, estimateSize: () => 52, overscan: 10, - }) + }); return (
@@ -254,8 +332,8 @@ export default function SessionsView() {

Sessions

{isLoading - ? 'Loading sessions...' - : `${filtered.length} session${filtered.length !== 1 ? 's' : ''} · ${formatTokens(totalTokens)} tokens · ${formatCost(totalCost)}`} + ? "Loading sessions..." + : `${filtered.length} session${filtered.length !== 1 ? "s" : ""} · ${formatTokens(totalTokens)} tokens · ${formatCost(totalCost)}`}

@@ -274,20 +352,22 @@ export default function SessionsView() { type="text" placeholder="Search sessions..." value={searchQuery} - onChange={e => setSearchQuery(e.target.value)} + onChange={(e) => setSearchQuery(e.target.value)} aria-label="Search sessions" className="w-full pl-9 pr-4 py-2 rounded-xl bg-[var(--bg-secondary)] border border-[var(--border)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]/50 focus-visible:ring-1 focus-visible:ring-[var(--accent)] transition-colors" /> @@ -301,190 +381,257 @@ export default function SessionsView() { {/* Mobile card list — shown below md breakpoint */}
- {isLoading && ( + {isLoading && Array.from({ length: 4 }).map((_, i) => ( -
+
- )) - )} + ))} {!isLoading && filtered.length === 0 && ( -
- {searchQuery || projectFilter ? 'No matching sessions' : 'No sessions found'} +
+
+ {searchQuery || projectFilter ? ( +
No matching sessions
+ ) : ( + <> +
+ No sessions found +
+
+ Run claude to start a session — it will appear here + automatically. +
+
+ If you've already run one, check your CAST connection or + database configuration. +
+ + )} +
)} - {!isLoading && filtered.map((session) => { - const tokens = (session.inputTokens || 0) + (session.outputTokens || 0) - const cost = estimateCost( - session.inputTokens || 0, - session.outputTokens || 0, - session.cacheCreationTokens || 0, - session.cacheReadTokens || 0, - session.model || '' - ) - return ( -
navigate(`/sessions/${session.projectEncoded}/${session.id}`)} - role="button" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/sessions/${session.projectEncoded}/${session.id}`) }} - aria-label={`Session for ${extractProjectName(session.projectPath)}, started ${timeAgo(session.startedAt)}`} - className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl p-4 cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:outline-none" - > -
-
-
- {extractProjectName(session.projectPath)} + {!isLoading && + filtered.map((session) => { + const tokens = + (session.inputTokens || 0) + (session.outputTokens || 0); + const cost = estimateCost( + session.inputTokens || 0, + session.outputTokens || 0, + session.cacheCreationTokens || 0, + session.cacheReadTokens || 0, + session.model || "", + ); + return ( +
+ navigate(`/sessions/${session.projectEncoded}/${session.id}`) + } + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") + navigate( + `/sessions/${session.projectEncoded}/${session.id}`, + ); + }} + aria-label={`Session for ${extractProjectName(session.projectPath)}, started ${timeAgo(session.startedAt)}`} + className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-xl p-4 cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:outline-none" + > +
+
+
+ {extractProjectName(session.projectPath)} +
+ {compactedSessionIds.has(session.id) && ( + + Compacted + + )} +
+
+ +
- {compactedSessionIds.has(session.id) && ( - - Compacted - - )}
-
- - +
+ {timeAgo(session.startedAt)} + {session.durationMs != null && ( + {formatDuration(session.durationMs)} + )} + + {tokens > 0 ? formatTokens(tokens) : "--"} tokens + + {cost > 0 ? formatCost(cost) : "--"}
-
- {timeAgo(session.startedAt)} - {session.durationMs != null && {formatDuration(session.durationMs)}} - {tokens > 0 ? formatTokens(tokens) : '--'} tokens - {cost > 0 ? formatCost(cost) : '--'} -
-
- ) - })} + ); + })}
{/* Desktop table — shown at md+ */}
{/* Scrollable wrapper for narrow viewports */}
- {/* Sticky header row */} -
- {COL_HEADERS.map(({ label, align }) => ( -
- {label} -
- ))} -
- - {/* Scrollable virtualized body */} -
- {/* Loading skeleton */} - {isLoading && ( - - - {Array.from({ length: 8 }).map((_, i) => )} - -
- )} + {/* Sticky header row */} +
+ {COL_HEADERS.map(({ label, align }) => ( +
+ {label} +
+ ))} +
- {/* Empty state */} - {!isLoading && filtered.length === 0 && ( -
- {searchQuery || projectFilter ? 'No matching sessions' : 'No sessions found'} + {/* Scrollable virtualized body */} +
+ {/* Loading skeleton */} + {isLoading && ( + + + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + +
+ )} + + {/* Empty state */} +
+ {searchQuery || projectFilter ? ( +
No matching sessions
+ ) : ( + <> +
+ No sessions found +
+
+ Run claude to start a session — it will appear here + automatically. +
+
+ If you've already run one, check your CAST connection or + database configuration. +
+ + )}
- )} - {/* Virtual rows */} - {!isLoading && filtered.length > 0 && ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const session = filtered[virtualRow.index] - const tokens = (session.inputTokens || 0) + (session.outputTokens || 0) - const cost = estimateCost( - session.inputTokens || 0, - session.outputTokens || 0, - session.cacheCreationTokens || 0, - session.cacheReadTokens || 0, - session.model || '' - ) - return ( -
navigate(`/sessions/${session.projectEncoded}/${session.id}`)} - role="row" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/sessions/${session.projectEncoded}/${session.id}`) }} - aria-label={`Session: ${extractProjectName(session.projectPath)}, ${timeAgo(session.startedAt)}`} - className="grid grid-cols-9 border-b border-[var(--border)] hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer text-sm focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)] focus-visible:outline-none" - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: `${virtualRow.size}px`, - transform: `translateY(${virtualRow.start}px)`, - }} - > -
- {extractProjectName(session.projectPath)} - {compactedSessionIds.has(session.id) && ( - - Compacted + {/* Virtual rows */} + {!isLoading && filtered.length > 0 && ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const session = filtered[virtualRow.index]; + const tokens = + (session.inputTokens || 0) + (session.outputTokens || 0); + const cost = estimateCost( + session.inputTokens || 0, + session.outputTokens || 0, + session.cacheCreationTokens || 0, + session.cacheReadTokens || 0, + session.model || "", + ); + return ( +
+ navigate( + `/sessions/${session.projectEncoded}/${session.id}`, + ) + } + role="row" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") + navigate( + `/sessions/${session.projectEncoded}/${session.id}`, + ); + }} + aria-label={`Session: ${extractProjectName(session.projectPath)}, ${timeAgo(session.startedAt)}`} + className="grid grid-cols-9 border-b border-[var(--border)] hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer text-sm focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)] focus-visible:outline-none" + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: `${virtualRow.size}px`, + transform: `translateY(${virtualRow.start}px)`, + }} + > +
+ + {extractProjectName(session.projectPath)} - )} -
-
- {timeAgo(session.startedAt)} -
-
- {session.durationMs != null ? formatDuration(session.durationMs) : '--'} -
-
- {session.messageCount} -
-
- {session.toolCallCount} -
-
- {tokens > 0 ? formatTokens(tokens) : '--'} -
-
- {cost > 0 ? formatCost(cost) : '--'} + {compactedSessionIds.has(session.id) && ( + + Compacted + + )} +
+
+ {timeAgo(session.startedAt)} +
+
+ {session.durationMs != null + ? formatDuration(session.durationMs) + : "--"} +
+
+ {session.messageCount} +
+
+ {session.toolCallCount} +
+
+ {tokens > 0 ? formatTokens(tokens) : "--"} +
+
+ {cost > 0 ? formatCost(cost) : "--"} +
+
+ +
+
+ +
-
- -
-
- -
-
- ) - })} -
- )} + ); + })} +
+ )} +
-
{/* end overflow-x-auto */} + {/* end overflow-x-auto */}
- ) + ); }