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 */}
- )
+ );
}