@principal-ai/principal-view-react 0.15.10 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1167 @@
1
+ /**
2
+ * SessionEventFeed — a three-column diagnostic feed over one agent session's
3
+ * events, showing the raw V1 event → repo-normalized → accumulated (UI) pipeline
4
+ * for every row.
5
+ *
6
+ * Input rows match the trail-viewer's `SessionEventRow` wire shape:
7
+ * { seq, type, raw, normalized, accumulated }
8
+ * `raw` is the raw V1 event payload, `normalized` its repo-normalized form, and
9
+ * `accumulated` the AgentSessionEvent the File City UI actually renders (null
10
+ * when the accumulator drops the event).
11
+ *
12
+ * Dark diagnostic palette — intentionally self-contained rather than themed.
13
+ */
14
+
15
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
16
+ import type {
17
+ AgentSessionEvent,
18
+ NormalizedPathInfo,
19
+ RepoNormalizedUniversalAgentSessionEvent,
20
+ V1RawEvent,
21
+ } from '@principal-ai/agent-monitoring';
22
+
23
+ export interface SessionEventFeedRow {
24
+ seq: number;
25
+ type: string;
26
+ raw: unknown;
27
+ normalized?: Record<string, unknown>;
28
+ accumulated: AgentSessionEvent | null;
29
+ }
30
+
31
+ export interface SessionEventFeedProps {
32
+ title?: string;
33
+ rows: SessionEventFeedRow[];
34
+ }
35
+
36
+ export interface SessionEventFeedGroupedProps {
37
+ title?: string;
38
+ rows: SessionEventFeedRow[];
39
+ }
40
+
41
+ type AnyRecord = Record<string, unknown>;
42
+ type NormalizedFeedEvent = Omit<RepoNormalizedUniversalAgentSessionEvent, 'raw'>;
43
+
44
+ /** Shared collapse state keyed by row seq. */
45
+ function useCollapse(seqs: number[], defaultCollapsed = false) {
46
+ const [collapsed, setCollapsed] = useState<Set<number>>(
47
+ () => (defaultCollapsed ? new Set(seqs) : new Set<number>()),
48
+ );
49
+ // When the feed grows (incremental paging appends rows), default newly added
50
+ // rows to collapsed so the invariant holds for rows that arrive after mount.
51
+ useEffect(() => {
52
+ if (!defaultCollapsed) return;
53
+ setCollapsed((prev) => {
54
+ let changed = false;
55
+ const next = new Set(prev);
56
+ for (const seq of seqs) {
57
+ if (!next.has(seq)) {
58
+ next.add(seq);
59
+ changed = true;
60
+ }
61
+ }
62
+ return changed ? next : prev;
63
+ });
64
+ }, [seqs, defaultCollapsed]);
65
+ const toggle = useCallback((seq: number) => {
66
+ setCollapsed((prev) => {
67
+ const next = new Set(prev);
68
+ if (next.has(seq)) next.delete(seq);
69
+ else next.add(seq);
70
+ return next;
71
+ });
72
+ }, []);
73
+ const setAll = useCallback(
74
+ (value: boolean) => setCollapsed(value ? new Set(seqs) : new Set<number>()),
75
+ [seqs],
76
+ );
77
+ return { collapsed, toggle, setAll };
78
+ }
79
+
80
+ function toRaw(raw: unknown): V1RawEvent {
81
+ return raw as V1RawEvent;
82
+ }
83
+
84
+ function toNormalized(normalized?: Record<string, unknown>): NormalizedFeedEvent | undefined {
85
+ return normalized as NormalizedFeedEvent | undefined;
86
+ }
87
+
88
+ // =============================================================================
89
+ // Raw event → presentable pieces
90
+ // =============================================================================
91
+
92
+ function dataOf(e: V1RawEvent): AnyRecord {
93
+ return (e.data ?? {}) as AnyRecord;
94
+ }
95
+
96
+ function partOf(e: V1RawEvent): AnyRecord | undefined {
97
+ return dataOf(e).part as AnyRecord | undefined;
98
+ }
99
+
100
+ function eventTimestamp(e: V1RawEvent): number {
101
+ const d = dataOf(e);
102
+ if (typeof d.time === 'number') return d.time;
103
+ const info = d.info as AnyRecord | undefined;
104
+ const infoTime = info?.time as AnyRecord | undefined;
105
+ if (typeof infoTime?.created === 'number') return infoTime.created;
106
+ const part = partOf(e);
107
+ if (typeof part?.time === 'number') return part.time;
108
+ const state = part?.state as AnyRecord | undefined;
109
+ const stateTime = state?.time as AnyRecord | undefined;
110
+ if (typeof stateTime?.start === 'number') return stateTime.start;
111
+ if (typeof stateTime?.end === 'number') return stateTime.end;
112
+ return 0;
113
+ }
114
+
115
+ // =============================================================================
116
+ // Event categorization (drives the preset filters)
117
+ // =============================================================================
118
+
119
+ type EventCategory = 'session' | 'conversation' | 'tool' | 'step' | 'patch' | 'compaction';
120
+
121
+ const ALL_CATEGORIES: EventCategory[] = [
122
+ 'session',
123
+ 'conversation',
124
+ 'tool',
125
+ 'step',
126
+ 'patch',
127
+ 'compaction',
128
+ ];
129
+
130
+ function eventCategory(e: V1RawEvent): EventCategory {
131
+ switch (e.type) {
132
+ case 'session.created.1':
133
+ case 'session.updated.1':
134
+ return 'session';
135
+ case 'message.updated.1':
136
+ case 'message.removed.1':
137
+ case 'message.part.updated.1': {
138
+ const partType = partOf(e)?.type;
139
+ switch (partType) {
140
+ case 'text':
141
+ case 'reasoning':
142
+ return 'conversation';
143
+ case 'tool':
144
+ return 'tool';
145
+ case 'step-start':
146
+ case 'step-finish':
147
+ return 'step';
148
+ case 'patch':
149
+ return 'patch';
150
+ case 'compaction':
151
+ return 'compaction';
152
+ default:
153
+ return 'conversation';
154
+ }
155
+ }
156
+ default:
157
+ return 'conversation';
158
+ }
159
+ }
160
+
161
+ const PRESETS: Array<{ id: string; label: string; include: EventCategory[] }> = [
162
+ { id: 'all', label: 'All', include: ALL_CATEGORIES },
163
+ { id: 'activity', label: 'Activity', include: ['conversation', 'tool', 'patch'] },
164
+ { id: 'conversation', label: 'Conversation', include: ['conversation'] },
165
+ { id: 'tools', label: 'Tools', include: ['tool'] },
166
+ { id: 'noise', label: 'Session & steps', include: ['session', 'step', 'compaction'] },
167
+ ];
168
+
169
+ /** Granular key for a card (raw event type, or part type for parts). */
170
+ function eventKind(e: V1RawEvent): string {
171
+ if (e.type === 'message.part.updated.1') {
172
+ return `part:${partOf(e)?.type ?? 'unknown'}`;
173
+ }
174
+ return e.type;
175
+ }
176
+
177
+ function kindLabel(kind: string): string {
178
+ if (kind.startsWith('part:')) return `part · ${kind.slice(5)}`;
179
+ return kind;
180
+ }
181
+
182
+ const KIND_COLORS: Record<string, string> = {
183
+ 'session.created.1': '#64748b',
184
+ 'session.updated.1': '#94a3b8',
185
+ 'message.updated.1': '#06b6d4',
186
+ 'message.removed.1': '#ef4444',
187
+ 'part:text': '#10b981',
188
+ 'part:reasoning': '#8b5cf6',
189
+ 'part:tool': '#3b82f6',
190
+ 'part:step-start': '#f59e0b',
191
+ 'part:step-finish': '#f59e0b',
192
+ 'part:patch': '#ef4444',
193
+ 'part:compaction': '#ec4899',
194
+ 'part:unknown': '#6b7280',
195
+ };
196
+
197
+ function kindColor(kind: string): string {
198
+ return KIND_COLORS[kind] ?? '#6b7280';
199
+ }
200
+
201
+ function describe(e: V1RawEvent): string {
202
+ const d = dataOf(e);
203
+ const info = d.info as AnyRecord | undefined;
204
+ const part = partOf(e);
205
+ const state = part?.state as AnyRecord | undefined;
206
+
207
+ switch (e.type) {
208
+ case 'session.created.1':
209
+ return (info?.title as string) ?? 'Session created';
210
+ case 'session.updated.1': {
211
+ const bits: string[] = [];
212
+ const tokens = info?.tokens as AnyRecord | undefined;
213
+ if (typeof info?.cost === 'number') bits.push(`$${info.cost.toFixed(4)}`);
214
+ if (typeof tokens?.input === 'number') bits.push(`${tokens.input} in`);
215
+ if (typeof tokens?.output === 'number') bits.push(`${tokens.output} out`);
216
+ return bits.length > 0 ? `Session updated · ${bits.join(' · ')}` : 'Session updated';
217
+ }
218
+ case 'message.updated.1': {
219
+ const role = (info?.role as string) ?? 'message';
220
+ const model = (info?.model as AnyRecord | undefined)?.modelID as string | undefined;
221
+ return model ? `${role} message · ${model}` : `${role} message`;
222
+ }
223
+ case 'message.removed.1':
224
+ return `Removed ${d.messageID as string}`;
225
+ case 'message.part.updated.1': {
226
+ const partType = part?.type as string;
227
+ const toolName = part?.tool as string | undefined;
228
+ const status = state?.status as string | undefined;
229
+ switch (partType) {
230
+ case 'text':
231
+ return (part?.text as string) ?? 'Text';
232
+ case 'reasoning':
233
+ return (part?.text as string) || 'Reasoning';
234
+ case 'tool':
235
+ return [toolName, status].filter(Boolean).join(' · ') || 'Tool';
236
+ case 'step-start':
237
+ return 'Step start';
238
+ case 'step-finish':
239
+ return part?.reason ? `Step finish · ${part.reason as string}` : 'Step finish';
240
+ case 'patch': {
241
+ const files = part?.files as string[] | undefined;
242
+ return `Patch · ${files?.length ?? 0} file${(files?.length ?? 0) === 1 ? '' : 's'}`;
243
+ }
244
+ case 'compaction':
245
+ return `Compaction${part?.auto ? ' · auto' : ''}`;
246
+ default:
247
+ return partType ?? 'Part';
248
+ }
249
+ }
250
+ default:
251
+ return e.type;
252
+ }
253
+ }
254
+
255
+ function toolSummary(input: unknown): string {
256
+ if (input === null || input === undefined) return '';
257
+ if (typeof input === 'string') return input;
258
+ if (Array.isArray(input)) return JSON.stringify(input).slice(0, 120);
259
+ const obj = input as AnyRecord;
260
+ for (const key of ['command', 'filePath', 'file_path', 'path', 'pattern', 'include', 'tool']) {
261
+ if (typeof obj[key] === 'string' && obj[key] !== '') return obj[key] as string;
262
+ }
263
+ return JSON.stringify(input).slice(0, 120);
264
+ }
265
+
266
+ // =============================================================================
267
+ // Cards
268
+ // =============================================================================
269
+
270
+ function formatTime(ts: number): string {
271
+ if (!ts) return '';
272
+ return new Date(ts).toLocaleTimeString([], {
273
+ hour: '2-digit',
274
+ minute: '2-digit',
275
+ second: '2-digit',
276
+ });
277
+ }
278
+
279
+ function RawEventCard({
280
+ event,
281
+ expanded,
282
+ onToggle,
283
+ }: {
284
+ event: V1RawEvent;
285
+ expanded: boolean;
286
+ onToggle: () => void;
287
+ }) {
288
+ const kind = eventKind(event);
289
+ const d = dataOf(event);
290
+ const part = partOf(event);
291
+ const state = part?.state as AnyRecord | undefined;
292
+ const color = kindColor(kind);
293
+
294
+ const body = useMemo(
295
+ () =>
296
+ ((): React.ReactNode => {
297
+ switch (event.type) {
298
+ case 'session.created.1': {
299
+ const info = d.info as AnyRecord | undefined;
300
+ return (
301
+ <MetaRows
302
+ rows={[
303
+ ['agent', info?.agent as string],
304
+ ['model', ((info?.model as AnyRecord | undefined)?.id as string) ?? ((info?.model as AnyRecord | undefined)?.modelID as string)],
305
+ ['directory', info?.directory as string],
306
+ ['version', info?.version as string],
307
+ ['slug', info?.slug as string],
308
+ ]}
309
+ />
310
+ );
311
+ }
312
+ case 'session.updated.1': {
313
+ const info = d.info as AnyRecord | undefined;
314
+ return (
315
+ <MetaRows
316
+ rows={[
317
+ ['cost', typeof info?.cost === 'number' ? `$${info.cost.toFixed(4)}` : undefined],
318
+ ['tokens', info?.tokens as AnyRecord | undefined],
319
+ ['title', info?.title as string],
320
+ ]}
321
+ />
322
+ );
323
+ }
324
+ case 'message.updated.1': {
325
+ const info = d.info as AnyRecord | undefined;
326
+ return (
327
+ <MetaRows
328
+ rows={[
329
+ ['role', info?.role as string],
330
+ ['agent', info?.agent as string],
331
+ ['model', info?.model as AnyRecord | undefined],
332
+ ['tokens', info?.tokens as AnyRecord | undefined],
333
+ ['messageID', info?.id as string],
334
+ ]}
335
+ />
336
+ );
337
+ }
338
+ case 'message.removed.1':
339
+ return <MetaRows rows={[['messageID', d.messageID as string]]} />;
340
+ case 'message.part.updated.1': {
341
+ switch (part?.type) {
342
+ case 'text':
343
+ case 'reasoning':
344
+ return <TextView text={part?.text as string | undefined} />;
345
+ case 'tool':
346
+ return (
347
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
348
+ <div style={{ fontSize: 12, color: '#9ca3af' }}>
349
+ callID {String(part?.callID ?? '')} · {String(state?.status ?? '')}
350
+ </div>
351
+ <ToolIO
352
+ input={state?.input}
353
+ output={state?.status === 'error' ? state?.error : state?.output}
354
+ />
355
+ </div>
356
+ );
357
+ case 'step-start':
358
+ return <MetaRows rows={[['snapshot', part?.snapshot as string]]} />;
359
+ case 'step-finish':
360
+ return (
361
+ <MetaRows
362
+ rows={[
363
+ ['reason', part?.reason as string],
364
+ ['cost', typeof part?.cost === 'number' ? `$${part.cost.toFixed(4)}` : undefined],
365
+ ['tokens', part?.tokens as AnyRecord | undefined],
366
+ ]}
367
+ />
368
+ );
369
+ case 'patch':
370
+ return (
371
+ <MetaRows
372
+ rows={[
373
+ ['files', part?.files as string[] | undefined],
374
+ ['hash', part?.hash as string],
375
+ ]}
376
+ />
377
+ );
378
+ case 'compaction':
379
+ return (
380
+ <MetaRows
381
+ rows={[
382
+ ['auto', typeof part?.auto === 'boolean' ? String(part.auto) : undefined],
383
+ ['overflow', typeof part?.overflow === 'boolean' ? String(part.overflow) : undefined],
384
+ ]}
385
+ />
386
+ );
387
+ default:
388
+ return <JsonView value={d} />;
389
+ }
390
+ }
391
+ default:
392
+ return <JsonView value={d} />;
393
+ }
394
+ })(),
395
+ [event],
396
+ );
397
+
398
+ return (
399
+ <div
400
+ style={{
401
+ display: 'flex',
402
+ flexDirection: 'column',
403
+ gap: expanded ? 8 : 0,
404
+ padding: '10px 14px',
405
+ borderRadius: 0,
406
+ minWidth: 0,
407
+ overflow: 'hidden',
408
+ backgroundColor: expanded ? '#111827' : '#161b26',
409
+ fontFamily: 'system-ui, sans-serif',
410
+ fontSize: 13,
411
+ }}
412
+ >
413
+ <button
414
+ onClick={onToggle}
415
+ aria-expanded={expanded}
416
+ style={{
417
+ display: 'flex',
418
+ alignItems: 'center',
419
+ gap: 8,
420
+ width: '100%',
421
+ border: 'none',
422
+ background: 'none',
423
+ padding: 0,
424
+ cursor: 'pointer',
425
+ fontFamily: 'inherit',
426
+ textAlign: 'left',
427
+ }}
428
+ >
429
+ <span style={{ color: '#6b7280', fontSize: 11, whiteSpace: 'nowrap' }}>#{event.seq}</span>
430
+ <span
431
+ style={{
432
+ fontSize: 11,
433
+ fontWeight: 600,
434
+ textTransform: 'uppercase',
435
+ letterSpacing: 0.5,
436
+ color,
437
+ whiteSpace: 'nowrap',
438
+ }}
439
+ >
440
+ {kindLabel(kind)}
441
+ </span>
442
+ <span
443
+ style={{
444
+ flex: 1,
445
+ overflow: 'hidden',
446
+ textOverflow: 'ellipsis',
447
+ whiteSpace: 'nowrap',
448
+ color: '#e5e7eb',
449
+ }}
450
+ >
451
+ {describe(event)}
452
+ </span>
453
+ <span style={{ color: '#6b7280', fontSize: 11, whiteSpace: 'nowrap' }}>
454
+ {formatTime(eventTimestamp(event))}
455
+ </span>
456
+ </button>
457
+ {expanded ? body : null}
458
+ </div>
459
+ );
460
+ }
461
+
462
+ function prettifyEventType(t: string): string {
463
+ return t.replace(/-/g, ' ');
464
+ }
465
+
466
+ function normalizedDescribe(n: NormalizedFeedEvent): string {
467
+ if (n.toolName) return [n.toolName, n.operation].filter(Boolean).join(' · ');
468
+ if (n.operation) return n.operation;
469
+ const d = n.data as AnyRecord | undefined;
470
+ if (typeof d?.message === 'string') return d.message.slice(0, 90);
471
+ return prettifyEventType(n.eventType);
472
+ }
473
+
474
+ function repoLabel(repo: {
475
+ root?: string;
476
+ owner?: string;
477
+ repo?: string;
478
+ remoteUrl?: string;
479
+ }): string {
480
+ const identity = repo.owner && repo.repo ? `${repo.owner}/${repo.repo}` : repo.repo ?? '';
481
+ return identity && repo.root ? `${identity} @ ${repo.root}` : repo.root ?? '';
482
+ }
483
+
484
+ function NormalizedEventCard({
485
+ event,
486
+ seq,
487
+ color,
488
+ expanded,
489
+ onToggle,
490
+ }: {
491
+ event: NormalizedFeedEvent;
492
+ seq: number;
493
+ color: string;
494
+ expanded: boolean;
495
+ onToggle: () => void;
496
+ }) {
497
+ return (
498
+ <div
499
+ style={{
500
+ display: 'flex',
501
+ flexDirection: 'column',
502
+ gap: expanded ? 8 : 0,
503
+ padding: '10px 14px',
504
+ borderRadius: 0,
505
+ minWidth: 0,
506
+ overflow: 'hidden',
507
+ backgroundColor: expanded ? '#0d1520' : '#101722',
508
+ fontFamily: 'system-ui, sans-serif',
509
+ fontSize: 13,
510
+ }}
511
+ >
512
+ <button
513
+ onClick={onToggle}
514
+ aria-expanded={expanded}
515
+ style={{
516
+ display: 'flex',
517
+ alignItems: 'center',
518
+ gap: 8,
519
+ width: '100%',
520
+ border: 'none',
521
+ background: 'none',
522
+ padding: 0,
523
+ cursor: 'pointer',
524
+ fontFamily: 'inherit',
525
+ textAlign: 'left',
526
+ }}
527
+ >
528
+ <span style={{ color: '#6b7280', fontSize: 11, whiteSpace: 'nowrap' }}>#{seq}</span>
529
+ <span
530
+ style={{
531
+ fontSize: 11,
532
+ fontWeight: 600,
533
+ textTransform: 'uppercase',
534
+ letterSpacing: 0.5,
535
+ color,
536
+ whiteSpace: 'nowrap',
537
+ }}
538
+ >
539
+ {prettifyEventType(event.eventType)}
540
+ </span>
541
+ <span
542
+ style={{
543
+ flex: 1,
544
+ overflow: 'hidden',
545
+ textOverflow: 'ellipsis',
546
+ whiteSpace: 'nowrap',
547
+ color: '#e5e7eb',
548
+ }}
549
+ >
550
+ {normalizedDescribe(event)}
551
+ </span>
552
+ <span style={{ color: '#6b7280', fontSize: 11, whiteSpace: 'nowrap' }}>
553
+ {formatTime(event.timestamp)}
554
+ </span>
555
+ </button>
556
+ {expanded ? (
557
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
558
+ <MetaRows
559
+ rows={[
560
+ ['eventType', event.eventType],
561
+ ['operation', event.operation],
562
+ ['toolName', event.toolName],
563
+ ['sessionId', event.sessionId],
564
+ ['workingDir', event.normalizedWorkingDirectory],
565
+ ['repository', event.repository ? repoLabel(event.repository) : undefined],
566
+ ]}
567
+ />
568
+ {event.files && event.files.length > 0 ? (
569
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
570
+ <div style={{ fontSize: 11, color: '#6b7280' }}>files ({event.files.length})</div>
571
+ {event.files.map((file, i) => (
572
+ <FileRow key={i} file={file} />
573
+ ))}
574
+ </div>
575
+ ) : null}
576
+ {event.toolInput !== undefined ? (
577
+ <ToolIO input={event.toolInput} output={event.toolOutput} />
578
+ ) : null}
579
+ </div>
580
+ ) : null}
581
+ </div>
582
+ );
583
+ }
584
+
585
+ function FileRow({ file }: { file: NormalizedPathInfo }) {
586
+ return (
587
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
588
+ <div
589
+ style={{
590
+ color: '#93c5fd',
591
+ fontSize: 12,
592
+ wordBreak: 'break-word',
593
+ fontFamily: 'monospace',
594
+ }}
595
+ >
596
+ {file.displayPath}
597
+ </div>
598
+ <div style={{ color: '#6b7280', fontSize: 11, wordBreak: 'break-word' }}>
599
+ {file.context}
600
+ {file.repository
601
+ ? ` · ${file.repository.gitRoot} → ${file.repository.relativePath}`
602
+ : ` · ${file.absolutePath}`}
603
+ </div>
604
+ </div>
605
+ );
606
+ }
607
+
608
+ const OPERATION_COLORS: Record<string, string> = {
609
+ starting: '#6b7280',
610
+ prompting: '#a78bfa',
611
+ reading: '#a855f7',
612
+ grepping: '#e879f9',
613
+ editing: '#22c55e',
614
+ tool: '#3b82f6',
615
+ errored: '#ef4444',
616
+ waiting: '#9ca3af',
617
+ finished: '#10b981',
618
+ compacting: '#ec4899',
619
+ subagent: '#f59e0b',
620
+ };
621
+
622
+ function AccumulatedEventCard({
623
+ event,
624
+ seq,
625
+ expanded,
626
+ onToggle,
627
+ }: {
628
+ event: AgentSessionEvent | null;
629
+ seq: number;
630
+ expanded: boolean;
631
+ onToggle: () => void;
632
+ }) {
633
+ if (!event) {
634
+ return (
635
+ <div
636
+ style={{
637
+ display: 'flex',
638
+ alignItems: 'center',
639
+ gap: 8,
640
+ padding: '10px 14px',
641
+ borderRadius: 0,
642
+ minWidth: 0,
643
+ overflow: 'hidden',
644
+ border: '1px dashed #1f2937',
645
+ backgroundColor: '#0d1117',
646
+ fontFamily: 'system-ui, sans-serif',
647
+ fontSize: 12,
648
+ }}
649
+ >
650
+ <span style={{ color: '#374151', whiteSpace: 'nowrap' }}>#{seq}</span>
651
+ <span style={{ color: '#4b5563', fontStyle: 'italic' }}>
652
+ no UI event (accumulator dropped)
653
+ </span>
654
+ </div>
655
+ );
656
+ }
657
+
658
+ const opColor = OPERATION_COLORS[event.operation] ?? '#6b7280';
659
+
660
+ return (
661
+ <div
662
+ style={{
663
+ display: 'flex',
664
+ flexDirection: 'column',
665
+ gap: expanded ? 8 : 0,
666
+ padding: '10px 14px',
667
+ borderRadius: 0,
668
+ minWidth: 0,
669
+ overflow: 'hidden',
670
+ backgroundColor: expanded ? '#121a13' : '#141b16',
671
+ fontFamily: 'system-ui, sans-serif',
672
+ fontSize: 13,
673
+ }}
674
+ >
675
+ <button
676
+ onClick={onToggle}
677
+ aria-expanded={expanded}
678
+ style={{
679
+ display: 'flex',
680
+ alignItems: 'center',
681
+ gap: 8,
682
+ width: '100%',
683
+ border: 'none',
684
+ background: 'none',
685
+ padding: 0,
686
+ cursor: 'pointer',
687
+ fontFamily: 'inherit',
688
+ textAlign: 'left',
689
+ }}
690
+ >
691
+ <span
692
+ style={{
693
+ width: 8,
694
+ height: 8,
695
+ borderRadius: '50%',
696
+ flexShrink: 0,
697
+ backgroundColor: event.sessionColor,
698
+ }}
699
+ />
700
+ <span style={{ color: '#6b7280', fontSize: 11, whiteSpace: 'nowrap' }}>#{seq}</span>
701
+ <span
702
+ style={{
703
+ fontSize: 11,
704
+ fontWeight: 600,
705
+ textTransform: 'uppercase',
706
+ letterSpacing: 0.5,
707
+ color: opColor,
708
+ whiteSpace: 'nowrap',
709
+ }}
710
+ >
711
+ {event.operation}
712
+ </span>
713
+ <span
714
+ style={{
715
+ flex: 1,
716
+ overflow: 'hidden',
717
+ textOverflow: 'ellipsis',
718
+ whiteSpace: 'nowrap',
719
+ color: '#e5e7eb',
720
+ }}
721
+ >
722
+ {event.description}
723
+ </span>
724
+ <span style={{ color: '#6b7280', fontSize: 11, whiteSpace: 'nowrap' }}>
725
+ {formatTime(event.timestamp)}
726
+ </span>
727
+ </button>
728
+ {expanded ? (
729
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
730
+ <MetaRows
731
+ rows={[
732
+ ['operation', event.operation],
733
+ ['session', event.sessionName],
734
+ ['contextTokens', event.contextTokens],
735
+ ['toolName', event.toolName],
736
+ ['subagent', event.subagentType],
737
+ ['childSessionId', event.childSessionId],
738
+ ]}
739
+ />
740
+ <div style={{ fontSize: 12, color: '#6b7280' }}>{event.description}</div>
741
+ {event.files.length > 0 ? <PathList label="files" paths={event.files} /> : null}
742
+ {event.dependencies.length > 0 ? (
743
+ <PathList label="dependencies" paths={event.dependencies} />
744
+ ) : null}
745
+ {event.layers.length > 0 ? (
746
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
747
+ <div style={{ fontSize: 11, color: '#6b7280' }}>layers</div>
748
+ {event.layers.map((layer) => (
749
+ <div
750
+ key={layer.id}
751
+ style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12 }}
752
+ >
753
+ <span
754
+ style={{
755
+ width: 8,
756
+ height: 8,
757
+ borderRadius: '50%',
758
+ backgroundColor: layer.color,
759
+ opacity: 0.8,
760
+ }}
761
+ />
762
+ <span style={{ color: '#d1d5db' }}>
763
+ {layer.name} ({layer.items.length})
764
+ </span>
765
+ </div>
766
+ ))}
767
+ </div>
768
+ ) : null}
769
+ </div>
770
+ ) : null}
771
+ </div>
772
+ );
773
+ }
774
+
775
+ function PathList({ label, paths }: { label: string; paths: NormalizedPathInfo[] }) {
776
+ return (
777
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
778
+ <div style={{ fontSize: 11, color: '#6b7280' }}>{label}</div>
779
+ {paths.map((p, i) => (
780
+ <div
781
+ key={i}
782
+ style={{ color: '#93c5fd', fontSize: 11.5, fontFamily: 'monospace', wordBreak: 'break-word' }}
783
+ >
784
+ {p.displayPath}
785
+ </div>
786
+ ))}
787
+ </div>
788
+ );
789
+ }
790
+
791
+ function TextView({ text }: { text?: string }) {
792
+ if (!text) return <div style={{ color: '#6b7280', fontSize: 12 }}>(empty)</div>;
793
+ return (
794
+ <div
795
+ style={{
796
+ color: '#d1d5db',
797
+ fontSize: 12.5,
798
+ lineHeight: 1.5,
799
+ maxHeight: 120,
800
+ overflowY: 'auto',
801
+ whiteSpace: 'pre-wrap',
802
+ wordBreak: 'break-word',
803
+ }}
804
+ >
805
+ {text}
806
+ </div>
807
+ );
808
+ }
809
+
810
+ function ToolIO({ input, output }: { input?: unknown; output?: unknown }) {
811
+ const summary = toolSummary(input);
812
+ return (
813
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
814
+ {summary ? (
815
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
816
+ <span style={{ fontSize: 11, color: '#60a5fa', fontWeight: 600 }}>IN</span>
817
+ <code style={{ color: '#93c5fd', fontSize: 12, wordBreak: 'break-word' }}>{summary}</code>
818
+ </div>
819
+ ) : null}
820
+ {input !== undefined && input !== null && typeof input === 'object' ? (
821
+ <CodeBlock value={JSON.stringify(input, null, 2)} />
822
+ ) : null}
823
+ {output !== undefined && output !== null ? (
824
+ <>
825
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
826
+ <span style={{ fontSize: 11, color: '#34d399', fontWeight: 600 }}>OUT</span>
827
+ <span style={{ fontSize: 11, color: '#6b7280' }}>
828
+ {typeof output === 'string' ? `${output.length.toLocaleString()} chars` : 'object'}
829
+ </span>
830
+ </div>
831
+ <CodeBlock value={typeof output === 'string' ? output : JSON.stringify(output, null, 2)} />
832
+ </>
833
+ ) : null}
834
+ </div>
835
+ );
836
+ }
837
+
838
+ function CodeBlock({ value }: { value: string }) {
839
+ return (
840
+ <pre
841
+ style={{
842
+ margin: 0,
843
+ padding: 8,
844
+ borderRadius: 6,
845
+ backgroundColor: '#0b1220',
846
+ color: '#9ca3af',
847
+ fontSize: 11.5,
848
+ lineHeight: 1.45,
849
+ maxHeight: 180,
850
+ overflow: 'auto',
851
+ whiteSpace: 'pre-wrap',
852
+ wordBreak: 'break-word',
853
+ }}
854
+ >
855
+ {value}
856
+ </pre>
857
+ );
858
+ }
859
+
860
+ function MetaRows({ rows }: { rows: Array<[string, unknown]> }) {
861
+ const present = rows.filter(([, v]) => v !== undefined && v !== null && v !== '');
862
+ if (present.length === 0) return null;
863
+ return (
864
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
865
+ {present.map(([label, value]) => (
866
+ <div key={label} style={{ display: 'flex', gap: 8, fontSize: 12 }}>
867
+ <span style={{ color: '#6b7280', minWidth: 76, whiteSpace: 'nowrap' }}>{label}</span>
868
+ <span
869
+ style={{
870
+ color: '#d1d5db',
871
+ wordBreak: 'break-word',
872
+ whiteSpace: typeof value === 'string' ? 'pre-wrap' : 'nowrap',
873
+ }}
874
+ >
875
+ {typeof value === 'string'
876
+ ? value
877
+ : Array.isArray(value)
878
+ ? (value as unknown[]).join(', ')
879
+ : JSON.stringify(value)}
880
+ </span>
881
+ </div>
882
+ ))}
883
+ </div>
884
+ );
885
+ }
886
+
887
+ function JsonView({ value }: { value: unknown }) {
888
+ return <CodeBlock value={JSON.stringify(value, null, 2)} />;
889
+ }
890
+
891
+ // =============================================================================
892
+ // Feed
893
+ // =============================================================================
894
+
895
+ export function SessionEventFeed({ title, rows }: SessionEventFeedProps) {
896
+ const items = useMemo(() => [...rows].sort((a, b) => a.seq - b.seq), [rows]);
897
+ const seqs = useMemo(() => items.map((r) => r.seq), [items]);
898
+ const { collapsed, toggle, setAll } = useCollapse(seqs, true);
899
+
900
+ const [active, setActive] = useState<string>('activity');
901
+
902
+ const activePreset = PRESETS.find((p) => p.id === active) ?? PRESETS[0];
903
+ const isVisible = (raw: V1RawEvent) => activePreset.include.includes(eventCategory(raw));
904
+ const visibleEvents = items.filter((r) => isVisible(toRaw(r.raw)));
905
+
906
+ const presetCounts = useMemo(() => {
907
+ const map = new Map<string, number>();
908
+ for (const preset of PRESETS) {
909
+ map.set(preset.id, items.filter((r) => preset.include.includes(eventCategory(toRaw(r.raw)))).length);
910
+ }
911
+ return map;
912
+ }, [items]);
913
+
914
+ const expandedCount = visibleEvents.filter((r) => !collapsed.has(r.seq)).length;
915
+
916
+ return (
917
+ <div style={{ padding: 24, backgroundColor: '#0d1117', minHeight: '100vh' }}>
918
+ <div
919
+ style={{
920
+ display: 'flex',
921
+ alignItems: 'baseline',
922
+ gap: 12,
923
+ marginBottom: 4,
924
+ fontFamily: 'system-ui, sans-serif',
925
+ }}
926
+ >
927
+ <h1 style={{ margin: 0, color: '#f3f4f6', fontSize: 18, fontWeight: 600 }}>{title}</h1>
928
+ <span style={{ color: '#6b7280', fontSize: 12 }}>
929
+ {items.length} raw events · {visibleEvents.length} visible · {expandedCount} expanded
930
+ </span>
931
+ </div>
932
+ <div
933
+ style={{
934
+ display: 'flex',
935
+ gap: 6,
936
+ flexWrap: 'wrap',
937
+ marginBottom: 16,
938
+ fontFamily: 'system-ui, sans-serif',
939
+ }}
940
+ >
941
+ <FilterChip label="all" active={active === 'all'} onClick={() => setActive('all')} />
942
+ {PRESETS.filter((p) => p.id !== 'all').map((preset) => (
943
+ <FilterChip
944
+ key={preset.id}
945
+ label={`${preset.label} (${presetCounts.get(preset.id) ?? 0})`}
946
+ active={active === preset.id}
947
+ onClick={() => setActive(preset.id)}
948
+ />
949
+ ))}
950
+ <span style={{ flex: 1 }} />
951
+ <FilterChip label="Expand all" active={false} onClick={() => setAll(false)} />
952
+ <FilterChip label="Collapse all" active={false} onClick={() => setAll(true)} />
953
+ </div>
954
+ <ColumnHeaders />
955
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 1300 }}>
956
+ {items.map((r) => (
957
+ <EventRow
958
+ key={r.seq}
959
+ row={r}
960
+ visible={isVisible(toRaw(r.raw))}
961
+ expanded={!collapsed.has(r.seq)}
962
+ onToggle={() => toggle(r.seq)}
963
+ />
964
+ ))}
965
+ </div>
966
+ </div>
967
+ );
968
+ }
969
+
970
+ export function SessionEventFeedGrouped({ title, rows }: SessionEventFeedGroupedProps) {
971
+ const items = useMemo(() => [...rows].sort((a, b) => a.seq - b.seq), [rows]);
972
+ const byKind = useMemo(() => {
973
+ const map = new Map<string, SessionEventFeedRow[]>();
974
+ for (const r of items) {
975
+ const kind = eventKind(toRaw(r.raw));
976
+ map.set(kind, [...(map.get(kind) ?? []), r]);
977
+ }
978
+ return map;
979
+ }, [items]);
980
+
981
+ return (
982
+ <div style={{ padding: 24, backgroundColor: '#0d1117', minHeight: '100vh' }}>
983
+ {title ? (
984
+ <h1 style={{ margin: '0 0 12px', color: '#f3f4f6', fontSize: 18, fontWeight: 600, fontFamily: 'system-ui, sans-serif' }}>
985
+ {title}
986
+ </h1>
987
+ ) : null}
988
+ <ColumnHeaders />
989
+ {Array.from(byKind.entries())
990
+ .sort((a, b) => b[1].length - a[1].length)
991
+ .map(([kind, group]) => (
992
+ <GroupSection key={kind} kind={kind} group={group} />
993
+ ))}
994
+ </div>
995
+ );
996
+ }
997
+
998
+ function GroupSection({ kind, group }: { kind: string; group: SessionEventFeedRow[] }) {
999
+ const seqs = useMemo(() => group.map((r) => r.seq), [group]);
1000
+ const { collapsed, toggle, setAll } = useCollapse(seqs, true);
1001
+
1002
+ return (
1003
+ <div style={{ marginBottom: 20 }}>
1004
+ <div
1005
+ style={{
1006
+ display: 'flex',
1007
+ alignItems: 'center',
1008
+ gap: 8,
1009
+ marginBottom: 8,
1010
+ fontFamily: 'system-ui, sans-serif',
1011
+ }}
1012
+ >
1013
+ <span style={{ color: kindColor(kind), fontWeight: 600, fontSize: 13 }}>
1014
+ {kindLabel(kind)}
1015
+ </span>
1016
+ <span style={{ color: '#6b7280', fontSize: 12 }}>{group.length}</span>
1017
+ <span style={{ flex: 1 }} />
1018
+ <FilterChip label="Expand all" active={false} onClick={() => setAll(false)} />
1019
+ <FilterChip label="Collapse all" active={false} onClick={() => setAll(true)} />
1020
+ </div>
1021
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxWidth: 1300 }}>
1022
+ {group.map((r) => (
1023
+ <EventRow
1024
+ key={r.seq}
1025
+ row={r}
1026
+ visible
1027
+ expanded={!collapsed.has(r.seq)}
1028
+ onToggle={() => toggle(r.seq)}
1029
+ />
1030
+ ))}
1031
+ </div>
1032
+ </div>
1033
+ );
1034
+ }
1035
+
1036
+ function ColumnHeaders() {
1037
+ return (
1038
+ <div
1039
+ style={{
1040
+ position: 'sticky',
1041
+ top: 0,
1042
+ zIndex: 10,
1043
+ display: 'grid',
1044
+ gridTemplateColumns: '3px 1fr 1fr 1fr',
1045
+ gap: 8,
1046
+ maxWidth: 1300,
1047
+ padding: '8px 14px',
1048
+ marginBottom: 8,
1049
+ backgroundColor: '#0d1117',
1050
+ borderBottom: '1px solid #1f2937',
1051
+ fontFamily: 'system-ui, sans-serif',
1052
+ }}
1053
+ >
1054
+ <span />
1055
+ <HeaderLabel>Raw event</HeaderLabel>
1056
+ <HeaderLabel>Repo-normalized</HeaderLabel>
1057
+ <HeaderLabel>UI event (accumulated)</HeaderLabel>
1058
+ </div>
1059
+ );
1060
+ }
1061
+
1062
+ function HeaderLabel({ children }: { children: React.ReactNode }) {
1063
+ return (
1064
+ <span
1065
+ style={{
1066
+ fontSize: 11,
1067
+ fontWeight: 600,
1068
+ textTransform: 'uppercase',
1069
+ letterSpacing: 0.5,
1070
+ color: '#9ca3af',
1071
+ }}
1072
+ >
1073
+ {children}
1074
+ </span>
1075
+ );
1076
+ }
1077
+
1078
+ function EventRow({
1079
+ row,
1080
+ visible,
1081
+ expanded,
1082
+ onToggle,
1083
+ }: {
1084
+ row: SessionEventFeedRow;
1085
+ visible: boolean;
1086
+ expanded: boolean;
1087
+ onToggle: () => void;
1088
+ }) {
1089
+ const raw = toRaw(row.raw);
1090
+ const normalized = toNormalized(row.normalized);
1091
+ if (!visible) return <FilteredOutLine raw={raw} />;
1092
+ const color = kindColor(eventKind(raw));
1093
+ const files = normalized?.files;
1094
+ const hasPaths = !!files && files.length > 0;
1095
+ return (
1096
+ <div style={{ display: 'grid', gridTemplateColumns: '3px 1fr 1fr 1fr', gap: 8 }}>
1097
+ <div
1098
+ title={hasPaths ? `${files.length} normalized path(s)` : 'no paths to normalize'}
1099
+ style={{
1100
+ backgroundColor: hasPaths ? '#10b981' : '#232c39',
1101
+ minHeight: '100%',
1102
+ }}
1103
+ />
1104
+ <RawEventCard event={raw} expanded={expanded} onToggle={onToggle} />
1105
+ {normalized ? (
1106
+ <NormalizedEventCard
1107
+ event={normalized}
1108
+ seq={row.seq}
1109
+ color={color}
1110
+ expanded={expanded}
1111
+ onToggle={onToggle}
1112
+ />
1113
+ ) : null}
1114
+ <AccumulatedEventCard
1115
+ event={row.accumulated}
1116
+ seq={row.seq}
1117
+ expanded={expanded}
1118
+ onToggle={onToggle}
1119
+ />
1120
+ </div>
1121
+ );
1122
+ }
1123
+
1124
+ function FilteredOutLine({ raw }: { raw: V1RawEvent }) {
1125
+ const color = kindColor(eventKind(raw));
1126
+ return (
1127
+ <div
1128
+ title={describe(raw)}
1129
+ style={{
1130
+ height: 2,
1131
+ backgroundColor: color,
1132
+ opacity: 0.16,
1133
+ cursor: 'default',
1134
+ }}
1135
+ />
1136
+ );
1137
+ }
1138
+
1139
+ function FilterChip({
1140
+ label,
1141
+ active,
1142
+ color,
1143
+ onClick,
1144
+ }: {
1145
+ label: string;
1146
+ active: boolean;
1147
+ color?: string;
1148
+ onClick: () => void;
1149
+ }) {
1150
+ return (
1151
+ <button
1152
+ onClick={onClick}
1153
+ style={{
1154
+ padding: '4px 10px',
1155
+ borderRadius: 999,
1156
+ border: `1px solid ${active ? color ?? '#3b82f6' : '#1f2937'}`,
1157
+ backgroundColor: active ? (color ?? '#3b82f6') + '22' : 'transparent',
1158
+ color: active ? '#f3f4f6' : '#9ca3af',
1159
+ fontSize: 11.5,
1160
+ cursor: 'pointer',
1161
+ fontFamily: 'inherit',
1162
+ }}
1163
+ >
1164
+ {label}
1165
+ </button>
1166
+ );
1167
+ }