add event debugging

This commit is contained in:
Fredrik Jensen 2025-12-07 12:12:49 +01:00
parent 2e52837bdb
commit 212bf1c705
4 changed files with 270 additions and 13 deletions

View File

@ -28,6 +28,8 @@ export type ToolResult = {
htmlContent: string | null htmlContent: string | null
isError: boolean isError: boolean
timestamp: Date timestamp: Date
executionTime: number // milliseconds
bundleSize: number | null // bytes, null if no UI
} }
function App() { function App() {
@ -79,7 +81,9 @@ function App() {
textContent: err instanceof Error ? err.message : 'Unknown error', textContent: err instanceof Error ? err.message : 'Unknown error',
htmlContent: null, htmlContent: null,
isError: true, isError: true,
timestamp: new Date() timestamp: new Date(),
executionTime: 0,
bundleSize: null
}) })
} finally { } finally {
setIsExecuting(false) setIsExecuting(false)

View File

@ -34,7 +34,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 12px 16px; padding: 12px 12px;
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
@ -57,7 +57,7 @@
display: inline-block; display: inline-block;
width: 5px; width: 5px;
height: 5px; height: 5px;
margin-left: 5px; margin-left: 2px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.5);
} }
@ -80,6 +80,37 @@
color: var(--text-muted); color: var(--text-muted);
} }
.metrics {
display: flex;
align-items: center;
gap: 12px;
}
.metric {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
font-weight: 400;
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
margin-left: 6px;
font-size: 10px;
background: var(--accent-blue);
color: var(--white);
border-radius: 9px;
}
.icon-button { .icon-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -188,6 +219,100 @@
word-break: break-word; word-break: break-word;
} }
/* Events Panel */
.events-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.events-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.events-empty p {
font-size: 14px;
}
.events-empty span {
font-size: 12px;
color: var(--text-muted);
}
.events-list {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.event-item {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.event-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.event-type {
font-size: 12px;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
color: var(--text-primary);
}
.event-time {
font-size: 10px;
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
}
.event-payload {
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
/* Event type colors */
.event-message .event-type {
color: var(--accent-blue);
}
.event-tool .event-type {
color: var(--accent-purple, #a855f7);
}
.event-resize .event-type {
color: var(--accent-green, #22c55e);
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.results-pane { .results-pane {
min-width: 0; min-width: 0;
@ -220,7 +345,8 @@
gap: 8px; gap: 8px;
} }
.timestamp { .timestamp,
.metrics {
display: none; display: none;
} }
@ -242,4 +368,18 @@
.text-output pre { .text-output pre {
font-size: 12px; font-size: 12px;
} }
.events-list {
padding: 8px;
gap: 6px;
}
.event-header {
padding: 6px 10px;
}
.event-payload {
padding: 8px 10px;
font-size: 10px;
}
} }

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { Monitor, FileText, Clock, AlertTriangle, Maximize2, Minimize2, RotateCw } from 'lucide-react' import { Monitor, FileText, AlertTriangle, Maximize2, Minimize2, RotateCw, Radio, Trash2, Timer, Package } from 'lucide-react'
import { Button } from './Button' import { Button } from './Button'
import type { ToolResult } from '../App' import type { ToolResult } from '../App'
import './ResultsPane.css' import './ResultsPane.css'
@ -10,15 +10,61 @@ interface ResultsPaneProps {
onReload?: () => void onReload?: () => void
} }
type Tab = 'ui' | 'text' type Tab = 'ui' | 'text' | 'events'
interface IframeEvent {
id: number
timestamp: Date
type: string
payload: unknown
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}b`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}kb`
return `${(bytes / (1024 * 1024)).toFixed(1)}mb`
}
export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps) { export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps) {
const [activeTab, setActiveTab] = useState<Tab>('ui') const [activeTab, setActiveTab] = useState<Tab>('ui')
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
const [events, setEvents] = useState<IframeEvent[]>([])
const eventIdRef = useRef(0)
const iframeRef = useRef<HTMLIFrameElement>(null)
const hasUI = result?.htmlContent != null const hasUI = result?.htmlContent != null
const hasText = result?.textContent != null const hasText = result?.textContent != null
// Clear events when result changes (new tool execution)
useEffect(() => {
setEvents([])
eventIdRef.current = 0
}, [result])
// Listen to postMessage events from the iframe
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Only capture messages from our iframe
if (iframeRef.current && event.source === iframeRef.current.contentWindow) {
const newEvent: IframeEvent = {
id: ++eventIdRef.current,
timestamp: new Date(),
type: event.data?.type || 'unknown',
payload: event.data?.payload || event.data
}
setEvents(prev => [...prev, newEvent])
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
const clearEvents = useCallback(() => {
setEvents([])
eventIdRef.current = 0
}, [])
useEffect(() => { useEffect(() => {
if (hasUI) { if (hasUI) {
setActiveTab('ui') setActiveTab('ui')
@ -47,14 +93,37 @@ export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps)
Text Text
<span className={`tab-badge ${hasText ? 'active' : ''}`} /> <span className={`tab-badge ${hasText ? 'active' : ''}`} />
</button> </button>
<button
className={`tab ${activeTab === 'events' ? 'active' : ''}`}
onClick={() => setActiveTab('events')}
>
Events
{events.length > 0 && <span className="tab-count">{events.length}</span>}
</button>
</div> </div>
<div className="results-actions"> <div className="results-actions">
{result?.timestamp && ( {result && !result.isError && (
<span className="timestamp"> <div className="metrics">
<Clock size={12} /> <span className="metric" title="Execution time">
{result.timestamp.toLocaleTimeString()} <Timer size={12} />
{result.executionTime}ms
</span> </span>
{result.bundleSize && (
<span className="metric" title="Bundle size">
<Package size={12} />
{formatBytes(result.bundleSize)}
</span>
)}
</div>
)}
{activeTab === 'events' && events.length > 0 && (
<Button
variant="ghost"
onClick={clearEvents}
title="Clear events"
icon={<Trash2 size={14} />}
/>
)} )}
{onReload && ( {onReload && (
<Button <Button
@ -94,6 +163,7 @@ export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps)
) : activeTab === 'ui' ? ( ) : activeTab === 'ui' ? (
hasUI ? ( hasUI ? (
<iframe <iframe
ref={iframeRef}
className="ui-frame" className="ui-frame"
srcDoc={result.htmlContent!} srcDoc={result.htmlContent!}
title="Tool UI Output" title="Tool UI Output"
@ -106,10 +176,40 @@ export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps)
<span>This tool may only return text content</span> <span>This tool may only return text content</span>
</div> </div>
) )
) : ( ) : activeTab === 'text' ? (
<div className="text-output"> <div className="text-output">
<pre>{result.textContent || 'No text content'}</pre> <pre>{result.textContent || 'No text content'}</pre>
</div> </div>
) : (
<div className="events-panel">
{events.length === 0 ? (
<div className="events-empty">
<Radio size={32} strokeWidth={1} />
<p>No events yet</p>
<span>Events from the UI (sendPrompt, callTool, resize) will appear here</span>
</div>
) : (
<div className="events-list">
{events.map(event => (
<div key={event.id} className={`event-item event-${event.type}`}>
<div className="event-header">
<span className="event-type">{event.type}</span>
<span className="event-time">
{event.timestamp.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}.{event.timestamp.getMilliseconds().toString().padStart(3, '0')}
</span>
</div>
<pre className="event-payload">
{JSON.stringify(event.payload, null, 2)}
</pre>
</div>
))}
</div>
)}
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -16,6 +16,8 @@ interface UseMCPReturn {
textContent: string textContent: string
htmlContent: string | null htmlContent: string | null
isError: boolean isError: boolean
executionTime: number
bundleSize: number | null
}> }>
} }
@ -195,6 +197,8 @@ export function useMCP(): UseMCPReturn {
throw new Error('Not connected') throw new Error('Not connected')
} }
const startTime = performance.now()
// Build headers - only include session ID if present (stateful server) // Build headers - only include session ID if present (stateful server)
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -216,6 +220,8 @@ export function useMCP(): UseMCPReturn {
}) })
const text = await response.text() const text = await response.text()
const executionTime = Math.round(performance.now() - startTime)
const result = parseSSE(text) as { const result = parseSSE(text) as {
result?: { result?: {
content: Array<{ content: Array<{
@ -232,6 +238,8 @@ export function useMCP(): UseMCPReturn {
textContent: result.error.message, textContent: result.error.message,
htmlContent: null, htmlContent: null,
isError: true, isError: true,
executionTime,
bundleSize: null,
} }
} }
@ -247,10 +255,15 @@ export function useMCP(): UseMCPReturn {
} }
} }
// Calculate bundle size (in bytes) if there's HTML content
const bundleSize = htmlContent ? new Blob([htmlContent]).size : null
return { return {
textContent, textContent,
htmlContent, htmlContent,
isError: false, isError: false,
executionTime,
bundleSize,
} }
}, [isConnected, sessionId]) }, [isConnected, sessionId])