/** * ContextPanel — Cumulative state display (Story 5.5) * * Features: * - Resizable right panel (default 40% width, drag to resize) * - Tabbed interface: Summary, LLM History, Tool Results, Approvals * - Collapses to thin bar with expand button * - Updates instantly when step changes (all data computed from events) */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AgentLensEvent, LlmCallPayload, LlmResponsePayload, LlmMessage, ToolCallPayload, ToolResponsePayload, ToolErrorPayload, ApprovalRequestedPayload, ApprovalDecisionPayload, CostTrackedPayload, } from '@agentkitai/agentlens-core'; import { useBookmarks } from './BookmarkProvider'; // ─── Types ────────────────────────────────────────────────────────── export interface ContextPanelProps { events: AgentLensEvent[]; currentStep: number; sessionStartTime: string; /** [F11-S4] Callback to jump to a specific step (used by bookmarks tab) */ onStepChange?: (step: number) => void; } type TabKey = 'summary' | 'llm' | 'tools' | 'approvals' | 'bookmarks'; interface TabDef { key: TabKey; label: string; icon: string; } const TABS: TabDef[] = [ { key: 'summary', label: 'Summary', icon: '📊' }, { key: 'llm', label: 'LLM History', icon: '🧠' }, { key: 'tools', label: 'Tool Results', icon: '🔧' }, { key: 'approvals', label: 'Approvals', icon: '🔐' }, { key: 'bookmarks', label: 'Bookmarks', icon: '⭐' }, ]; // ─── Cumulative context computation ───────────────────────────────── interface CumulativeContext { eventCounts: Record; cumulativeCost: number; elapsedMs: number; errorCount: number; warningCount: number; warnings: string[]; llmHistory: LlmHistoryEntry[]; toolResults: ToolResultEntry[]; approvals: ApprovalEntry[]; } interface LlmHistoryEntry { callId: string; provider: string; model: string; messages: LlmMessage[]; response?: string | null; toolCalls?: Array<{ id: string; name: string; arguments: Record }>; inputTokens: number; outputTokens: number; totalTokens: number; costUsd: number; latencyMs: number; redacted: boolean; } interface ToolResultEntry { callId: string; toolName: string; arguments: Record; result?: unknown; error?: string; durationMs?: number; completed: boolean; } interface ApprovalEntry { requestId: string; action: string; status: 'pending' | 'granted' | 'denied' | 'expired'; urgency?: string; decidedBy?: string; reason?: string; } function computeContext( events: AgentLensEvent[], currentStep: number, sessionStartTime: string, ): CumulativeContext { const ctx: CumulativeContext = { eventCounts: {}, cumulativeCost: 0, elapsedMs: 0, errorCount: 0, warningCount: 0, warnings: [], llmHistory: [], toolResults: [], approvals: [], }; const llmCallMap = new Map(); const toolCallMap = new Map(); const approvalMap = new Map(); const startMs = new Date(sessionStartTime).getTime(); const upTo = Math.min(currentStep + 1, events.length); for (let i = 0; i < upTo; i++) { const ev = events[i]; const p = ev.payload as Record; // Event counts ctx.eventCounts[ev.eventType] = (ctx.eventCounts[ev.eventType] || 0) + 1; // Elapsed time ctx.elapsedMs = new Date(ev.timestamp).getTime() - startMs; // Error count if ( ev.severity === 'error' || ev.severity === 'critical' || ev.eventType === 'tool_error' || ev.eventType === 'alert_triggered' ) { ctx.errorCount++; } // Warnings if (ev.severity === 'warn') { ctx.warningCount++; ctx.warnings.push(`${ev.eventType}: ${getWarningText(ev)}`); } // Cost tracking if (ev.eventType === 'cost_tracked') { const costP = p as unknown as CostTrackedPayload; ctx.cumulativeCost += costP.costUsd; } // LLM history if (ev.eventType === 'llm_call') { const llmP = p as unknown as LlmCallPayload; const entry: LlmHistoryEntry = { callId: llmP.callId, provider: llmP.provider, model: llmP.model, messages: llmP.messages, inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0, latencyMs: 0, redacted: llmP.redacted ?? false, }; llmCallMap.set(llmP.callId, entry); ctx.llmHistory.push(entry); } if (ev.eventType === 'llm_response') { const llmR = p as unknown as LlmResponsePayload; const entry = llmCallMap.get(llmR.callId); if (entry) { entry.response = llmR.completion; entry.toolCalls = llmR.toolCalls; entry.inputTokens = llmR.usage.inputTokens; entry.outputTokens = llmR.usage.outputTokens; entry.totalTokens = llmR.usage.totalTokens; entry.costUsd = llmR.costUsd; entry.latencyMs = llmR.latencyMs; if (llmR.redacted) entry.redacted = true; } // Also accumulate cost from llm_response ctx.cumulativeCost += llmR.costUsd; } // Tool results if (ev.eventType === 'tool_call') { const toolP = p as unknown as ToolCallPayload; const entry: ToolResultEntry = { callId: toolP.callId, toolName: toolP.toolName, arguments: toolP.arguments, completed: false, }; toolCallMap.set(toolP.callId, entry); ctx.toolResults.push(entry); } if (ev.eventType === 'tool_response') { const toolR = p as unknown as ToolResponsePayload; const entry = toolCallMap.get(toolR.callId); if (entry) { entry.result = toolR.result; entry.durationMs = toolR.durationMs; entry.completed = true; } } if (ev.eventType === 'tool_error') { const toolE = p as unknown as ToolErrorPayload; const entry = toolCallMap.get(toolE.callId); if (entry) { entry.error = toolE.error; entry.durationMs = toolE.durationMs; entry.completed = true; } } // Approvals if (ev.eventType === 'approval_requested') { const apP = p as unknown as ApprovalRequestedPayload; const entry: ApprovalEntry = { requestId: apP.requestId, action: apP.action, status: 'pending', urgency: apP.urgency, }; approvalMap.set(apP.requestId, entry); ctx.approvals.push(entry); } if (ev.eventType === 'approval_granted') { const apD = p as unknown as ApprovalDecisionPayload; const entry = approvalMap.get(apD.requestId); if (entry) { entry.status = 'granted'; entry.decidedBy = apD.decidedBy; entry.reason = apD.reason; } } if (ev.eventType === 'approval_denied') { const apD = p as unknown as ApprovalDecisionPayload; const entry = approvalMap.get(apD.requestId); if (entry) { entry.status = 'denied'; entry.decidedBy = apD.decidedBy; entry.reason = apD.reason; } } if (ev.eventType === 'approval_expired') { const apD = p as unknown as ApprovalDecisionPayload; const entry = approvalMap.get(apD.requestId); if (entry) { entry.status = 'expired'; } } } return ctx; } function getWarningText(ev: AgentLensEvent): string { const p = ev.payload as Record; if (typeof p.message === 'string') return p.message; if (typeof p.error === 'string') return p.error; return ev.eventType; } // ─── Duration formatting ──────────────────────────────────────────── function formatDurationMs(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; const mins = Math.floor(ms / 60000); const secs = Math.floor((ms % 60000) / 1000); if (mins < 60) return `${mins}m ${secs}s`; return `${Math.floor(mins / 60)}h ${mins % 60}m`; } function summarizeArgs(args: Record): string { const keys = Object.keys(args); if (keys.length === 0) return '—'; if (keys.length <= 2) { return keys.map(k => { const v = args[k]; const s = typeof v === 'string' ? (v.length > 40 ? v.slice(0, 40) + '…' : v) : JSON.stringify(v)?.slice(0, 40) ?? ''; return `${k}: ${s}`; }).join(', '); } return `${keys.length} args`; } // ─── Summary Tab ──────────────────────────────────────────────────── function SummaryTab({ ctx }: { ctx: CumulativeContext }): React.ReactElement { const sortedCounts = Object.entries(ctx.eventCounts) .sort(([, a], [, b]) => b - a); return (
{/* Key metrics */}
0} />
{/* Event counts by type */}

