feat(observabilidade): UI timeline por sessão com filtros
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import type { SessionEvent } from '../../../api/types/session'
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? s.slice(0, n) + '…' : s
|
||||
}
|
||||
|
||||
interface Props {
|
||||
event: SessionEvent
|
||||
defaultCollapsed: boolean
|
||||
}
|
||||
|
||||
export function EventBlock({ event, defaultCollapsed }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
|
||||
const base = 'rounded border px-3 py-2 my-1 text-sm'
|
||||
switch (event.type) {
|
||||
case 'user':
|
||||
if (event.tool_result !== null && event.tool_result !== undefined) {
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/40 border-slate-700`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-slate-500 uppercase">
|
||||
tool_result {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<pre className="mt-2 text-xs overflow-x-auto whitespace-pre-wrap text-slate-300">
|
||||
{typeof event.tool_result === 'string' ? event.tool_result : JSON.stringify(event.tool_result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-blue-500/10 border-blue-500/30`}>
|
||||
<div className="text-xs text-blue-300 uppercase mb-1">user</div>
|
||||
<div className="whitespace-pre-wrap text-slate-100">{event.text ?? '—'}</div>
|
||||
</div>
|
||||
)
|
||||
case 'assistant':
|
||||
if (event.tool_name) {
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-amber-500/10 border-amber-500/30`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-amber-300 uppercase">
|
||||
tool_use: {event.tool_name} {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && event.tool_input && (
|
||||
<pre className="mt-2 text-xs overflow-x-auto whitespace-pre-wrap text-slate-300">
|
||||
{JSON.stringify(event.tool_input, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-800/40 border-slate-700`}>
|
||||
<div className="text-xs text-slate-500 uppercase mb-1">assistant</div>
|
||||
<div className="whitespace-pre-wrap text-slate-200">{truncate(event.text ?? '—', collapsed ? 300 : Number.MAX_SAFE_INTEGER)}</div>
|
||||
{(event.text?.length ?? 0) > 300 && (
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="text-xs text-slate-500 mt-1">
|
||||
{collapsed ? 'Expandir' : 'Colapsar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 'system':
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/30 border-slate-800 text-xs text-slate-500`}>
|
||||
<button onClick={() => setCollapsed(!collapsed)} className="uppercase">
|
||||
system {event.skill_invoked ? `· skill: ${event.skill_invoked}` : ''} {event.hook_name ? `· hook: ${event.hook_name}` : ''} {collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
{!collapsed && <div className="mt-2 whitespace-pre-wrap">{event.text ?? JSON.stringify(event.raw)}</div>}
|
||||
</div>
|
||||
)
|
||||
case 'attachment':
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-purple-500/10 border-purple-500/30 text-xs text-purple-300`}>
|
||||
📎 attachment
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div id={`evt-${event.index}`} className={`${base} bg-slate-900/20 border-slate-800 text-xs text-slate-500`}>
|
||||
{event.type}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { getSession } from '../lib/api/sessions'
|
||||
import { EventBlock } from '../components/sessions/EventBlock'
|
||||
import type { SessionMeta, SessionEvent } from '../../api/types/session'
|
||||
|
||||
type FilterMode = 'all' | 'no-system' | 'tools-only' | 'prompts-only'
|
||||
|
||||
export default function SessionDetail() {
|
||||
return <div className="p-6 text-slate-400">Em construção — Task 7.</div>
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [meta, setMeta] = useState<SessionMeta | null>(null)
|
||||
const [events, setEvents] = useState<SessionEvent[]>([])
|
||||
const [mode, setMode] = useState<FilterMode>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getSession(id)
|
||||
.then((r) => {
|
||||
setMeta(r.meta)
|
||||
setEvents(r.events)
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const visible = events.filter((e) => {
|
||||
if (mode === 'no-system') return e.type !== 'system'
|
||||
if (mode === 'tools-only') return e.tool_name !== null || e.tool_result !== null
|
||||
if (mode === 'prompts-only') return e.type === 'user' && e.tool_result === null
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) return <div className="p-6 text-slate-400">A carregar…</div>
|
||||
if (error) return <div className="p-6 text-red-300">{error}</div>
|
||||
if (!meta) return <div className="p-6 text-slate-400">Sessão não encontrada.</div>
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Link to="/sessions" className="text-sm text-slate-400 hover:underline">
|
||||
← Voltar à lista
|
||||
</Link>
|
||||
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-xl font-semibold text-white">{meta.first_prompt?.slice(0, 120) ?? meta.session_id}</h1>
|
||||
<div className="text-sm text-slate-400 flex flex-wrap gap-4">
|
||||
<span>{new Date(meta.started_at).toLocaleString('pt-PT')}</span>
|
||||
<span>Projecto: {meta.project_slug}</span>
|
||||
<span>Duração: {meta.duration_sec ?? 0}s</span>
|
||||
<span>Eventos: {meta.event_count}</span>
|
||||
<span>Tool calls: {meta.tool_calls}</span>
|
||||
<span>Skills: {meta.skills_invoked.join(', ') || '—'}</span>
|
||||
<span>Resultado: {meta.outcome}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">JSONL: {meta.jsonl_path}</div>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-2 text-sm">
|
||||
{(['all', 'no-system', 'tools-only', 'prompts-only'] as FilterMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-1 rounded ${mode === m ? 'bg-indigo-500/30 text-indigo-200' : 'bg-white/5 text-slate-400'}`}
|
||||
>
|
||||
{m === 'all' ? 'Tudo' : m === 'no-system' ? 'Esconder system' : m === 'tools-only' ? 'Só tools' : 'Só prompts'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">
|
||||
A mostrar {visible.length} de {events.length} eventos.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{visible.map((e) => (
|
||||
<EventBlock key={e.index} event={e} defaultCollapsed={e.type === 'system' || (e.tool_result !== null && e.tool_result !== undefined)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user