Phase 1: Trace System + Observability - tool_traces DB table + insert/update service - tool_trace_start/tool_trace_finish WS frames (contracts + FE types) - Instrumented tool-phase.ts with timing around every tool call - GET /api/chats/:id/traces paginated endpoint - Trace viewer frontend (collapsible panel with timing bars + token breakdown) Phase 2: Session Persistence + Resume - agent_snapshots table (UPSERT per chat, persisted on turn boundaries) - save/load/delete service functions - Agent snapshot sent on WS reconnect - Session timeline view (vertical timeline with scroll-to + restore) Tooling: - run_command tool (execFile, 30s timeout, 32KB cap, path-guarded) - Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
189 lines
8.0 KiB
TypeScript
189 lines
8.0 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Clock, Cpu, Hash, Layers, RefreshCw, X } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
import type { Message } from '@/api/types';
|
|
|
|
interface TurnEntry {
|
|
message: Message;
|
|
turnNumber: number;
|
|
elapsed: string;
|
|
toolCallCount: number;
|
|
}
|
|
|
|
interface Props {
|
|
messages: Message[];
|
|
chatId: string;
|
|
onClose: () => void;
|
|
onScrollToMessage: (messageId: string) => void;
|
|
}
|
|
|
|
function formatElapsed(startedAt: string | null, finishedAt: string | null): string {
|
|
if (!startedAt || !finishedAt) return '—';
|
|
const start = new Date(startedAt).getTime();
|
|
const end = new Date(finishedAt).getTime();
|
|
if (Number.isNaN(start) || Number.isNaN(end)) return '—';
|
|
const ms = end - start;
|
|
if (ms < 0) return '—';
|
|
if (ms < 1000) return `${ms}ms`;
|
|
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
const mins = Math.floor(ms / 60_000);
|
|
const secs = Math.round((ms % 60_000) / 1000);
|
|
return `${mins}m ${secs}s`;
|
|
}
|
|
|
|
/**
|
|
* SessionTimeline — vertical timeline of assistant turns in a chat.
|
|
*
|
|
* Renders a side-panel overlay with each turn's model, tokens, duration,
|
|
* and tool-call count. Clicking a turn scrolls the main chat to that
|
|
* message. The latest turn shows a "Scroll to latest" restore button.
|
|
*/
|
|
export function SessionTimeline({ messages, onClose, onScrollToMessage }: Props) {
|
|
const turns = useMemo<TurnEntry[]>(() => {
|
|
const assistantMsgs = messages.filter(
|
|
(m) => m.role === 'assistant' && m.status === 'complete',
|
|
);
|
|
return assistantMsgs.map((message, i) => ({
|
|
message,
|
|
turnNumber: i + 1,
|
|
elapsed: formatElapsed(message.started_at, message.finished_at),
|
|
toolCallCount: message.tool_calls?.length ?? 0,
|
|
}));
|
|
}, [messages]);
|
|
|
|
const latestTurnId = turns.length > 0 ? turns[turns.length - 1]!.message.id : null;
|
|
|
|
return (
|
|
<div className="absolute inset-y-0 right-0 w-80 z-20 bg-background border-l border-border shadow-xl flex flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border shrink-0">
|
|
<h3 className="text-sm font-semibold">Session Timeline</h3>
|
|
<Button variant="ghost" size="icon-xs" onClick={onClose} aria-label="Close timeline">
|
|
<X size={14} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Timeline entries */}
|
|
<div className="flex-1 overflow-y-auto px-3 py-3">
|
|
{turns.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground text-center py-8">
|
|
No assistant turns yet.
|
|
</div>
|
|
) : (
|
|
<div className="relative">
|
|
{turns.map((turn, i) => {
|
|
const isLatest = turn.message.id === latestTurnId;
|
|
return (
|
|
<div key={turn.message.id} className="relative flex gap-3 pb-4 last:pb-0">
|
|
{/* Vertical connector line */}
|
|
{i < turns.length - 1 && (
|
|
<div className="absolute left-[11px] top-5 bottom-0 w-px bg-border" />
|
|
)}
|
|
|
|
{/* Timeline dot button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onScrollToMessage(turn.message.id)}
|
|
className="relative flex-shrink-0 mt-1 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-full"
|
|
aria-label={`Scroll to turn ${turn.turnNumber}`}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'size-[22px] rounded-full border-2 flex items-center justify-center',
|
|
isLatest
|
|
? 'border-primary bg-primary/10'
|
|
: 'border-muted-foreground/30 bg-background',
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'size-2 rounded-full',
|
|
isLatest ? 'bg-primary' : 'bg-muted-foreground/50',
|
|
)}
|
|
/>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Content card */}
|
|
<div className="flex-1 min-w-0">
|
|
<div
|
|
className="rounded-lg border border-border bg-card p-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
|
|
onClick={() => onScrollToMessage(turn.message.id)}
|
|
>
|
|
{/* Turn number + latest badge */}
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-foreground">
|
|
Turn {turn.turnNumber}
|
|
</span>
|
|
{isLatest && (
|
|
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full leading-none">
|
|
Latest
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Model name */}
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
|
|
<Cpu size={11} className="shrink-0" />
|
|
<span className="truncate">{turn.message.model ?? 'Unknown model'}</span>
|
|
</div>
|
|
|
|
{/* Token count with breakdown */}
|
|
{turn.message.tokens_used != null && (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1 flex-wrap">
|
|
<Hash size={11} className="shrink-0" />
|
|
<span>{turn.message.tokens_used.toLocaleString()} total</span>
|
|
{turn.message.cache_tokens != null && turn.message.cache_tokens > 0 && (
|
|
<span className="text-blue-500 dark:text-blue-400">
|
|
({turn.message.cache_tokens.toLocaleString()} cache)
|
|
</span>
|
|
)}
|
|
{turn.message.reasoning_tokens != null && turn.message.reasoning_tokens > 0 && (
|
|
<span className="text-amber-500 dark:text-amber-400">
|
|
({turn.message.reasoning_tokens.toLocaleString()} reasoning)
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Duration + tool calls */}
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
<span className="inline-flex items-center gap-1">
|
|
<Clock size={11} />
|
|
{turn.elapsed}
|
|
</span>
|
|
{turn.toolCallCount > 0 && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Layers size={11} />
|
|
{turn.toolCallCount} tool call{turn.toolCallCount !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Restore button for latest turn */}
|
|
{isLatest && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onScrollToMessage(turn.message.id);
|
|
}}
|
|
className="mt-1.5 w-full inline-flex items-center justify-center gap-1 text-[11px] font-medium text-primary hover:text-primary/80 transition-colors py-1 rounded-md hover:bg-primary/5"
|
|
>
|
|
<RefreshCw size={11} />
|
|
Scroll to latest
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|