@heyiam/ui 0.0.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.
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@heyiam/ui",
3
+ "version": "0.0.1",
4
+ "description": "Shared visualization components for heyi.am",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "files": ["src"],
9
+ "peerDependencies": {
10
+ "react": ">=18",
11
+ "react-dom": ">=18"
12
+ },
13
+ "license": "MIT"
14
+ }
@@ -0,0 +1,853 @@
1
+ import { useRef, useCallback } from 'react';
2
+ import type { Session, ChildSessionSummary } from './types';
3
+
4
+ export interface WorkTimelineProps {
5
+ sessions: Session[];
6
+ /** Called when a session bar is clicked */
7
+ onSessionClick?: (session: Session) => void;
8
+ }
9
+
10
+ // ── Color mapping (shared with AgentTimeline) ──────────────────
11
+
12
+ const AGENT_COLORS: Record<string, string> = {
13
+ main: '#084471',
14
+ orchestrator: '#084471',
15
+ 'frontend-dev': '#7c3aed',
16
+ frontend: '#7c3aed',
17
+ 'backend-dev': '#0891b2',
18
+ backend: '#0891b2',
19
+ 'qa-engineer': '#059669',
20
+ qa: '#059669',
21
+ 'ux-designer': '#d97706',
22
+ ux: '#d97706',
23
+ 'product-manager': '#dc2626',
24
+ pm: '#dc2626',
25
+ 'security-engineer': '#6b7280',
26
+ 'team-lead': '#6b7280',
27
+ explore: '#94a3b8',
28
+ };
29
+
30
+ const DEFAULT_COLOR = '#6b7280';
31
+ const MAIN_COLOR = '#084471';
32
+
33
+ // SVG text cannot resolve CSS custom properties, so use literal font family
34
+ const SVG_FONT = "'IBM Plex Mono', monospace";
35
+
36
+ function getAgentColor(role?: string): string {
37
+ if (!role) return DEFAULT_COLOR;
38
+ return AGENT_COLORS[role.toLowerCase()] ?? DEFAULT_COLOR;
39
+ }
40
+
41
+ // ── Time helpers ───────────────────────────────────────────────
42
+
43
+ function getSessionStart(s: Session): number {
44
+ return new Date(s.date).getTime();
45
+ }
46
+
47
+ function getSessionEnd(s: Session): number {
48
+ if (s.endTime) return new Date(s.endTime).getTime();
49
+ return getSessionStart(s) + s.durationMinutes * 60_000;
50
+ }
51
+
52
+ function formatGap(ms: number): string {
53
+ const hours = ms / 3_600_000;
54
+ if (hours < 20) {
55
+ const h = Math.round(hours);
56
+ return `${h}h`;
57
+ }
58
+ const days = Math.round(hours / 24);
59
+ if (days < 1) return `${Math.round(hours)}h`;
60
+ return days === 1 ? '1 day' : `${days} days`;
61
+ }
62
+
63
+ function formatDateLabel(ms: number): string {
64
+ const d = new Date(ms);
65
+ const months = [
66
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
67
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
68
+ ];
69
+ return `${months[d.getMonth()]} ${d.getDate()}`;
70
+ }
71
+
72
+ function formatTimeLabel(ms: number): string {
73
+ const d = new Date(ms);
74
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
75
+ }
76
+
77
+ // ── Segment computation ────────────────────────────────────────
78
+
79
+ const GAP_THRESHOLD_MS = 60 * 60_000; // 1 hour
80
+ const GAP_PX = 56;
81
+ const PX_PER_MINUTE = 5;
82
+ const MIN_SESSION_WIDTH = 90;
83
+
84
+ interface SessionSegment {
85
+ type: 'session';
86
+ session: Session;
87
+ startMs: number;
88
+ endMs: number;
89
+ }
90
+
91
+ /** Multiple sessions running at the same time */
92
+ interface ConcurrentSegment {
93
+ type: 'concurrent';
94
+ sessions: Session[];
95
+ startMs: number;
96
+ endMs: number;
97
+ }
98
+
99
+ interface GapSegment {
100
+ type: 'gap';
101
+ durationMs: number;
102
+ }
103
+
104
+ type Segment = SessionSegment | ConcurrentSegment | GapSegment;
105
+
106
+ /**
107
+ * Cluster overlapping sessions into groups.
108
+ * Uses a sweep-line: sessions that overlap with any session in the current
109
+ * cluster (by start < clusterEnd) get merged into the same cluster.
110
+ */
111
+ function clusterOverlapping(
112
+ sorted: Session[],
113
+ ): Array<{ sessions: Session[]; startMs: number; endMs: number }> {
114
+ const clusters: Array<{ sessions: Session[]; startMs: number; endMs: number }> = [];
115
+ let current: { sessions: Session[]; startMs: number; endMs: number } | null = null;
116
+
117
+ for (const s of sorted) {
118
+ const start = getSessionStart(s);
119
+ const end = getSessionEnd(s);
120
+
121
+ if (!current || start >= current.endMs) {
122
+ if (current) clusters.push(current);
123
+ current = { sessions: [s], startMs: start, endMs: end };
124
+ } else {
125
+ current.sessions.push(s);
126
+ if (end > current.endMs) current.endMs = end;
127
+ }
128
+ }
129
+
130
+ if (current) clusters.push(current);
131
+ return clusters;
132
+ }
133
+
134
+ export function computeSegments(sessions: Session[]): Segment[] {
135
+ if (sessions.length === 0) return [];
136
+
137
+ const sorted = [...sessions].sort(
138
+ (a, b) => getSessionStart(a) - getSessionStart(b),
139
+ );
140
+
141
+ const clusters = clusterOverlapping(sorted);
142
+ const segments: Segment[] = [];
143
+
144
+ for (let i = 0; i < clusters.length; i++) {
145
+ const cluster = clusters[i];
146
+
147
+ if (i > 0) {
148
+ const prevEnd = clusters[i - 1].endMs;
149
+ const gapMs = cluster.startMs - prevEnd;
150
+ if (gapMs > GAP_THRESHOLD_MS) {
151
+ segments.push({ type: 'gap', durationMs: gapMs });
152
+ }
153
+ }
154
+
155
+ if (cluster.sessions.length === 1) {
156
+ const s = cluster.sessions[0];
157
+ segments.push({
158
+ type: 'session',
159
+ session: s,
160
+ startMs: cluster.startMs,
161
+ endMs: cluster.endMs,
162
+ });
163
+ } else {
164
+ segments.push({
165
+ type: 'concurrent',
166
+ sessions: cluster.sessions,
167
+ startMs: cluster.startMs,
168
+ endMs: cluster.endMs,
169
+ });
170
+ }
171
+ }
172
+
173
+ return segments;
174
+ }
175
+
176
+ // ── Determine if all sessions are same-day ─────────────────────
177
+
178
+ function isSameDay(ms1: number, ms2: number): boolean {
179
+ const d1 = new Date(ms1);
180
+ const d2 = new Date(ms2);
181
+ return (
182
+ d1.getFullYear() === d2.getFullYear() &&
183
+ d1.getMonth() === d2.getMonth() &&
184
+ d1.getDate() === d2.getDate()
185
+ );
186
+ }
187
+
188
+ interface DateTick {
189
+ label: string;
190
+ x: number;
191
+ }
192
+
193
+ // ── Layout constants ────────────────────────────────────────────
194
+
195
+ const PADDING_LEFT = 32;
196
+ const PADDING_RIGHT = 32;
197
+ const LABEL_AREA_TOP = 36;
198
+ const AXIS_AREA_BOTTOM = 28;
199
+ const LANE_SPACING = 24;
200
+ const FORK_INSET = 24;
201
+ const CURVE_DX = 18;
202
+
203
+ /**
204
+ * Get the number of fork lanes a session needs.
205
+ * Uses childSessions (full data) first, then children (summaries), then childCount.
206
+ */
207
+ function getChildLaneCount(session: Session): number {
208
+ if (session.childSessions && session.childSessions.length > 0) return session.childSessions.length;
209
+ if (session.children && session.children.length > 0) return session.children.length;
210
+ return 0;
211
+ }
212
+
213
+ /**
214
+ * Get the renderable children for fork/join — prefers full childSessions,
215
+ * falls back to children summaries.
216
+ */
217
+ function getRenderableChildren(session: Session): Array<{
218
+ id: string;
219
+ role?: string;
220
+ durationMinutes: number;
221
+ linesOfCode: number;
222
+ date?: string;
223
+ }> {
224
+ if (session.childSessions && session.childSessions.length > 0) {
225
+ return session.childSessions.map((c) => ({
226
+ id: c.id,
227
+ role: c.agentRole,
228
+ durationMinutes: c.durationMinutes,
229
+ linesOfCode: c.linesOfCode,
230
+ date: c.date,
231
+ }));
232
+ }
233
+ if (session.children && session.children.length > 0) {
234
+ return session.children.map((c) => ({
235
+ id: c.sessionId,
236
+ role: c.role,
237
+ durationMinutes: c.durationMinutes ?? 0,
238
+ linesOfCode: c.linesOfCode ?? 0,
239
+ date: c.date,
240
+ }));
241
+ }
242
+ return [];
243
+ }
244
+
245
+ // ── Component ──────────────────────────────────────────────────
246
+
247
+ /** Truncate title to fit within a pixel width (rough: ~5.5px per char at 10px font) */
248
+ function truncateTitle(title: string, maxPx: number): string {
249
+ const maxChars = Math.floor(maxPx / 5.5);
250
+ if (title.length <= maxChars) return title;
251
+ return title.slice(0, Math.max(maxChars - 1, 8)) + '…';
252
+ }
253
+
254
+ export function WorkTimeline({ sessions, onSessionClick }: WorkTimelineProps) {
255
+ const scrollRef = useRef<HTMLDivElement>(null);
256
+
257
+ const scrollBy = useCallback((delta: number) => {
258
+ scrollRef.current?.scrollBy({ left: delta, behavior: 'smooth' });
259
+ }, []);
260
+
261
+ if (sessions.length === 0) {
262
+ return (
263
+ <div className="work-timeline" data-testid="work-timeline-empty">
264
+ <p style={{
265
+ fontFamily: SVG_FONT,
266
+ fontSize: '0.75rem',
267
+ color: '#6b7280',
268
+ }}>
269
+ No sessions to display.
270
+ </p>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ const segments = computeSegments(sessions);
276
+
277
+ // Compute total width from segments
278
+ let totalContentWidth = 0;
279
+ for (const seg of segments) {
280
+ if (seg.type === 'gap') {
281
+ totalContentWidth += GAP_PX;
282
+ } else {
283
+ const durationMin = (seg.endMs - seg.startMs) / 60_000;
284
+ totalContentWidth += Math.max(durationMin * PX_PER_MINUTE, MIN_SESSION_WIDTH);
285
+ }
286
+ }
287
+
288
+ const svgWidth = PADDING_LEFT + totalContentWidth + PADDING_RIGHT;
289
+
290
+ // Compute max lanes needed for fork/join (from child sessions or concurrent sessions)
291
+ let maxLanes = 0;
292
+ for (const seg of segments) {
293
+ if (seg.type === 'session') {
294
+ const lanes = getChildLaneCount(seg.session);
295
+ if (lanes > maxLanes) maxLanes = lanes;
296
+ } else if (seg.type === 'concurrent') {
297
+ if (seg.sessions.length > maxLanes) maxLanes = seg.sessions.length;
298
+ }
299
+ }
300
+
301
+ // Dynamic height based on max lanes
302
+ const baseLaneHeight = 48;
303
+ const forkSpread = maxLanes > 0 ? maxLanes * LANE_SPACING + 16 : 0;
304
+ const laneAreaHeight = Math.max(baseLaneHeight, forkSpread);
305
+ const mainY = LABEL_AREA_TOP + laneAreaHeight / 2;
306
+ const svgHeight = LABEL_AREA_TOP + laneAreaHeight + AXIS_AREA_BOTTOM;
307
+
308
+ // Determine if same-day for time label formatting
309
+ const allSessionSegs = segments.filter(
310
+ (s): s is SessionSegment | ConcurrentSegment => s.type !== 'gap',
311
+ );
312
+ const firstMs = allSessionSegs[0].startMs;
313
+ const lastMs = allSessionSegs[allSessionSegs.length - 1].endMs;
314
+ const sameDay = isSameDay(firstMs, lastMs);
315
+
316
+ // Build x-position map for segments
317
+ interface SegmentLayout {
318
+ seg: Segment;
319
+ x1: number;
320
+ x2: number;
321
+ }
322
+
323
+ const layouts: SegmentLayout[] = [];
324
+ let cursor = PADDING_LEFT;
325
+
326
+ for (const seg of segments) {
327
+ if (seg.type === 'gap') {
328
+ const x1 = cursor;
329
+ const x2 = cursor + GAP_PX;
330
+ layouts.push({ seg, x1, x2 });
331
+ cursor = x2;
332
+ } else {
333
+ const durationMin = (seg.endMs - seg.startMs) / 60_000;
334
+ const w = Math.max(durationMin * PX_PER_MINUTE, MIN_SESSION_WIDTH);
335
+ const x1 = cursor;
336
+ const x2 = cursor + w;
337
+ layouts.push({ seg, x1, x2 });
338
+ cursor = x2;
339
+ }
340
+ }
341
+
342
+ // Compute date ticks
343
+ const dateTicks: DateTick[] = [];
344
+ for (const layout of layouts) {
345
+ if (layout.seg.type !== 'gap') {
346
+ dateTicks.push({
347
+ label: sameDay ? formatTimeLabel(layout.seg.startMs) : formatDateLabel(layout.seg.startMs),
348
+ x: layout.x1,
349
+ });
350
+ }
351
+ }
352
+
353
+ // Deduplicate date ticks that are too close
354
+ const filteredTicks: DateTick[] = [];
355
+ for (const tick of dateTicks) {
356
+ const prev = filteredTicks[filteredTicks.length - 1];
357
+ if (!prev || tick.x - prev.x > 70) {
358
+ filteredTicks.push(tick);
359
+ }
360
+ }
361
+
362
+ const needsScroll = svgWidth > 800;
363
+
364
+ return (
365
+ <div className="work-timeline" data-testid="work-timeline" style={{ position: 'relative' }}>
366
+ {needsScroll && (
367
+ <div className="work-timeline__scroll-controls">
368
+ <button
369
+ type="button"
370
+ className="work-timeline__scroll-btn"
371
+ onClick={() => scrollBy(-300)}
372
+ aria-label="Scroll left"
373
+ >
374
+ &#8592;
375
+ </button>
376
+ <button
377
+ type="button"
378
+ className="work-timeline__scroll-btn"
379
+ onClick={() => scrollBy(300)}
380
+ aria-label="Scroll right"
381
+ >
382
+ &#8594;
383
+ </button>
384
+ </div>
385
+ )}
386
+ <div
387
+ className="work-timeline__container"
388
+ ref={scrollRef}
389
+ style={{
390
+ overflowX: 'auto',
391
+ WebkitOverflowScrolling: 'touch',
392
+ paddingBottom: 'var(--spacing-2, 0.5rem)',
393
+ }}
394
+ >
395
+ <svg
396
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
397
+ xmlns="http://www.w3.org/2000/svg"
398
+ role="img"
399
+ aria-label={`Work timeline showing ${sessions.length} sessions`}
400
+ width={svgWidth}
401
+ height={svgHeight}
402
+ style={{ display: 'block' }}
403
+ >
404
+ {/* Time axis line */}
405
+ <line
406
+ x1={PADDING_LEFT}
407
+ y1={svgHeight - AXIS_AREA_BOTTOM + 4}
408
+ x2={svgWidth - PADDING_RIGHT}
409
+ y2={svgHeight - AXIS_AREA_BOTTOM + 4}
410
+ stroke="#e5e7eb"
411
+ strokeWidth={1}
412
+ data-testid="axis-line"
413
+ />
414
+
415
+ {/* Date/time tick labels */}
416
+ {filteredTicks.map((tick, i) => (
417
+ <g key={i} data-testid="axis-tick">
418
+ <line
419
+ x1={tick.x}
420
+ y1={svgHeight - AXIS_AREA_BOTTOM + 1}
421
+ x2={tick.x}
422
+ y2={svgHeight - AXIS_AREA_BOTTOM + 7}
423
+ stroke="#d1d5db"
424
+ strokeWidth={1}
425
+ />
426
+ <text
427
+ x={tick.x}
428
+ y={svgHeight - 6}
429
+ fontFamily={SVG_FONT}
430
+ fontSize={8}
431
+ fill="#9ca3af"
432
+ textAnchor="start"
433
+ >
434
+ {tick.label}
435
+ </text>
436
+ </g>
437
+ ))}
438
+
439
+ {/* Render each segment */}
440
+ {layouts.map((layout, i) => {
441
+ if (layout.seg.type === 'gap') {
442
+ return (
443
+ <g key={`gap-${i}`} data-testid="gap-segment">
444
+ <line
445
+ x1={layout.x1 + 4}
446
+ y1={mainY}
447
+ x2={layout.x2 - 4}
448
+ y2={mainY}
449
+ stroke="#d1d5db"
450
+ strokeWidth={1.5}
451
+ strokeDasharray="4,4"
452
+ />
453
+ <text
454
+ x={(layout.x1 + layout.x2) / 2}
455
+ y={mainY - 8}
456
+ fontFamily={SVG_FONT}
457
+ fontSize={8}
458
+ fontStyle="italic"
459
+ fill="#9ca3af"
460
+ textAnchor="middle"
461
+ data-testid="gap-label"
462
+ >
463
+ {formatGap(layout.seg.durationMs)}
464
+ </text>
465
+ </g>
466
+ );
467
+ }
468
+
469
+ if (layout.seg.type === 'concurrent') {
470
+ return (
471
+ <ConcurrentSessionBar
472
+ key={`concurrent-${i}`}
473
+ x1={layout.x1}
474
+ x2={layout.x2}
475
+ mainY={mainY}
476
+ sessions={layout.seg.sessions}
477
+ clusterStartMs={layout.seg.startMs}
478
+ clusterEndMs={layout.seg.endMs}
479
+ onSessionClick={onSessionClick}
480
+ />
481
+ );
482
+ }
483
+
484
+ const seg = layout.seg;
485
+ const session = seg.session;
486
+ const renderableChildren = getRenderableChildren(session);
487
+ const childCount = session.childCount ?? session.children?.length ?? 0;
488
+ const hasChildren = renderableChildren.length > 0;
489
+ const isMultiAgent = hasChildren || childCount > 0;
490
+
491
+ const durationLabel = `${session.durationMinutes}m`;
492
+ const locLabel = session.linesOfCode > 0 ? `${session.linesOfCode} LOC` : '';
493
+ const subtitle = [durationLabel, locLabel].filter(Boolean).join(' \u00b7 ');
494
+
495
+ // Compute label Y: push up when fork/join lanes exist
496
+ const labelOffsetY = hasChildren ? renderableChildren.length * LANE_SPACING / 2 + 18 : 18;
497
+
498
+ return (
499
+ <g
500
+ key={`session-${i}`}
501
+ data-testid="session-segment"
502
+ style={onSessionClick ? { cursor: 'pointer' } : undefined}
503
+ onClick={onSessionClick ? () => onSessionClick(session) : undefined}
504
+ role={onSessionClick ? 'button' : undefined}
505
+ tabIndex={onSessionClick ? 0 : undefined}
506
+ onKeyDown={onSessionClick ? (e) => { if (e.key === 'Enter') onSessionClick(session); } : undefined}
507
+ aria-label={`${session.title}, ${durationLabel}${childCount > 0 ? `, ${childCount} agents` : ''}`}
508
+ >
509
+ {/* Title above bar */}
510
+ <text
511
+ x={layout.x1 + 6}
512
+ y={mainY - labelOffsetY}
513
+ fontFamily={SVG_FONT}
514
+ fontSize={10}
515
+ fontWeight={600}
516
+ fill="#191c1e"
517
+ data-testid="session-title"
518
+ >
519
+ {truncateTitle(session.title, layout.x2 - layout.x1 - 12)}
520
+ </text>
521
+ <text
522
+ x={layout.x1 + 6}
523
+ y={mainY - labelOffsetY + 12}
524
+ fontFamily={SVG_FONT}
525
+ fontSize={8}
526
+ fill="#6b7280"
527
+ data-testid="session-subtitle"
528
+ >
529
+ {subtitle}
530
+ </text>
531
+
532
+ {hasChildren ? (
533
+ <ForkJoinBar
534
+ x1={layout.x1}
535
+ x2={layout.x2}
536
+ mainY={mainY}
537
+ children={renderableChildren}
538
+ />
539
+ ) : isMultiAgent ? (
540
+ <ThickAgentBar
541
+ x1={layout.x1}
542
+ x2={layout.x2}
543
+ mainY={mainY}
544
+ agentCount={childCount}
545
+ />
546
+ ) : (
547
+ <SingleBar
548
+ x1={layout.x1}
549
+ x2={layout.x2}
550
+ mainY={mainY}
551
+ />
552
+ )}
553
+ </g>
554
+ );
555
+ })}
556
+ </svg>
557
+ </div>
558
+ </div>
559
+ );
560
+ }
561
+
562
+ // ── Single session bar ─────────────────────────────────────────
563
+
564
+ function SingleBar({ x1, x2, mainY }: { x1: number; x2: number; mainY: number }) {
565
+ return (
566
+ <g data-testid="single-bar">
567
+ <circle cx={x1} cy={mainY} r={4} fill="none" stroke={MAIN_COLOR} strokeWidth={1.5} />
568
+ <line x1={x1} y1={mainY} x2={x2} y2={mainY} stroke={MAIN_COLOR} strokeWidth={2.5} strokeLinecap="round" />
569
+ <circle cx={x2} cy={mainY} r={4} fill={MAIN_COLOR} />
570
+ </g>
571
+ );
572
+ }
573
+
574
+ // ── Thick bar for unloaded multi-agent (count only, no timing data) ──
575
+
576
+ function ThickAgentBar({
577
+ x1, x2, mainY, agentCount,
578
+ }: {
579
+ x1: number; x2: number; mainY: number; agentCount: number;
580
+ }) {
581
+ return (
582
+ <g data-testid="thick-bar">
583
+ <circle cx={x1} cy={mainY} r={4} fill="none" stroke={MAIN_COLOR} strokeWidth={1.5} />
584
+ <line x1={x1} y1={mainY} x2={x2} y2={mainY} stroke={MAIN_COLOR} strokeWidth={4.5} strokeLinecap="round" />
585
+ <circle cx={x2} cy={mainY} r={4} fill={MAIN_COLOR} />
586
+ <text
587
+ x={(x1 + x2) / 2}
588
+ y={mainY + 18}
589
+ fontFamily={SVG_FONT}
590
+ fontSize={8}
591
+ fill="#6b7280"
592
+ textAnchor="middle"
593
+ data-testid="agent-count-badge"
594
+ >
595
+ ({agentCount} agents)
596
+ </text>
597
+ </g>
598
+ );
599
+ }
600
+
601
+ // ── Fork/join bar for multi-agent sessions ──────────────────────
602
+
603
+ interface RenderableChild {
604
+ id: string;
605
+ role?: string;
606
+ durationMinutes: number;
607
+ linesOfCode: number;
608
+ date?: string;
609
+ }
610
+
611
+ function ForkJoinBar({
612
+ x1, x2, mainY, children,
613
+ }: {
614
+ x1: number; x2: number; mainY: number; children: RenderableChild[];
615
+ }) {
616
+ const sorted = [...children].sort((a, b) => {
617
+ // Sort by date if available, otherwise keep original order
618
+ if (a.date && b.date) return new Date(a.date).getTime() - new Date(b.date).getTime();
619
+ return 0;
620
+ });
621
+
622
+ const n = sorted.length;
623
+ const totalH = (n - 1) * LANE_SPACING;
624
+
625
+ // Fork/join points
626
+ const forkX = x1 + FORK_INSET;
627
+ const joinX = x2 - FORK_INSET;
628
+ const laneStartX = forkX + CURVE_DX + 4;
629
+ const laneEndX = joinX - CURVE_DX - 4;
630
+ const laneWidth = Math.max(laneEndX - laneStartX, 20);
631
+
632
+ // Compute proportional widths for each child
633
+ const maxDuration = Math.max(...sorted.map((c) => c.durationMinutes), 1);
634
+
635
+ return (
636
+ <g data-testid="multi-agent-bar">
637
+ {/* Main line before fork */}
638
+ <circle cx={x1} cy={mainY} r={4} fill="none" stroke={MAIN_COLOR} strokeWidth={1.5} />
639
+ <line x1={x1} y1={mainY} x2={forkX} y2={mainY} stroke={MAIN_COLOR} strokeWidth={2.5} strokeLinecap="round" />
640
+
641
+ {/* Fork dot */}
642
+ <circle cx={forkX} cy={mainY} r={4} fill={MAIN_COLOR} data-testid="fork-dot" />
643
+
644
+ {/* Child lanes */}
645
+ {sorted.map((child, i) => {
646
+ const laneY = mainY - totalH / 2 + i * LANE_SPACING;
647
+ const color = getAgentColor(child.role);
648
+ const childLaneWidth = Math.max(24, (child.durationMinutes / maxDuration) * laneWidth);
649
+
650
+ return (
651
+ <g key={child.id || i} data-testid="child-lane">
652
+ {/* Fork curve */}
653
+ <path
654
+ d={`M${forkX},${mainY} C${forkX + CURVE_DX},${mainY} ${forkX + CURVE_DX},${laneY} ${laneStartX},${laneY}`}
655
+ stroke={color}
656
+ strokeWidth={1.5}
657
+ fill="none"
658
+ strokeDasharray="4,3"
659
+ />
660
+
661
+ {/* Lane background */}
662
+ <rect
663
+ x={laneStartX}
664
+ y={laneY - 10}
665
+ rx={2}
666
+ ry={2}
667
+ width={childLaneWidth}
668
+ height={20}
669
+ fill={color}
670
+ opacity={0.06}
671
+ />
672
+
673
+ {/* Lane line */}
674
+ <line
675
+ x1={laneStartX}
676
+ y1={laneY}
677
+ x2={laneStartX + childLaneWidth}
678
+ y2={laneY}
679
+ stroke={color}
680
+ strokeWidth={2.5}
681
+ strokeLinecap="round"
682
+ />
683
+
684
+ {/* Role label to the right */}
685
+ <text
686
+ x={laneStartX + childLaneWidth + 6}
687
+ y={laneY + 3}
688
+ fontFamily={SVG_FONT}
689
+ fontSize={8}
690
+ fill={color}
691
+ fontWeight={600}
692
+ data-testid="child-role-label"
693
+ >
694
+ {(child.role ?? 'agent').toUpperCase()}
695
+ </text>
696
+
697
+ {/* Join curve */}
698
+ <path
699
+ d={`M${laneStartX + childLaneWidth},${laneY} C${joinX - CURVE_DX},${laneY} ${joinX - CURVE_DX},${mainY} ${joinX},${mainY}`}
700
+ stroke={color}
701
+ strokeWidth={1.5}
702
+ fill="none"
703
+ strokeDasharray="4,3"
704
+ />
705
+ </g>
706
+ );
707
+ })}
708
+
709
+ {/* Join dot */}
710
+ <circle cx={joinX} cy={mainY} r={4} fill={MAIN_COLOR} data-testid="join-dot" />
711
+
712
+ {/* Main line after join */}
713
+ <line x1={joinX} y1={mainY} x2={x2} y2={mainY} stroke={MAIN_COLOR} strokeWidth={2.5} strokeLinecap="round" />
714
+ <circle cx={x2} cy={mainY} r={4} fill={MAIN_COLOR} />
715
+ </g>
716
+ );
717
+ }
718
+
719
+ // ── Concurrent top-level sessions (overlapping in time) ─────────
720
+
721
+ const CONCURRENT_COLORS = ['#084471', '#7c3aed', '#0891b2', '#059669', '#d97706', '#dc2626'];
722
+
723
+ function ConcurrentSessionBar({
724
+ x1, x2, mainY, sessions, clusterStartMs, clusterEndMs, onSessionClick,
725
+ }: {
726
+ x1: number; x2: number; mainY: number;
727
+ sessions: Session[];
728
+ clusterStartMs: number; clusterEndMs: number;
729
+ onSessionClick?: (session: Session) => void;
730
+ }) {
731
+ const sorted = [...sessions].sort(
732
+ (a, b) => getSessionStart(a) - getSessionStart(b),
733
+ );
734
+
735
+ const n = sorted.length;
736
+ const totalH = (n - 1) * LANE_SPACING;
737
+ const forkX = x1 + 12;
738
+ const joinX = x2 - 12;
739
+ const laneAreaWidth = joinX - forkX - CURVE_DX * 2 - 8;
740
+ const clusterDurationMs = clusterEndMs - clusterStartMs;
741
+
742
+ return (
743
+ <g data-testid="concurrent-segment">
744
+ {/* Main line into fork */}
745
+ <circle cx={x1} cy={mainY} r={4} fill="none" stroke={MAIN_COLOR} strokeWidth={1.5} />
746
+ <line x1={x1} y1={mainY} x2={forkX} y2={mainY} stroke={MAIN_COLOR} strokeWidth={2.5} strokeLinecap="round" />
747
+ <circle cx={forkX} cy={mainY} r={4} fill={MAIN_COLOR} data-testid="concurrent-fork" />
748
+
749
+ {/* Session lanes */}
750
+ {sorted.map((session, i) => {
751
+ const laneY = mainY - totalH / 2 + i * LANE_SPACING;
752
+ const color = CONCURRENT_COLORS[i % CONCURRENT_COLORS.length];
753
+
754
+ // Position lane proportionally within the cluster time range
755
+ const sessionStart = getSessionStart(session);
756
+ const sessionEnd = getSessionEnd(session);
757
+ const startFrac = clusterDurationMs > 0 ? (sessionStart - clusterStartMs) / clusterDurationMs : 0;
758
+ const endFrac = clusterDurationMs > 0 ? (sessionEnd - clusterStartMs) / clusterDurationMs : 1;
759
+ const laneStartX = forkX + CURVE_DX + 4 + startFrac * laneAreaWidth;
760
+ const laneEndX = forkX + CURVE_DX + 4 + endFrac * laneAreaWidth;
761
+ const laneW = Math.max(laneEndX - laneStartX, 20);
762
+
763
+ const durationLabel = `${session.durationMinutes}m`;
764
+ const locLabel = session.linesOfCode > 0 ? `${session.linesOfCode} LOC` : '';
765
+ const subtitle = [durationLabel, locLabel].filter(Boolean).join(' · ');
766
+
767
+ return (
768
+ <g
769
+ key={session.id || i}
770
+ data-testid="concurrent-lane"
771
+ style={onSessionClick ? { cursor: 'pointer' } : undefined}
772
+ onClick={onSessionClick ? (e) => { e.stopPropagation(); onSessionClick(session); } : undefined}
773
+ role={onSessionClick ? 'button' : undefined}
774
+ tabIndex={onSessionClick ? 0 : undefined}
775
+ >
776
+ {/* Fork curve from main line to lane */}
777
+ <path
778
+ d={`M${forkX},${mainY} C${forkX + CURVE_DX},${mainY} ${forkX + CURVE_DX},${laneY} ${laneStartX},${laneY}`}
779
+ stroke={color}
780
+ strokeWidth={1.5}
781
+ fill="none"
782
+ strokeDasharray="4,3"
783
+ />
784
+
785
+ {/* Lane background */}
786
+ <rect
787
+ x={laneStartX}
788
+ y={laneY - 10}
789
+ rx={2}
790
+ ry={2}
791
+ width={laneW}
792
+ height={20}
793
+ fill={color}
794
+ opacity={0.06}
795
+ />
796
+
797
+ {/* Lane bar */}
798
+ <line
799
+ x1={laneStartX}
800
+ y1={laneY}
801
+ x2={laneStartX + laneW}
802
+ y2={laneY}
803
+ stroke={color}
804
+ strokeWidth={2.5}
805
+ strokeLinecap="round"
806
+ />
807
+
808
+ {/* Session title to the left */}
809
+ <text
810
+ x={laneStartX - 4}
811
+ y={laneY + 3}
812
+ fontFamily={SVG_FONT}
813
+ fontSize={8}
814
+ fontWeight={600}
815
+ fill={color}
816
+ textAnchor="end"
817
+ data-testid="concurrent-title"
818
+ >
819
+ {session.title.length > 30 ? session.title.slice(0, 28) + '…' : session.title}
820
+ </text>
821
+
822
+ {/* Stats after the lane */}
823
+ <text
824
+ x={laneStartX + laneW + 6}
825
+ y={laneY + 3}
826
+ fontFamily={SVG_FONT}
827
+ fontSize={7}
828
+ fill="#9ca3af"
829
+ >
830
+ {subtitle}
831
+ </text>
832
+
833
+ {/* Join curve from lane to main line */}
834
+ <path
835
+ d={`M${laneStartX + laneW},${laneY} C${joinX - CURVE_DX},${laneY} ${joinX - CURVE_DX},${mainY} ${joinX},${mainY}`}
836
+ stroke={color}
837
+ strokeWidth={1.5}
838
+ fill="none"
839
+ strokeDasharray="4,3"
840
+ />
841
+ </g>
842
+ );
843
+ })}
844
+
845
+ {/* Join dot */}
846
+ <circle cx={joinX} cy={mainY} r={4} fill={MAIN_COLOR} data-testid="concurrent-join" />
847
+
848
+ {/* Main line out */}
849
+ <line x1={joinX} y1={mainY} x2={x2} y2={mainY} stroke={MAIN_COLOR} strokeWidth={2.5} strokeLinecap="round" />
850
+ <circle cx={x2} cy={mainY} r={4} fill={MAIN_COLOR} />
851
+ </g>
852
+ );
853
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { WorkTimeline, computeSegments } from './WorkTimeline';
2
+ export type { WorkTimelineProps } from './WorkTimeline';
3
+ export type {
4
+ Session,
5
+ ChildSessionSummary,
6
+ ExecutionStep,
7
+ ToolUsage,
8
+ FileChange,
9
+ TurnEvent,
10
+ QaPair,
11
+ } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,73 @@
1
+ export interface ExecutionStep {
2
+ stepNumber: number;
3
+ title: string;
4
+ description: string;
5
+ type?: 'analysis' | 'implementation' | 'testing' | 'deployment' | 'decision';
6
+ }
7
+
8
+ export interface ToolUsage {
9
+ tool: string;
10
+ count: number;
11
+ }
12
+
13
+ export interface FileChange {
14
+ path: string;
15
+ additions: number;
16
+ deletions: number;
17
+ editCount?: number;
18
+ }
19
+
20
+ export interface TurnEvent {
21
+ timestamp: string;
22
+ type: 'prompt' | 'response' | 'tool' | 'error';
23
+ content: string;
24
+ turnNumber?: number;
25
+ tools?: string[];
26
+ }
27
+
28
+ export interface QaPair {
29
+ question: string;
30
+ answer: string;
31
+ }
32
+
33
+ export interface ChildSessionSummary {
34
+ sessionId: string;
35
+ role?: string;
36
+ title?: string;
37
+ durationMinutes?: number;
38
+ linesOfCode?: number;
39
+ date?: string;
40
+ }
41
+
42
+ export interface Session {
43
+ id: string;
44
+ title: string;
45
+ date: string;
46
+ endTime?: string;
47
+ durationMinutes: number;
48
+ wallClockMinutes?: number;
49
+ turns: number;
50
+ linesOfCode: number;
51
+ status: 'draft' | 'enhanced' | 'published' | 'archived' | 'sealed';
52
+ projectName: string;
53
+ rawLog: string[];
54
+ sessionRef?: string;
55
+ context?: string;
56
+ developerTake?: string;
57
+ skills?: string[];
58
+ executionPath?: ExecutionStep[];
59
+ toolBreakdown?: ToolUsage[];
60
+ filesChanged?: FileChange[];
61
+ turnTimeline?: TurnEvent[];
62
+ toolCalls?: number;
63
+ qaPairs?: QaPair[];
64
+ childSessions?: Session[];
65
+ parentSessionId?: string | null;
66
+ agentRole?: string;
67
+ isOrchestrated?: boolean;
68
+ childCount?: number;
69
+ children?: ChildSessionSummary[];
70
+ cwd?: string;
71
+ quickEnhanced?: boolean;
72
+ source?: string;
73
+ }