Event Counts

{sortedCounts.map(([type, count]) => (
{type.replace(/_/g, ' ')} {count}
))}
{/* Warnings list */} {ctx.warnings.length > 0 && (

Warnings

{ctx.warnings.slice(-10).map((w, i) => (
{w}
))}
)}
); } function MetricCard({ icon, label, value, alert }: { icon: string; label: string; value: string | number; alert?: boolean; }): React.ReactElement { return (
{icon} {label}
{value}
); } // ─── LLM History Tab ──────────────────────────────────────────────── function LlmHistoryTab({ entries }: { entries: LlmHistoryEntry[] }): React.ReactElement { if (entries.length === 0) { return (
🧠

No LLM calls yet

); } return (
{entries.map((entry, idx) => ( ))}
); } function LlmHistoryEntry({ entry, index }: { entry: LlmHistoryEntry; index: number }): React.ReactElement { const [expanded, setExpanded] = useState(false); return (
#{index + 1} {entry.model} {entry.redacted && ( REDACTED )}
${entry.costUsd.toFixed(4)}
{entry.totalTokens.toLocaleString()} tokens ({entry.inputTokens.toLocaleString()}↑ {entry.outputTokens.toLocaleString()}↓) {entry.latencyMs > 0 && {formatDurationMs(entry.latencyMs)}}
{expanded && (
{entry.redacted ? (
[REDACTED] — Content was redacted for privacy
) : ( <> {entry.messages.map((msg, mi) => ( ))} {entry.response !== undefined && entry.response !== null && ( )} {entry.toolCalls && entry.toolCalls.length > 0 && (
Tool calls: {entry.toolCalls.map(tc => tc.name).join(', ')}
)} )}
)}
); } const ROLE_COLORS: Record = { system: 'bg-gray-50 border-gray-200 text-gray-600', user: 'bg-blue-50 border-blue-200 text-blue-800', assistant: 'bg-green-50 border-green-200 text-green-800', tool: 'bg-purple-50 border-purple-200 text-purple-800', }; function MessageBubble({ message }: { message: LlmMessage }): React.ReactElement { const colorClass = ROLE_COLORS[message.role] ?? ROLE_COLORS.system; const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2); return (
{message.role}
{content}
); } // ─── Tool Results Tab ─────────────────────────────────────────────── function ToolResultsTab({ entries }: { entries: ToolResultEntry[] }): React.ReactElement { if (entries.length === 0) { return (
🔧

