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
isError: boolean
timestamp: Date
executionTime: number // milliseconds
bundleSize: number | null // bytes, null if no UI
}
function App() {
@ -79,7 +81,9 @@ function App() {
textContent: err instanceof Error ? err.message : 'Unknown error',
htmlContent: null,
isError: true,
timestamp: new Date()
timestamp: new Date(),
executionTime: 0,
bundleSize: null
})
} finally {
setIsExecuting(false)

View File

@ -34,7 +34,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
padding: 12px 12px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
@ -57,7 +57,7 @@
display: inline-block;
width: 5px;
height: 5px;
margin-left: 5px;
margin-left: 2px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
}
@ -80,6 +80,37 @@
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 {
display: inline-flex;
align-items: center;
@ -188,6 +219,100 @@
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) {
.results-pane {
min-width: 0;
@ -220,7 +345,8 @@
gap: 8px;
}
.timestamp {
.timestamp,
.metrics {
display: none;
}
@ -242,4 +368,18 @@
.text-output pre {
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 { Monitor, FileText, Clock, AlertTriangle, Maximize2, Minimize2, RotateCw } from 'lucide-react'
import { useEffect, useState, useRef, useCallback } from 'react'
import { Monitor, FileText, AlertTriangle, Maximize2, Minimize2, RotateCw, Radio, Trash2, Timer, Package } from 'lucide-react'
import { Button } from './Button'
import type { ToolResult } from '../App'
import './ResultsPane.css'
@ -10,15 +10,61 @@ interface ResultsPaneProps {
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) {
const [activeTab, setActiveTab] = useState<Tab>('ui')
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 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(() => {
if (hasUI) {
setActiveTab('ui')
@ -47,14 +93,37 @@ export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps)
Text
<span className={`tab-badge ${hasText ? 'active' : ''}`} />
</button>
<button
className={`tab ${activeTab === 'events' ? 'active' : ''}`}
onClick={() => setActiveTab('events')}
>
Events
{events.length > 0 && <span className="tab-count">{events.length}</span>}
</button>
</div>
<div className="results-actions">
{result?.timestamp && (
<span className="timestamp">
<Clock size={12} />
{result.timestamp.toLocaleTimeString()}
{result && !result.isError && (
<div className="metrics">
<span className="metric" title="Execution time">
<Timer size={12} />
{result.executionTime}ms
</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 && (
<Button
@ -94,6 +163,7 @@ export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps)
) : activeTab === 'ui' ? (
hasUI ? (
<iframe
ref={iframeRef}
className="ui-frame"
srcDoc={result.htmlContent!}
title="Tool UI Output"
@ -106,10 +176,40 @@ export function ResultsPane({ result, isExecuting, onReload }: ResultsPaneProps)
<span>This tool may only return text content</span>
</div>
)
) : (
) : activeTab === 'text' ? (
<div className="text-output">
<pre>{result.textContent || 'No text content'}</pre>
</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>

View File

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