fleetlens 0.2.8 → 0.3.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.
Files changed (89) hide show
  1. package/app/apps/web/.next/BUILD_ID +1 -1
  2. package/app/apps/web/.next/build-manifest.json +3 -3
  3. package/app/apps/web/.next/prerender-manifest.json +3 -3
  4. package/app/apps/web/.next/server/app/_global-error.html +1 -1
  5. package/app/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/app/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/app/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/app/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/app/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/app/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/app/apps/web/.next/server/app/_not-found/page.js +1 -1
  12. package/app/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  13. package/app/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/app/apps/web/.next/server/app/page.js +3 -3
  15. package/app/apps/web/.next/server/app/page.js.nft.json +1 -1
  16. package/app/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  17. package/app/apps/web/.next/server/app/parallelism/page.js +1 -1
  18. package/app/apps/web/.next/server/app/parallelism/page.js.nft.json +1 -1
  19. package/app/apps/web/.next/server/app/parallelism/page_client-reference-manifest.js +1 -1
  20. package/app/apps/web/.next/server/app/projects/[slug]/page.js +3 -3
  21. package/app/apps/web/.next/server/app/projects/[slug]/page.js.nft.json +1 -1
  22. package/app/apps/web/.next/server/app/projects/[slug]/page_client-reference-manifest.js +1 -1
  23. package/app/apps/web/.next/server/app/projects/page.js +1 -1
  24. package/app/apps/web/.next/server/app/projects/page.js.nft.json +1 -1
  25. package/app/apps/web/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  26. package/app/apps/web/.next/server/app/sessions/[id]/page.js +3 -2
  27. package/app/apps/web/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  28. package/app/apps/web/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
  29. package/app/apps/web/.next/server/app/sessions/page.js +1 -1
  30. package/app/apps/web/.next/server/app/sessions/page.js.nft.json +1 -1
  31. package/app/apps/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  32. package/app/apps/web/.next/server/app/usage/page.js +1 -1
  33. package/app/apps/web/.next/server/app/usage/page.js.nft.json +1 -1
  34. package/app/apps/web/.next/server/app/usage/page_client-reference-manifest.js +1 -1
  35. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0op9af~._.js +1 -1
  36. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__00z7zlm._.js +3 -0
  37. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__05lbtny._.js +12 -0
  38. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0ms7h0~._.js +1 -1
  39. package/app/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__08cr2u9._.js → [root-of-the-server]__0p~g4m6._.js} +2 -2
  40. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0w52fbc._.js +1 -1
  41. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0wvzyl7._.js +3 -0
  42. package/app/apps/web/.next/server/chunks/ssr/_09n75_5._.js +1 -1
  43. package/app/apps/web/.next/server/chunks/ssr/_0ph19-k._.js +12 -0
  44. package/app/apps/web/.next/server/chunks/ssr/_0u29mdd._.js +3 -0
  45. package/app/apps/web/.next/server/chunks/ssr/_0xies90._.js +1 -1
  46. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_06j3c.0._.js +1 -1
  47. package/app/apps/web/.next/server/chunks/ssr/packages_parser_dist_0-jvt~a._.js +1 -1
  48. package/app/apps/web/.next/server/chunks/ssr/packages_parser_dist_analytics_0k4dgst.js +1 -1
  49. package/app/apps/web/.next/server/middleware-build-manifest.js +3 -3
  50. package/app/apps/web/.next/server/pages/500.html +1 -1
  51. package/app/apps/web/.next/server/server-reference-manifest.js +1 -1
  52. package/app/apps/web/.next/server/server-reference-manifest.json +1 -1
  53. package/app/apps/web/.next/static/chunks/03.mv4y4.w-vb.js +6 -0
  54. package/app/apps/web/.next/static/chunks/08983lz4~hffx.js +6 -0
  55. package/app/apps/web/.next/static/chunks/0fc6dk2qtokwh.js +1 -0
  56. package/app/apps/web/.next/static/chunks/0p1lpoh02zh8h.js +10 -0
  57. package/app/apps/web/.next/static/chunks/{03fw7f2obcvq~.js → 14q3xsfpkvuef.js} +2 -2
  58. package/app/apps/web/app/layout.tsx +2 -0
  59. package/app/apps/web/app/projects/[slug]/page.tsx +2 -0
  60. package/app/apps/web/app/sessions/[id]/page.tsx +27 -1
  61. package/app/apps/web/app/sessions/[id]/session-view.tsx +372 -793
  62. package/app/apps/web/app/sessions/[id]/team-tab/adapter.ts +633 -0
  63. package/app/apps/web/app/sessions/[id]/team-tab/format.ts +20 -0
  64. package/app/apps/web/app/sessions/[id]/team-tab/team-minimap.tsx +682 -0
  65. package/app/apps/web/app/sessions/[id]/team-tab/team-tab-client.tsx +78 -0
  66. package/app/apps/web/app/sessions/[id]/team-tab/team-table.tsx +796 -0
  67. package/app/apps/web/app/sessions/[id]/team-tab/turn-drawer.tsx +805 -0
  68. package/app/apps/web/app/sessions/[id]/tool-cards.tsx +461 -0
  69. package/app/apps/web/app/sessions/[id]/turn-steps.tsx +306 -0
  70. package/app/apps/web/app/sessions/sessions-grid.tsx +36 -5
  71. package/app/apps/web/components/live-sessions-widget.tsx +20 -6
  72. package/app/apps/web/components/team-badge.tsx +90 -0
  73. package/app/apps/web/package.json +1 -1
  74. package/app/apps/web/tsconfig.tsbuildinfo +1 -1
  75. package/dist/daemon-worker.js +74 -21
  76. package/dist/index.js +93 -23
  77. package/package.json +1 -1
  78. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0b..h_8._.js +0 -3
  79. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0cdd~8n._.js +0 -3
  80. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0~a~v00._.js +0 -3
  81. package/app/apps/web/.next/server/chunks/ssr/_0zv~jv-._.js +0 -12
  82. package/app/apps/web/.next/server/chunks/ssr/_12dtryh._.js +0 -12
  83. package/app/apps/web/.next/static/chunks/07oyxajlp3dvb.js +0 -6
  84. package/app/apps/web/.next/static/chunks/0a24elrqoqqep.js +0 -10
  85. package/app/apps/web/.next/static/chunks/0i4zodv86~~~i.js +0 -1
  86. package/app/apps/web/.next/static/chunks/0rbm60-qyshdm.js +0 -6
  87. /package/app/apps/web/.next/static/{CH5VTNGEbso0WpO5B8BTA → 0fPDRTdMQ-Y62h6fHNJXL}/_buildManifest.js +0 -0
  88. /package/app/apps/web/.next/static/{CH5VTNGEbso0WpO5B8BTA → 0fPDRTdMQ-Y62h6fHNJXL}/_clientMiddlewareManifest.js +0 -0
  89. /package/app/apps/web/.next/static/{CH5VTNGEbso0WpO5B8BTA → 0fPDRTdMQ-Y62h6fHNJXL}/_ssgManifest.js +0 -0