No tool calls yet

); } return (
{entries.map((entry, idx) => ( ))}
); } function ToolResultRow({ entry }: { entry: ToolResultEntry }): React.ReactElement { const [expanded, setExpanded] = useState(false); return (
{entry.toolName}
{entry.durationMs !== undefined && ( {formatDurationMs(entry.durationMs)} )} {!entry.completed && ( pending )}
{summarizeArgs(entry.arguments)}
{entry.error && (
{entry.error}
)} {expanded && (
Arguments
              {JSON.stringify(entry.arguments, null, 2)}
            
{entry.result !== undefined && (
Result
                {typeof entry.result === 'string' ? entry.result : JSON.stringify(entry.result, null, 2)}
              
)}
)}
); } // ─── Approvals Tab ────────────────────────────────────────────────── const APPROVAL_STATUS_STYLES: Record = { pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending' }, granted: { bg: 'bg-green-100', text: 'text-green-800', label: 'Granted' }, denied: { bg: 'bg-red-100', text: 'text-red-800', label: 'Denied' }, expired: { bg: 'bg-gray-100', text: 'text-gray-600', label: 'Expired' }, }; function ApprovalsTab({ entries }: { entries: ApprovalEntry[] }): React.ReactElement { if (entries.length === 0) { return (
🔐

No approval requests

); } return (
{entries.map((entry, idx) => { const statusStyle = APPROVAL_STATUS_STYLES[entry.status] ?? APPROVAL_STATUS_STYLES.pending; return (
{entry.action} {statusStyle.label}
{entry.urgency && (
Urgency: {entry.urgency}
)} {entry.decidedBy && (
By: {entry.decidedBy}
)} {entry.reason && (
Reason: {entry.reason}
)}
); })}
); } // ─── [F11-S4] Bookmarks Tab ───────────────────────────────────────── function BookmarksTab({ events, onStepChange, }: { events: AgentLensEvent[]; onStepChange?: (step: number) => void; }): React.ReactElement { const { bookmarks, clear } = useBookmarks(); const sorted = useMemo(() => [...bookmarks].sort((a, b) => a - b), [bookmarks]); if (sorted.length === 0) { return (

No bookmarks yet

Click ☆ on any event to bookmark it

); } return (
{sorted.length} bookmark{sorted.length !== 1 ? 's' : ''}
{sorted.map((stepIdx) => { const ev = events[stepIdx]; if (!ev) return null; const p = ev.payload as Record; const summary = (p.toolName as string) ?? (p.model as string) ?? ev.eventType.replace(/_/g, ' '); return ( ); })}
); } // ─── Main ContextPanel Component ──────────────────────────────────── const MIN_PANEL_WIDTH = 200; const MAX_PANEL_RATIO = 0.7; const COLLAPSED_WIDTH = 36; export function ContextPanel({ events, currentStep, sessionStartTime, onStepChange, }: ContextPanelProps): React.ReactElement { const [activeTab, setActiveTab] = useState('summary'); const [collapsed, setCollapsed] = useState(false); const [panelWidth, setPanelWidth] = useState(null); // null = use default 40% const panelRef = useRef(null); const isDragging = useRef(false); const dragStartX = useRef(0); const dragStartWidth = useRef(0); // Compute cumulative context up to current step const ctx = useMemo( () => computeContext(events, currentStep, sessionStartTime), [events, currentStep, sessionStartTime], ); // ── Resize handling ─────────────────────────────────────────── const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); isDragging.current = true; dragStartX.current = e.clientX; dragStartWidth.current = panelRef.current?.offsetWidth ?? 400; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, []); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isDragging.current) return; const delta = dragStartX.current - e.clientX; // dragging left = increase width const parentWidth = panelRef.current?.parentElement?.offsetWidth ?? 1000; const maxWidth = parentWidth * MAX_PANEL_RATIO; const newWidth = Math.max(MIN_PANEL_WIDTH, Math.min(maxWidth, dragStartWidth.current + delta)); setPanelWidth(newWidth); }; const handleMouseUp = () => { if (isDragging.current) { isDragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; } }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, []); // ── Collapsed state ─────────────────────────────────────────── if (collapsed) { return (
setCollapsed(false)} title="Expand context panel" > Context
); } // ── Panel width style ───────────────────────────────────────── const widthStyle = panelWidth ? { width: panelWidth } : { width: '40%' }; return (
{/* Resize handle */}
{/* Header with tabs + collapse button */}
{/* Collapse button */} {/* Tabs */}
{TABS.map(tab => ( ))}
{/* Tab content */}
{activeTab === 'summary' && } {activeTab === 'llm' && } {activeTab === 'tools' && } {activeTab === 'approvals' && } {activeTab === 'bookmarks' && }
); }