@principal-ai/principal-view-react 0.15.9 → 0.16.0

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