@@ -0,0 +1,633 @@
1
+ import { formatGap } from "@/lib/format";
2
+ import type {
3
+ TeamView,
4
+ SessionDetail,
5
+ PresentationRow,
6
+ SubagentRun,
7
+ TurnMegaRow,
8
+ } from "@claude-lens/parser";
9
+ import { buildPresentation, buildMegaRows } from "@claude-lens/parser";
10
+
11
+ export type TeamTrack = {
12
+ id: string;
13
+ label: string;
14
+ color: string;
15
+ isLead: boolean;
16
+ presentationRows: PresentationRow[];
17
+ subagents: SubagentRun[];
18
+ sessionStartMs: number;
19
+ turns: TeamTurn[];
20
+ };
21
+
22
+ export type TeamTurn = {
23
+ id: string;
24
+ trackId: string;
25
+ startMs: number;
26
+ endMs: number;
27
+ megaRow: TurnMegaRow;
28
+ durationMs: number;
29
+ userPrompt?: PresentationRow;
30
+ };
31
+
32
+ export type IdleBand = {
33
+ kind: "idle";
34
+ startMs: number;
35
+ endMs: number;
36
+ durationMs: number;
37
+ yPx: number;
38
+ heightPx: number;
39
+ };
40
+
41
+ export type YAnchor = {
42
+ tsMs: number;
43
+ yPx: number;
44
+ };
45
+
46
+ export type XAnchor = {
47
+ tsMs: number;
48
+ xFrac: number;
49
+ };
50
+
51
+ /** Idle band expressed as an x-range for the minimap. Unlike the body's
52
+ * IdleBand (yPx), this is expressed as normalized fractions [0, 1] so
53
+ * the minimap's horizontal scale stays independent of pixel width. */
54
+ export type MinimapIdleBand = {
55
+ startMs: number;
56
+ endMs: number;
57
+ durationMs: number;
58
+ xFracStart: number;
59
+ xFracEnd: number;
60
+ label: string;
61
+ };
62
+
63
+ /** When the total team span exceeds DAY_PAGE_HOURS we switch the minimap
64
+ * from a single compressed strip into day pages, each rendered on its own
65
+ * x-scale so a single day has the whole minimap width to itself. Page
66
+ * nav arrows move between pages. */
67
+ export type DayPage = {
68
+ startMs: number;
69
+ endMs: number;
70
+ label: string;
71
+ xAnchors: XAnchor[];
72
+ minimapIdleBands: MinimapIdleBand[];
73
+ };
74
+
75
+ export type TimelineData = {
76
+ tracks: TeamTrack[];
77
+ yAnchors: YAnchor[];
78
+ idleBands: IdleBand[];
79
+ totalHeightPx: number;
80
+ firstEventMs: number;
81
+ lastEventMs: number;
82
+ timeTicks: { tsMs: number; yPx: number; label: string }[];
83
+ /** Event-anchored x-scale for the minimap. Same spirit as yAnchors:
84
+ * active intervals get proportional width, all-idle stretches collapse.
85
+ * When dayPages is set, minimap consumers should prefer those; this one
86
+ * still covers the full span for consumers that don't paginate. */
87
+ xAnchors: XAnchor[];
88
+ minimapIdleBands: MinimapIdleBand[];
89
+ /** True when the team spans more than one local day — consumers can
90
+ * show full dates in hover cards / labels instead of just times. */
91
+ multiDay: boolean;
92
+ /** Present when the team span exceeds DAY_PAGE_HOURS. The minimap
93
+ * splits itself across these pages instead of cramming everything
94
+ * into one compressed strip. */
95
+ dayPages?: DayPage[];
96
+ };
97
+
98
+ /** Code-level knob for the day-page width. 24h is the default that fits
99
+ * most multi-day team traces; 12h is available for fleets that run in
100
+ * shorter bursts. Not exposed to users. */
101
+ const DAY_PAGE_HOURS = 24;
102
+ const DAY_PAGE_MS = DAY_PAGE_HOURS * 60 * 60 * 1000;
103
+
104
+ const LEAD_COLOR = "#f0b429";
105
+ const MEMBER_COLORS = [
106
+ "#58a6ff",
107
+ "#b58cf0",
108
+ "#3fb950",
109
+ "#f85149",
110
+ "#db6d28",
111
+ "#ff7b72",
112
+ "#56d364",
113
+ ];
114
+
115
+ const TURN_MIN_HEIGHT = 140;
116
+ function turnPreferredHeight(durationMs: number): number {
117
+ const minutes = durationMs / 60_000;
118
+ // Gentle log growth so a 10-hour turn doesn't blow up the page.
119
+ const bonus = Math.min(220, 80 * Math.log10(minutes + 1));
120
+ return TURN_MIN_HEIGHT + bonus;
121
+ }
122
+
123
+ function idleBandHeight(gapMs: number): number {
124
+ if (gapMs < 10_000) return 6;
125
+ if (gapMs < 60_000) return 18;
126
+ if (gapMs < 10 * 60_000) return 24;
127
+ if (gapMs < 60 * 60_000) return 32;
128
+ return 44;
129
+ }
130
+
131
+ // Gaps smaller than this between turn activity are NOT collapsed — they stay
132
+ // proportional to keep short pauses readable.
133
+ const ALL_IDLE_THRESHOLD_MS = 30_000;
134
+
135
+ export function teamViewToTimelineData(
136
+ view: TeamView,
137
+ details: Map<string, SessionDetail>,
138
+ ): TimelineData {
139
+ const tracks: TeamTrack[] = [];
140
+
141
+ const lead = details.get(view.leadSessionId);
142
+ if (lead) tracks.push(buildTrack(lead, "LEAD", LEAD_COLOR, true));
143
+
144
+ view.memberSessionIds.forEach((id, i) => {
145
+ const d = details.get(id);
146
+ if (!d) return;
147
+ const label = view.agentNameBySessionId.get(id) ?? id.slice(0, 8);
148
+ tracks.push(
149
+ buildTrack(d, label, MEMBER_COLORS[i % MEMBER_COLORS.length]!, false),
150
+ );
151
+ });
152
+
153
+ const allTurns = tracks.flatMap((t) => t.turns);
154
+ allTurns.sort((a, b) => a.startMs - b.startMs);
155
+
156
+ const { yAnchors, idleBands, totalHeightPx, timeTicks } = buildYFunction(
157
+ allTurns,
158
+ view.firstEventMs,
159
+ view.lastEventMs,
160
+ );
161
+
162
+ const multiDay = isMultiDay(view.firstEventMs, view.lastEventMs);
163
+ const { xAnchors, minimapIdleBands } = buildXFunction(
164
+ allTurns,
165
+ view.firstEventMs,
166
+ view.lastEventMs,
167
+ multiDay,
168
+ );
169
+
170
+ const spanMs = view.lastEventMs - view.firstEventMs;
171
+ const dayPages =
172
+ spanMs > DAY_PAGE_MS
173
+ ? buildDayPages(allTurns, view.firstEventMs, view.lastEventMs, multiDay)
174
+ : undefined;
175
+
176
+ return {
177
+ tracks,
178
+ yAnchors,
179
+ idleBands,
180
+ totalHeightPx,
181
+ firstEventMs: view.firstEventMs,
182
+ lastEventMs: view.lastEventMs,
183
+ timeTicks,
184
+ xAnchors,
185
+ minimapIdleBands,
186
+ multiDay,
187
+ dayPages,
188
+ };
189
+ }
190
+
191
+ /** Walk the full span in DAY_PAGE_HOURS slices starting at the local-day
192
+ * boundary that contains firstEventMs. Each page runs buildXFunction
193
+ * over just the turns that overlap its window so the compressed scale
194
+ * is tight to that day's actual activity. */
195
+ function buildDayPages(
196
+ allTurns: TeamTurn[],
197
+ firstEventMs: number,
198
+ lastEventMs: number,
199
+ multiDay: boolean,
200
+ ): DayPage[] {
201
+ // Anchor page 0 at local midnight of firstEventMs so successive pages
202
+ // line up with real calendar days (feels natural when labels show
203
+ // "Mar 27", "Mar 28", etc.).
204
+ const firstDate = new Date(firstEventMs);
205
+ const pageStart = new Date(
206
+ firstDate.getFullYear(),
207
+ firstDate.getMonth(),
208
+ firstDate.getDate(),
209
+ ).getTime();
210
+
211
+ const pages: DayPage[] = [];
212
+ let cursor = pageStart;
213
+ while (cursor < lastEventMs) {
214
+ const startMs = cursor;
215
+ const endMs = Math.min(lastEventMs, cursor + DAY_PAGE_MS);
216
+ const clampedStart = Math.max(startMs, firstEventMs);
217
+ // Keep any turn that touches this window.
218
+ const pageTurns = allTurns.filter(
219
+ (t) => t.endMs >= clampedStart && t.startMs <= endMs,
220
+ );
221
+ // Skip empty days. A long team span can have multi-day gaps where
222
+ // nothing actually happened — showing blank pages for those just
223
+ // forces the user to click through noise. The minimap's job is to
224
+ // visualize activity, so days without any are dropped entirely.
225
+ if (pageTurns.length === 0) {
226
+ cursor += DAY_PAGE_MS;
227
+ continue;
228
+ }
229
+ const windowStart = clampedStart;
230
+ const windowEnd = endMs;
231
+ const { xAnchors, minimapIdleBands } = buildXFunction(
232
+ pageTurns,
233
+ windowStart,
234
+ windowEnd,
235
+ multiDay,
236
+ );
237
+ pages.push({
238
+ startMs: windowStart,
239
+ endMs: windowEnd,
240
+ label: formatDayLabel(startMs),
241
+ xAnchors,
242
+ minimapIdleBands,
243
+ });
244
+ cursor += DAY_PAGE_MS;
245
+ }
246
+ return pages;
247
+ }
248
+
249
+ function formatDayLabel(ms: number): string {
250
+ return new Date(ms).toLocaleDateString([], {
251
+ month: "short",
252
+ day: "numeric",
253
+ });
254
+ }
255
+
256
+ type ActiveRegion = { startMs: number; endMs: number };
257
+
258
+ function mergeActiveRegions(turns: TeamTurn[]): ActiveRegion[] {
259
+ const sorted = [...turns].sort((a, b) => a.startMs - b.startMs);
260
+ const active: ActiveRegion[] = [];
261
+ for (const t of sorted) {
262
+ const last = active[active.length - 1];
263
+ if (last && t.startMs <= last.endMs) {
264
+ if (t.endMs > last.endMs) last.endMs = t.endMs;
265
+ } else {
266
+ active.push({ startMs: t.startMs, endMs: t.endMs });
267
+ }
268
+ }
269
+ return active;
270
+ }
271
+
272
+ function isMultiDay(firstMs: number, lastMs: number): boolean {
273
+ const a = new Date(firstMs);
274
+ const b = new Date(lastMs);
275
+ return (
276
+ a.getFullYear() !== b.getFullYear() ||
277
+ a.getMonth() !== b.getMonth() ||
278
+ a.getDate() !== b.getDate()
279
+ );
280
+ }
281
+
282
+ function buildTrack(
283
+ d: SessionDetail,
284
+ label: string,
285
+ color: string,
286
+ isLead: boolean,
287
+ ): TeamTrack {
288
+ const sessionStartMs = d.firstTimestamp ? Date.parse(d.firstTimestamp) : 0;
289
+ const presentationRows = buildPresentation(d.events);
290
+ const mega = buildMegaRows(presentationRows);
291
+
292
+ const turns: TeamTurn[] = [];
293
+ let turnIndex = 0;
294
+ let lastUserRow: PresentationRow | undefined;
295
+ for (const m of mega) {
296
+ if (m.kind === "user") {
297
+ lastUserRow = m;
298
+ continue;
299
+ }
300
+ if (m.kind !== "turn") continue;
301
+ if (m.tOffsetMs === undefined || m.durationMs === undefined) continue;
302
+ const startMs = sessionStartMs + m.tOffsetMs;
303
+ // Never zero — always at least 1s so layout math never divides by 0.
304
+ const durationMs = Math.max(1000, m.durationMs);
305
+ turns.push({
306
+ id: `${d.sessionId}:${turnIndex++}`,
307
+ trackId: d.sessionId,
308
+ startMs,
309
+ endMs: startMs + durationMs,
310
+ megaRow: m,
311
+ durationMs,
312
+ userPrompt: lastUserRow,
313
+ });
314
+ }
315
+
316
+ return {
317
+ id: d.sessionId,
318
+ label,
319
+ color,
320
+ isLead,
321
+ presentationRows,
322
+ subagents: d.subagents ?? [],
323
+ sessionStartMs,
324
+ turns,
325
+ };
326
+ }
327
+
328
+ function buildYFunction(
329
+ allTurns: TeamTurn[],
330
+ firstEventMs: number,
331
+ lastEventMs: number,
332
+ ): {
333
+ yAnchors: YAnchor[];
334
+ idleBands: IdleBand[];
335
+ totalHeightPx: number;
336
+ timeTicks: { tsMs: number; yPx: number; label: string }[];
337
+ } {
338
+ if (allTurns.length === 0) {
339
+ return {
340
+ yAnchors: [
341
+ { tsMs: firstEventMs, yPx: 0 },
342
+ { tsMs: lastEventMs, yPx: 0 },
343
+ ],
344
+ idleBands: [],
345
+ totalHeightPx: 0,
346
+ timeTicks: [],
347
+ };
348
+ }
349
+
350
+ const active = mergeActiveRegions(allTurns);
351
+
352
+ const anchorSet = new Set<number>();
353
+ anchorSet.add(firstEventMs);
354
+ anchorSet.add(lastEventMs);
355
+ for (const a of active) {
356
+ anchorSet.add(a.startMs);
357
+ anchorSet.add(a.endMs);
358
+ }
359
+ for (const t of allTurns) {
360
+ anchorSet.add(t.startMs);
361
+ anchorSet.add(t.endMs);
362
+ }
363
+ const anchorTimes = [...anchorSet].sort((a, b) => a - b);
364
+
365
+ type Interval = {
366
+ startMs: number;
367
+ endMs: number;
368
+ isIdle: boolean;
369
+ heightPx: number;
370
+ };
371
+ const intervals: Interval[] = [];
372
+ for (let i = 0; i < anchorTimes.length - 1; i++) {
373
+ const startMs = anchorTimes[i]!;
374
+ const endMs = anchorTimes[i + 1]!;
375
+ const durationMs = endMs - startMs;
376
+ if (durationMs <= 0) continue;
377
+
378
+ const insideActive = active.some(
379
+ (a) => a.startMs <= startMs && a.endMs >= endMs,
380
+ );
381
+ if (!insideActive && durationMs >= ALL_IDLE_THRESHOLD_MS) {
382
+ intervals.push({
383
+ startMs,
384
+ endMs,
385
+ isIdle: true,
386
+ heightPx: idleBandHeight(durationMs),
387
+ });
388
+ continue;
389
+ }
390
+
391
+ // Baseline so short active intervals still have enough room to render.
392
+ let needed = 24;
393
+ for (const t of allTurns) {
394
+ const overlap = Math.min(t.endMs, endMs) - Math.max(t.startMs, startMs);
395
+ if (overlap <= 0) continue;
396
+ const fraction = overlap / t.durationMs;
397
+ const contribution = turnPreferredHeight(t.durationMs) * fraction;
398
+ if (contribution > needed) needed = contribution;
399
+ }
400
+ intervals.push({ startMs, endMs, isIdle: false, heightPx: needed });
401
+ }
402
+
403
+ const yAnchors: YAnchor[] = [{ tsMs: anchorTimes[0]!, yPx: 0 }];
404
+ let cursorY = 0;
405
+ for (let i = 0; i < intervals.length; i++) {
406
+ cursorY += intervals[i]!.heightPx;
407
+ yAnchors.push({ tsMs: intervals[i]!.endMs, yPx: cursorY });
408
+ }
409
+ const totalHeightPx = cursorY;
410
+
411
+ const idleBands: IdleBand[] = [];
412
+ let cursor2 = 0;
413
+ for (const i of intervals) {
414
+ if (i.isIdle) {
415
+ idleBands.push({
416
+ kind: "idle",
417
+ startMs: i.startMs,
418
+ endMs: i.endMs,
419
+ durationMs: i.endMs - i.startMs,
420
+ yPx: cursor2,
421
+ heightPx: i.heightPx,
422
+ });
423
+ }
424
+ cursor2 += i.heightPx;
425
+ }
426
+
427
+ const timeTicks = yAnchors.map((a) => ({
428
+ tsMs: a.tsMs,
429
+ yPx: a.yPx,
430
+ label: formatTimeLabel(a.tsMs),
431
+ }));
432
+
433
+ return { yAnchors, idleBands, totalHeightPx, timeTicks };
434
+ }
435
+
436
+ function formatTimeLabel(ms: number): string {
437
+ return new Date(ms).toLocaleTimeString([], {
438
+ hour: "2-digit",
439
+ minute: "2-digit",
440
+ });
441
+ }
442
+
443
+ /* ----------- X function (minimap horizontal scale) ----------- */
444
+
445
+ /** Synthetic "ms weight" each active interval contributes. Used to keep
446
+ * short active bursts visually wide enough to click while still letting
447
+ * genuinely long activity dominate. */
448
+ const ACTIVE_MIN_WEIGHT_MS = 45_000;
449
+
450
+ /** Synthetic "ms weight" each idle interval collapses to. Doesn't scale
451
+ * with real duration — a 10-minute gap and a 10-hour overnight gap both
452
+ * take roughly the same horizontal space, with the actual duration and
453
+ * date carried in the hatched band's label. */
454
+ function idleWeightMs(gapMs: number): number {
455
+ if (gapMs < 60_000) return 20_000;
456
+ if (gapMs < 10 * 60_000) return 40_000;
457
+ if (gapMs < 60 * 60_000) return 60_000;
458
+ if (gapMs < 6 * 60 * 60_000) return 80_000;
459
+ return 100_000; // multi-hour, multi-day — effectively capped
460
+ }
461
+
462
+ function buildXFunction(
463
+ allTurns: TeamTurn[],
464
+ firstEventMs: number,
465
+ lastEventMs: number,
466
+ multiDay: boolean,
467
+ ): {
468
+ xAnchors: XAnchor[];
469
+ minimapIdleBands: MinimapIdleBand[];
470
+ } {
471
+ if (allTurns.length === 0 || lastEventMs <= firstEventMs) {
472
+ return {
473
+ xAnchors: [
474
+ { tsMs: firstEventMs, xFrac: 0 },
475
+ { tsMs: lastEventMs, xFrac: 1 },
476
+ ],
477
+ minimapIdleBands: [],
478
+ };
479
+ }
480
+
481
+ const active = mergeActiveRegions(allTurns);
482
+
483
+ // Build the interval list: alternating active / idle spans that cover
484
+ // [firstEventMs, lastEventMs] end to end.
485
+ type Interval = {
486
+ startMs: number;
487
+ endMs: number;
488
+ isIdle: boolean;
489
+ weight: number;
490
+ };
491
+ const intervals: Interval[] = [];
492
+ let cursor = firstEventMs;
493
+ for (const a of active) {
494
+ if (a.startMs > cursor) {
495
+ const gap = a.startMs - cursor;
496
+ intervals.push({
497
+ startMs: cursor,
498
+ endMs: a.startMs,
499
+ isIdle: gap >= ALL_IDLE_THRESHOLD_MS,
500
+ weight:
501
+ gap >= ALL_IDLE_THRESHOLD_MS
502
+ ? idleWeightMs(gap)
503
+ : Math.max(ACTIVE_MIN_WEIGHT_MS, gap),
504
+ });
505
+ }
506
+ const dur = a.endMs - a.startMs;
507
+ intervals.push({
508
+ startMs: a.startMs,
509
+ endMs: a.endMs,
510
+ isIdle: false,
511
+ weight: Math.max(ACTIVE_MIN_WEIGHT_MS, dur),
512
+ });
513
+ cursor = a.endMs;
514
+ }
515
+ if (cursor < lastEventMs) {
516
+ const gap = lastEventMs - cursor;
517
+ intervals.push({
518
+ startMs: cursor,
519
+ endMs: lastEventMs,
520
+ isIdle: gap >= ALL_IDLE_THRESHOLD_MS,
521
+ weight:
522
+ gap >= ALL_IDLE_THRESHOLD_MS
523
+ ? idleWeightMs(gap)
524
+ : Math.max(ACTIVE_MIN_WEIGHT_MS, gap),
525
+ });
526
+ }
527
+
528
+ const totalWeight = intervals.reduce((s, i) => s + i.weight, 0) || 1;
529
+
530
+ const xAnchors: XAnchor[] = [{ tsMs: firstEventMs, xFrac: 0 }];
531
+ let cumWeight = 0;
532
+ for (const i of intervals) {
533
+ cumWeight += i.weight;
534
+ xAnchors.push({ tsMs: i.endMs, xFrac: cumWeight / totalWeight });
535
+ }
536
+
537
+ const minimapIdleBands: MinimapIdleBand[] = [];
538
+ let walkWeight = 0;
539
+ for (const i of intervals) {
540
+ const start = walkWeight / totalWeight;
541
+ walkWeight += i.weight;
542
+ const end = walkWeight / totalWeight;
543
+ if (i.isIdle) {
544
+ minimapIdleBands.push({
545
+ startMs: i.startMs,
546
+ endMs: i.endMs,
547
+ durationMs: i.endMs - i.startMs,
548
+ xFracStart: start,
549
+ xFracEnd: end,
550
+ label: idleLabel(i.startMs, i.endMs, multiDay),
551
+ });
552
+ }
553
+ }
554
+
555
+ return { xAnchors, minimapIdleBands };
556
+ }
557
+
558
+ function idleLabel(startMs: number, endMs: number, multiDay: boolean): string {
559
+ const dur = `${formatGap(endMs - startMs)} idle`;
560
+ if (!multiDay) return dur;
561
+ const a = new Date(startMs);
562
+ const b = new Date(endMs);
563
+ const sameDay =
564
+ a.getFullYear() === b.getFullYear() &&
565
+ a.getMonth() === b.getMonth() &&
566
+ a.getDate() === b.getDate();
567
+ if (sameDay) return dur;
568
+ const toDate = b.toLocaleDateString([], { month: "short", day: "numeric" });
569
+ return `${dur} → ${toDate}`;
570
+ }
571
+
572
+ export function xOfMs(anchors: XAnchor[], tsMs: number): number {
573
+ if (anchors.length === 0) return 0;
574
+ if (tsMs <= anchors[0]!.tsMs) return anchors[0]!.xFrac;
575
+ if (tsMs >= anchors[anchors.length - 1]!.tsMs)
576
+ return anchors[anchors.length - 1]!.xFrac;
577
+
578
+ let lo = 0;
579
+ let hi = anchors.length - 1;
580
+ while (hi - lo > 1) {
581
+ const mid = (lo + hi) >> 1;
582
+ if (anchors[mid]!.tsMs <= tsMs) lo = mid;
583
+ else hi = mid;
584
+ }
585
+ const a = anchors[lo]!;
586
+ const b = anchors[hi]!;
587
+ if (b.tsMs === a.tsMs) return a.xFrac;
588
+ const frac = (tsMs - a.tsMs) / (b.tsMs - a.tsMs);
589
+ return a.xFrac + frac * (b.xFrac - a.xFrac);
590
+ }
591
+
592
+ /** Inverse of xOfMs — given an xFrac in [0, 1], return the wall-clock ms
593
+ * that lives at that compressed position. Used by the minimap's click-to-
594
+ * seek. */
595
+ export function msOfXFrac(anchors: XAnchor[], xFrac: number): number {
596
+ if (anchors.length === 0) return 0;
597
+ if (xFrac <= anchors[0]!.xFrac) return anchors[0]!.tsMs;
598
+ if (xFrac >= anchors[anchors.length - 1]!.xFrac)
599
+ return anchors[anchors.length - 1]!.tsMs;
600
+
601
+ let lo = 0;
602
+ let hi = anchors.length - 1;
603
+ while (hi - lo > 1) {
604
+ const mid = (lo + hi) >> 1;
605
+ if (anchors[mid]!.xFrac <= xFrac) lo = mid;
606
+ else hi = mid;
607
+ }
608
+ const a = anchors[lo]!;
609
+ const b = anchors[hi]!;
610
+ if (b.xFrac === a.xFrac) return a.tsMs;
611
+ const frac = (xFrac - a.xFrac) / (b.xFrac - a.xFrac);
612
+ return a.tsMs + frac * (b.tsMs - a.tsMs);
613
+ }
614
+
615
+ export function yOfMs(anchors: YAnchor[], tsMs: number): number {
616
+ if (anchors.length === 0) return 0;
617
+ if (tsMs <= anchors[0]!.tsMs) return anchors[0]!.yPx;
618
+ if (tsMs >= anchors[anchors.length - 1]!.tsMs)
619
+ return anchors[anchors.length - 1]!.yPx;
620
+
621
+ let lo = 0;
622
+ let hi = anchors.length - 1;
623
+ while (hi - lo > 1) {
624
+ const mid = (lo + hi) >> 1;
625
+ if (anchors[mid]!.tsMs <= tsMs) lo = mid;
626
+ else hi = mid;
627
+ }
628
+ const a = anchors[lo]!;
629
+ const b = anchors[hi]!;
630
+ if (b.tsMs === a.tsMs) return a.yPx;
631
+ const frac = (tsMs - a.tsMs) / (b.tsMs - a.tsMs);
632
+ return a.yPx + frac * (b.yPx - a.yPx);
633
+ }
@@ -0,0 +1,20 @@
1
+ import type { SessionEvent } from "@claude-lens/parser";
2
+
3
+ type TeammateMsg = NonNullable<SessionEvent["teammateMessage"]>;
4
+
5
+ export function formatTeammatePreview(tm: TeammateMsg): string {
6
+ switch (tm.kind) {
7
+ case "idle-notification":
8
+ return `${tm.teammateId} is idle / available`;
9
+ case "shutdown-request":
10
+ return `${tm.teammateId} requesting shutdown`;
11
+ case "shutdown-approved":
12
+ return `${tm.teammateId} shutdown approved`;
13
+ case "teammate-terminated":
14
+ return `${tm.teammateId} has shut down`;
15
+ case "task-assignment":
16
+ return `task assigned to ${tm.teammateId}`;
17
+ default:
18
+ return tm.body.length > 120 ? tm.body.slice(0, 120) + "…" : tm.body;
19
+ }
20
+ }