@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.
- package/dist/components/session-events/SessionEventFeed.d.ts +32 -0
- package/dist/components/session-events/SessionEventFeed.d.ts.map +1 -0
- package/dist/components/session-events/SessionEventFeed.js +656 -0
- package/dist/components/session-events/SessionEventFeed.js.map +1 -0
- package/dist/components/session-events/index.d.ts +7 -0
- package/dist/components/session-events/index.d.ts.map +1 -0
- package/dist/components/session-events/index.js +6 -0
- package/dist/components/session-events/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/components/session-events/SessionEventFeed.tsx +1151 -0
- package/src/components/session-events/index.ts +13 -0
- package/src/index.ts +11 -0
- package/src/stories/ChangeTypeVisual.stories.tsx +67 -0
- package/src/stories/RawEventCards.stories.tsx +49 -0
- package/src/stories/data/opencode-session-raw-events.json +15609 -0
|
@@ -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
|
+
}
|