// timeline.jsx — LLM Timeline experimental UX // 시간 + 종류 아이콘 + 펼치는 본문, 도구 호출은 args/result, 명령은 입력/출력 const { useState: useTLState, useMemo: useTLMemo } = React; function timeBetween(prev, cur) { if (!prev) return null; const ms = +cur - +prev; if (ms < 1000) return `+${ms}ms`; return `+${(ms / 1000).toFixed(1)}s`; } function TimelineRow({ ev, prev, idx, total, filter, openMap, onToggle, live }) { if (filter === 'tool' && ev.kind !== 'tool' && ev.kind !== 'cmd') return null; if (filter === 'message' && ev.kind === 'tool') return null; if (filter === 'thought' && ev.kind !== 'thought') return null; const open = !!openMap[ev.id]; const isLive = live && idx === total - 1; const glyph = { message: '💬', tool: '⚙', cmd: '$_', thought: '·', decision: '✓', }[ev.kind]; const tagLabel = { message: 'MSG', tool: 'TOOL', cmd: 'CMD', thought: 'THINK', decision: 'FINAL', }[ev.kind]; const delta = timeBetween(prev?.t, ev.t); return (
{fmtTimeFull(ev.t)} {delta && {delta}}
{glyph}
onToggle(ev.id)} >
{tagLabel} {ev.kind === 'tool' && {ev.tool}} {ev.kind === 'decision' && cycle_complete}
{ev.summary}
{(ev.durationMs != null) && (
{ev.durationMs}ms
)}
{open && (ev.kind === 'tool' || ev.kind === 'cmd') && (
{ev.kind === 'tool' && (
args
result
)} {ev.kind === 'cmd' && (
command
{ev.cmd}
output
{ev.output}
)}
)}
); } function LLMTimeline({ events, live = false }) { const [filter, setFilter] = useTLState('all'); const [openMap, setOpenMap] = useTLState({}); const [expandAll, setExpandAll] = useTLState(false); const filtered = events; const counts = useTLMemo(() => { const c = { all: events.length, tool: 0, message: 0, thought: 0 }; for (const e of events) { if (e.kind === 'tool' || e.kind === 'cmd') c.tool++; else if (e.kind === 'message' || e.kind === 'decision') c.message++; else if (e.kind === 'thought') c.thought++; } return c; }, [events]); const onToggle = (id) => setOpenMap(m => ({ ...m, [id]: !m[id] })); const onToggleAll = () => { const next = !expandAll; setExpandAll(next); if (next) { const m = {}; for (const e of events) if (e.kind === 'tool' || e.kind === 'cmd') m[e.id] = true; setOpenMap(m); } else { setOpenMap({}); } }; const totalDuration = events.reduce((a, e) => a + (e.durationMs || 0), 0); const toolCount = events.filter(e => e.kind === 'tool' || e.kind === 'cmd').length; return (
{events.length}개 이벤트 · {toolCount}회 도구 · {(totalDuration / 1000).toFixed(1)}s 실행
{filtered.map((ev, i) => { if (filter === 'tool' && ev.kind !== 'tool' && ev.kind !== 'cmd') return null; if (filter === 'message' && ev.kind !== 'message' && ev.kind !== 'decision') return null; if (filter === 'thought' && ev.kind !== 'thought') return null; return ( ); })}
); } Object.assign(window, { LLMTimeline, TimelineRow });