fleetlens 0.13.0 → 0.13.2

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 (211) hide show
  1. package/app/apps/web/.next/BUILD_ID +1 -1
  2. package/app/apps/web/.next/build-manifest.json +8 -8
  3. package/app/apps/web/.next/prerender-manifest.json +3 -3
  4. package/app/apps/web/.next/server/app/_global-error/page/build-manifest.json +5 -5
  5. package/app/apps/web/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/app/apps/web/.next/server/app/_global-error.html +1 -1
  7. package/app/apps/web/.next/server/app/_global-error.rsc +7 -7
  8. package/app/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  9. package/app/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  10. package/app/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  11. package/app/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  12. package/app/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/app/apps/web/.next/server/app/_not-found/page/build-manifest.json +5 -5
  14. package/app/apps/web/.next/server/app/_not-found/page.js +1 -1
  15. package/app/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  16. package/app/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/app/apps/web/.next/server/app/api/ask/route.js.nft.json +1 -1
  18. package/app/apps/web/.next/server/app/api/digest/day/[date]/route.js +1 -1
  19. package/app/apps/web/.next/server/app/api/digest/day/[date]/route.js.nft.json +1 -1
  20. package/app/apps/web/.next/server/app/api/digest/month/[yearMonth]/route.js +1 -1
  21. package/app/apps/web/.next/server/app/api/digest/month/[yearMonth]/route.js.nft.json +1 -1
  22. package/app/apps/web/.next/server/app/api/digest/month-index/route.js.nft.json +1 -1
  23. package/app/apps/web/.next/server/app/api/digest/week/[startDate]/route.js +1 -1
  24. package/app/apps/web/.next/server/app/api/digest/week/[startDate]/route.js.nft.json +1 -1
  25. package/app/apps/web/.next/server/app/api/digest/week-index/route.js.nft.json +1 -1
  26. package/app/apps/web/.next/server/app/api/events/route.js.nft.json +1 -1
  27. package/app/apps/web/.next/server/app/api/jobs/route.js.nft.json +1 -1
  28. package/app/apps/web/.next/server/app/api/pdf/[key]/route.js.nft.json +1 -1
  29. package/app/apps/web/.next/server/app/api/runs/[runId]/route.js.nft.json +1 -1
  30. package/app/apps/web/.next/server/app/api/runs/[runId]/stream/route.js.nft.json +1 -1
  31. package/app/apps/web/.next/server/app/api/runs/route.js.nft.json +1 -1
  32. package/app/apps/web/.next/server/app/api/settings/route.js.nft.json +1 -1
  33. package/app/apps/web/.next/server/app/api/team-config/route.js.nft.json +1 -1
  34. package/app/apps/web/.next/server/app/api/workflow-agent/route.js.nft.json +1 -1
  35. package/app/apps/web/.next/server/app/changelog/page/build-manifest.json +5 -5
  36. package/app/apps/web/.next/server/app/changelog/page.js +1 -1
  37. package/app/apps/web/.next/server/app/changelog/page.js.nft.json +1 -1
  38. package/app/apps/web/.next/server/app/changelog/page_client-reference-manifest.js +1 -1
  39. package/app/apps/web/.next/server/app/day/[date]/page/build-manifest.json +5 -5
  40. package/app/apps/web/.next/server/app/day/[date]/page.js +1 -1
  41. package/app/apps/web/.next/server/app/day/[date]/page.js.nft.json +1 -1
  42. package/app/apps/web/.next/server/app/day/[date]/page_client-reference-manifest.js +1 -1
  43. package/app/apps/web/.next/server/app/day/page/build-manifest.json +5 -5
  44. package/app/apps/web/.next/server/app/day/page.js +1 -1
  45. package/app/apps/web/.next/server/app/day/page.js.nft.json +1 -1
  46. package/app/apps/web/.next/server/app/day/page_client-reference-manifest.js +1 -1
  47. package/app/apps/web/.next/server/app/digest/[date]/page/build-manifest.json +5 -5
  48. package/app/apps/web/.next/server/app/digest/[date]/page.js +1 -1
  49. package/app/apps/web/.next/server/app/digest/[date]/page.js.nft.json +1 -1
  50. package/app/apps/web/.next/server/app/digest/[date]/page_client-reference-manifest.js +1 -1
  51. package/app/apps/web/.next/server/app/digest/page/build-manifest.json +5 -5
  52. package/app/apps/web/.next/server/app/digest/page.js +1 -1
  53. package/app/apps/web/.next/server/app/digest/page.js.nft.json +1 -1
  54. package/app/apps/web/.next/server/app/digest/page_client-reference-manifest.js +1 -1
  55. package/app/apps/web/.next/server/app/insights/[key]/page/build-manifest.json +5 -5
  56. package/app/apps/web/.next/server/app/insights/[key]/page.js +1 -1
  57. package/app/apps/web/.next/server/app/insights/[key]/page.js.nft.json +1 -1
  58. package/app/apps/web/.next/server/app/insights/[key]/page_client-reference-manifest.js +1 -1
  59. package/app/apps/web/.next/server/app/insights/page/build-manifest.json +5 -5
  60. package/app/apps/web/.next/server/app/insights/page.js +3 -2
  61. package/app/apps/web/.next/server/app/insights/page.js.nft.json +1 -1
  62. package/app/apps/web/.next/server/app/insights/page_client-reference-manifest.js +1 -1
  63. package/app/apps/web/.next/server/app/page/build-manifest.json +5 -5
  64. package/app/apps/web/.next/server/app/page.js +1 -1
  65. package/app/apps/web/.next/server/app/page.js.nft.json +1 -1
  66. package/app/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  67. package/app/apps/web/.next/server/app/parallelism/page/build-manifest.json +5 -5
  68. package/app/apps/web/.next/server/app/parallelism/page.js +1 -1
  69. package/app/apps/web/.next/server/app/parallelism/page.js.nft.json +1 -1
  70. package/app/apps/web/.next/server/app/parallelism/page_client-reference-manifest.js +1 -1
  71. package/app/apps/web/.next/server/app/print/[key]/page/build-manifest.json +5 -5
  72. package/app/apps/web/.next/server/app/print/[key]/page.js +1 -1
  73. package/app/apps/web/.next/server/app/print/[key]/page.js.nft.json +1 -1
  74. package/app/apps/web/.next/server/app/print/[key]/page_client-reference-manifest.js +1 -1
  75. package/app/apps/web/.next/server/app/projects/[slug]/page/build-manifest.json +5 -5
  76. package/app/apps/web/.next/server/app/projects/[slug]/page.js +1 -1
  77. package/app/apps/web/.next/server/app/projects/[slug]/page.js.nft.json +1 -1
  78. package/app/apps/web/.next/server/app/projects/[slug]/page_client-reference-manifest.js +1 -1
  79. package/app/apps/web/.next/server/app/projects/page/build-manifest.json +5 -5
  80. package/app/apps/web/.next/server/app/projects/page.js +1 -1
  81. package/app/apps/web/.next/server/app/projects/page.js.nft.json +1 -1
  82. package/app/apps/web/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  83. package/app/apps/web/.next/server/app/runs/page/build-manifest.json +5 -5
  84. package/app/apps/web/.next/server/app/runs/page.js +1 -1
  85. package/app/apps/web/.next/server/app/runs/page.js.nft.json +1 -1
  86. package/app/apps/web/.next/server/app/runs/page_client-reference-manifest.js +1 -1
  87. package/app/apps/web/.next/server/app/sessions/[id]/page/build-manifest.json +5 -5
  88. package/app/apps/web/.next/server/app/sessions/[id]/page.js +1 -1
  89. package/app/apps/web/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  90. package/app/apps/web/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
  91. package/app/apps/web/.next/server/app/sessions/page/build-manifest.json +5 -5
  92. package/app/apps/web/.next/server/app/sessions/page.js +1 -1
  93. package/app/apps/web/.next/server/app/sessions/page.js.nft.json +1 -1
  94. package/app/apps/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  95. package/app/apps/web/.next/server/app/settings/page/build-manifest.json +5 -5
  96. package/app/apps/web/.next/server/app/settings/page.js +1 -1
  97. package/app/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  98. package/app/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  99. package/app/apps/web/.next/server/app/team/page/build-manifest.json +5 -5
  100. package/app/apps/web/.next/server/app/team/page.js +1 -1
  101. package/app/apps/web/.next/server/app/team/page.js.nft.json +1 -1
  102. package/app/apps/web/.next/server/app/team/page_client-reference-manifest.js +1 -1
  103. package/app/apps/web/.next/server/app/usage/page/build-manifest.json +5 -5
  104. package/app/apps/web/.next/server/app/usage/page.js +1 -1
  105. package/app/apps/web/.next/server/app/usage/page.js.nft.json +1 -1
  106. package/app/apps/web/.next/server/app/usage/page_client-reference-manifest.js +1 -1
  107. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0gril0u._.js +9 -0
  108. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0n79hpm._.js +9 -0
  109. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0~utv~v._.js +9 -0
  110. package/app/apps/web/.next/server/chunks/packages_parser_dist_06d9se1._.js +1 -1
  111. package/app/apps/web/.next/server/chunks/packages_parser_dist_10ihc3~._.js +1 -1
  112. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0exckd4._.js +1 -1
  113. package/app/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0-1_njm._.js → [root-of-the-server]__0k1x-c9._.js} +2 -2
  114. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0lhwp-k._.js +8 -0
  115. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0n-ax-5._.js +1 -1
  116. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0~.f_3z._.js +3 -3
  117. package/app/apps/web/.next/server/chunks/ssr/_0kajk31._.js +1 -1
  118. package/app/apps/web/.next/server/chunks/ssr/_0s3n5mz._.js +8 -0
  119. package/app/apps/web/.next/server/chunks/ssr/_0xu2pvc._.js +1 -1
  120. package/app/apps/web/.next/server/chunks/ssr/{_0jw2sm7._.js → _12mbgs5._.js} +2 -2
  121. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_day_[date]_day-view_tsx_0wjoe1u._.js +1 -1
  122. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_insights_page_tsx_071kwyw._.js +3 -0
  123. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_sessions_sessions-grid_tsx_0bri5x.._.js +3 -0
  124. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_0l6hsch._.js +6 -0
  125. package/app/apps/web/.next/server/chunks/ssr/apps_web_lib_changelog_ts_0n7_p1d._.js +1 -1
  126. package/app/apps/web/.next/server/chunks/ssr/apps_web_lib_changelog_ts_0nb5_-s._.js +1 -1
  127. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0cepfi8._.js +3 -0
  128. package/app/apps/web/.next/server/chunks/ssr/packages_entries_dist_node_0_271yr.js +1 -1
  129. package/app/apps/web/.next/server/chunks/ssr/packages_parser_dist_0mx_509._.js +1 -1
  130. package/app/apps/web/.next/server/middleware-build-manifest.js +8 -8
  131. package/app/apps/web/.next/server/middleware-manifest.json +5 -5
  132. package/app/apps/web/.next/server/pages/500.html +1 -1
  133. package/app/apps/web/.next/server/server-reference-manifest.js +1 -1
  134. package/app/apps/web/.next/server/server-reference-manifest.json +1 -1
  135. package/app/apps/web/.next/static/chunks/02xxpexzk3rf-.js +1 -0
  136. package/app/apps/web/.next/static/chunks/{07lbm-sdh9c2j.js → 0389wb_f0j78j.js} +1 -1
  137. package/app/apps/web/.next/static/chunks/03gy1evbn94qu.js +6 -0
  138. package/app/apps/web/.next/static/chunks/03rd.3cc_t~yf.js +4 -0
  139. package/app/apps/web/.next/static/chunks/{0l4e3as18eexy.js → 0g223u9amib0q.js} +1 -1
  140. package/app/apps/web/.next/static/chunks/{0h3.-_s-k0mj3.js → 0i.hc~2bj0_3d.js} +1 -1
  141. package/app/apps/web/.next/static/chunks/0m7dd-2zd9vl2.js +4 -0
  142. package/app/apps/web/.next/static/chunks/0nqklmbs5s3ax.js +6 -0
  143. package/app/apps/web/.next/static/chunks/{073081qv4uyn8.js → 0p--hzrway8~3.js} +1 -1
  144. package/app/apps/web/.next/static/chunks/{0gblp_v.kiu-..js → 0qv-gw888_5nu.js} +1 -1
  145. package/app/apps/web/.next/static/chunks/{14bjditmmrn7l.js → 0tirv_vo6q8uc.js} +1 -1
  146. package/app/apps/web/.next/static/chunks/0uyctl-m8iydb.js +1 -0
  147. package/app/apps/web/.next/static/chunks/0~qdw1b2uz._5.js +10 -0
  148. package/app/apps/web/.next/static/chunks/{0n0n.0279ot6c.js → 14vx702c32kn6.js} +1 -1
  149. package/app/apps/web/.next/static/chunks/{turbopack-0lb9w090w5ezx.js → turbopack-0mxu3~9vk.brh.js} +1 -1
  150. package/app/apps/web/app/api/digest/_shared/digest-route-helpers.ts +178 -0
  151. package/app/apps/web/app/api/digest/day/[date]/route.ts +34 -128
  152. package/app/apps/web/app/api/digest/month/[yearMonth]/route.ts +36 -121
  153. package/app/apps/web/app/api/digest/week/[startDate]/route.ts +36 -123
  154. package/app/apps/web/app/insights/page.tsx +20 -5
  155. package/app/apps/web/app/parallelism/burst-detail-modal.tsx +637 -0
  156. package/app/apps/web/app/parallelism/concurrency-info-modal.tsx +228 -0
  157. package/app/apps/web/app/parallelism/gantt-chart-utils.ts +49 -0
  158. package/app/apps/web/app/parallelism/gantt-chart.tsx +24 -903
  159. package/app/apps/web/app/sessions/[id]/session-view/colors.ts +31 -0
  160. package/app/apps/web/app/sessions/[id]/session-view/debug-list.tsx +78 -0
  161. package/app/apps/web/app/sessions/[id]/session-view/drawer.tsx +414 -0
  162. package/app/apps/web/app/sessions/[id]/session-view/entry-day-strip.tsx +124 -0
  163. package/app/apps/web/app/sessions/[id]/session-view/header-stats.tsx +217 -0
  164. package/app/apps/web/app/sessions/[id]/session-view/helpers.ts +135 -0
  165. package/app/apps/web/app/sessions/[id]/session-view/minimap.tsx +1587 -0
  166. package/app/apps/web/app/sessions/[id]/session-view/subagent-drawer.tsx +339 -0
  167. package/app/apps/web/app/sessions/[id]/session-view/token-stats.tsx +111 -0
  168. package/app/apps/web/app/sessions/[id]/session-view/tooltip.tsx +63 -0
  169. package/app/apps/web/app/sessions/[id]/session-view/types.ts +46 -0
  170. package/app/apps/web/app/sessions/[id]/session-view/workflows-panel.tsx +944 -0
  171. package/app/apps/web/app/sessions/[id]/session-view/workflows-shared.tsx +104 -0
  172. package/app/apps/web/app/sessions/[id]/session-view.tsx +1513 -5018
  173. package/app/apps/web/app/sessions/[id]/team-tab/minimap-hover-card.tsx +201 -0
  174. package/app/apps/web/app/sessions/[id]/team-tab/minimap-idle-band.tsx +53 -0
  175. package/app/apps/web/app/sessions/[id]/team-tab/minimap-shared.ts +20 -0
  176. package/app/apps/web/app/sessions/[id]/team-tab/team-minimap.tsx +10 -265
  177. package/app/apps/web/app/sessions/sessions-grid.tsx +10 -10
  178. package/app/apps/web/components/digest-actions.tsx +103 -0
  179. package/app/apps/web/components/job-queue-widget.tsx +1 -1
  180. package/app/apps/web/components/month-digest-view.tsx +19 -64
  181. package/app/apps/web/components/searchable-select.tsx +215 -0
  182. package/app/apps/web/components/tail-mode.tsx +46 -9
  183. package/app/apps/web/components/week-digest-view.tsx +16 -71
  184. package/app/apps/web/lib/changelog-data.generated.ts +1 -1
  185. package/app/apps/web/lib/entries-index.ts +7 -8
  186. package/app/apps/web/lib/entries.ts +0 -9
  187. package/app/apps/web/lib/format.test.ts +221 -0
  188. package/app/apps/web/lib/format.ts +17 -0
  189. package/app/apps/web/lib/utilization-tone.test.ts +56 -0
  190. package/app/apps/web/package.json +1 -1
  191. package/app/apps/web/tsconfig.tsbuildinfo +1 -1
  192. package/dist/daemon-worker.js +35 -43
  193. package/dist/index.js +37 -49
  194. package/package.json +1 -1
  195. package/app/apps/web/.next/server/chunks/[root-of-the-server]__04j3tng._.js +0 -9
  196. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0cnh3k4._.js +0 -9
  197. package/app/apps/web/.next/server/chunks/[root-of-the-server]__132eosx._.js +0 -9
  198. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__136-0me._.js +0 -8
  199. package/app/apps/web/.next/server/chunks/ssr/_0w.cv64._.js +0 -8
  200. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_01d-iwx._.js +0 -6
  201. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0-lmm4p._.js +0 -3
  202. package/app/apps/web/.next/static/chunks/05k84gif3ucxm.js +0 -6
  203. package/app/apps/web/.next/static/chunks/0nnjldasalwdu.js +0 -4
  204. package/app/apps/web/.next/static/chunks/0okg13phslr1..js +0 -4
  205. package/app/apps/web/.next/static/chunks/0pzt9vemtzct8.js +0 -10
  206. package/app/apps/web/.next/static/chunks/0qmh5da6gfm56.js +0 -1
  207. package/app/apps/web/.next/static/chunks/0rnay-a.-jr8-.js +0 -1
  208. package/app/apps/web/.next/static/chunks/0snv3-2ux2xvv.js +0 -6
  209. /package/app/apps/web/.next/static/{rbp9x3iPbzrz-wxS-DAU5 → bZs5UyzsYBqC6MFY5n-M_}/_buildManifest.js +0 -0
  210. /package/app/apps/web/.next/static/{rbp9x3iPbzrz-wxS-DAU5 → bZs5UyzsYBqC6MFY5n-M_}/_clientMiddlewareManifest.js +0 -0
  211. /package/app/apps/web/.next/static/{rbp9x3iPbzrz-wxS-DAU5 → bZs5UyzsYBqC6MFY5n-M_}/_ssgManifest.js +0 -0
@@ -0,0 +1,1587 @@
1
+ "use client";
2
+
3
+ /* ------------------------------------------------------------------ */
4
+ /* Mini-map */
5
+ /* */
6
+ /* Design: */
7
+ /* - Renders ONE segment per presentation row (not per raw event) */
8
+ /* so density matches what the user sees in the transcript. */
9
+ /* - Variable heights by importance: User/Agent/Error/Interrupt */
10
+ /* full height; Tool/Model reduced; idle = full w/ stripes. */
11
+ /* - Minimum block width 5px so single events never disappear. */
12
+ /* - Scroll playhead: thin vertical line tracks the current transcript*/
13
+ /* scroll position inside the <main> container. */
14
+ /* - Time axis ticks below the bar, auto-scaled by total duration. */
15
+ /* - Rich hover card (role pill + preview) instead of bare SVG title. */
16
+ /* ------------------------------------------------------------------ */
17
+
18
+ import React, { useEffect, useMemo, useRef, useState } from "react";
19
+ import type {
20
+ PresentationRow,
21
+ PresentationRowKind,
22
+ PrMarker,
23
+ SessionEvent,
24
+ SubagentRun,
25
+ TurnMegaRow,
26
+ WorkflowRun,
27
+ } from "@claude-lens/parser";
28
+ import { rowPrimaryIndex, type DisplayRow, type MinimapSeg } from "./types";
29
+ import { subagentColor, workflowColor } from "./colors";
30
+ import { ROLE_THEMES, rowPreview, shortenToolName } from "../turn-steps";
31
+ import {
32
+ estimateCost,
33
+ formatCost,
34
+ formatGap,
35
+ formatOffset,
36
+ formatTokens,
37
+ } from "@/lib/format";
38
+
39
+ /** All rows render at full bar height — distinction comes from color/pattern
40
+ * alone, matching Claude Managed Agents' unified-height timeline. */
41
+ const ROW_IMPORTANCE: Record<PresentationRowKind, number> = {
42
+ user: 1.0,
43
+ agent: 1.0,
44
+ interrupt: 1.0,
45
+ error: 1.0,
46
+ "tool-group": 1.0,
47
+ model: 1.0,
48
+ "task-notification": 1.0,
49
+ };
50
+
51
+ export function Minimap({
52
+ displayRows,
53
+ durationMs,
54
+ dayWindow,
55
+ selectedIndex,
56
+ onSelect,
57
+ headerOffset,
58
+ subagents,
59
+ workflows,
60
+ onWorkflowClick,
61
+ selectedSubagentId,
62
+ onSelectSubagent,
63
+ prMarkers,
64
+ coldResumeMarkers,
65
+ rawIdleBands,
66
+ model,
67
+ onPlayheadChange,
68
+ }: {
69
+ displayRows: DisplayRow[];
70
+ durationMs: number;
71
+ /** When set, scope the timeline to this offset window (one local day).
72
+ * null renders the full session. */
73
+ dayWindow?: { startMs: number; endMs: number } | null;
74
+ selectedIndex: number | null;
75
+ onSelect: (i: number) => void;
76
+ headerOffset: number;
77
+ subagents?: SubagentRun[];
78
+ workflows?: WorkflowRun[];
79
+ onWorkflowClick?: (runId: string) => void;
80
+ selectedSubagentId?: string | null;
81
+ onSelectSubagent?: (id: string | null) => void;
82
+ prMarkers?: PrMarker[];
83
+ coldResumeMarkers?: {
84
+ tOffsetMs: number;
85
+ info: NonNullable<SessionEvent["coldResume"]>;
86
+ }[];
87
+ /** Idle bands derived directly from raw event timestamps. Used in place
88
+ * of the legacy "gap before user row" heuristic so Codex sessions
89
+ * (which spend most of their time in agent reasoning, not user input)
90
+ * surface their dead-air on the minimap. */
91
+ rawIdleBands?: { start: number; end: number; durationMs: number }[];
92
+ model?: string;
93
+ /** Fires with the offset (ms) of the topmost on-screen transcript row — the
94
+ * same scroll signal that draws the playhead. SessionView uses it to make
95
+ * the selected day follow scroll. */
96
+ onPlayheadChange?: (ms: number | null) => void;
97
+ }) {
98
+ const WIDTH = 1400;
99
+ /** Main timeline height. Sub-agent lanes stack below this. */
100
+ const MAIN_H = 28;
101
+ /** Per-subagent lane height including the inner gap. */
102
+ const SUB_LANE_H = 11;
103
+ /** Gap between the main timeline and the sub-agent lanes (when present). */
104
+ const SUB_LANE_GAP = 6;
105
+ /** Per-workflow lane height — a touch taller than subagent lanes so the
106
+ * workflow tier reads as distinct. */
107
+ const WF_LANE_H = 13;
108
+ const BAR_TOP = 3;
109
+ const BAR_BOT = MAIN_H - 3;
110
+ const BAR_H = BAR_BOT - BAR_TOP;
111
+ /** Gap subtracted from each segment's width so blocks never touch. */
112
+ const GAP = 3;
113
+ const MIN_BLOCK = 6;
114
+ /** Minimum displayed segment width in SVG units. Every segment gets at
115
+ * least this much space regardless of time duration, so tool-group
116
+ * blocks inside a 24-minute session are still visible + clickable. */
117
+ const MIN_DISPLAY_WIDTH = 12;
118
+ /** Error/interrupt rendered as thin vertical bars capped at this width. */
119
+ const THIN_BAR_MAX = 5;
120
+
121
+ const [hover, setHover] = useState<{
122
+ clientX: number;
123
+ row?: PresentationRow;
124
+ turn?: TurnMegaRow;
125
+ idleMs?: number;
126
+ subagent?: SubagentRun;
127
+ workflow?: WorkflowRun;
128
+ pr?: PrMarker;
129
+ cold?: {
130
+ tOffsetMs: number;
131
+ info: NonNullable<SessionEvent["coldResume"]>;
132
+ };
133
+ } | null>(null);
134
+ const [playheadMs, setPlayheadMs] = useState<number | null>(null);
135
+ // Read inside the observer so the latest callback fires without re-subscribing
136
+ // the IntersectionObserver (its effect deps stay [displayRows.length, headerOffset]).
137
+ const onPlayheadChangeRef = useRef(onPlayheadChange);
138
+ onPlayheadChangeRef.current = onPlayheadChange;
139
+ const containerRef = useRef<HTMLDivElement>(null);
140
+
141
+ /* Playhead — track which transcript row is at the top of the viewport.
142
+ *
143
+ * The old implementation ran `querySelectorAll("[data-sl-row-index]")`
144
+ * on every scroll event. For a session with ~2000 rows that's thousands
145
+ * of DOM queries per second during scroll — enough to pin a CPU core
146
+ * and make Chrome ask to kill the tab. IntersectionObserver is event-
147
+ * driven: the browser only notifies us when rows enter or leave the region
148
+ * below the sticky header, so the cost scales with *changes* instead of
149
+ * with scroll rate × row count.
150
+ */
151
+ useEffect(() => {
152
+ // The playhead observer roots on the nearest <main> ancestor. After the
153
+ // Minimap extraction the DOM topology is unchanged so this still resolves —
154
+ // but wrapping the minimap in a portal or a new scroll container would
155
+ // sever that ancestry and silently break the playhead.
156
+ const main = containerRef.current?.closest("main") as HTMLElement | null;
157
+ if (!main) return;
158
+
159
+ // Crop the observation region by the sticky header at the top and keep the
160
+ // whole viewport below it. Every row visible below the header intersects
161
+ // this region; the topmost (smallest offsetTop) is the row sitting at the
162
+ // header line — the playhead. Using the full area (not a thin band) means
163
+ // the playhead never blanks out in the gaps between sparse rows, so the
164
+ // scroll→timeline highlight — and the day-follow that rides on it — stays
165
+ // continuous. Still event-driven: a row only fires when it enters or
166
+ // leaves, so cost scales with scroll distance, not row count.
167
+ const rootMargin = `-${headerOffset}px 0px 0px 0px`;
168
+
169
+ // Rows currently visible below the header. The topmost is the playhead.
170
+ const visibleRows = new Set<HTMLElement>();
171
+
172
+ const publishTopmost = () => {
173
+ let value: number | null = null;
174
+ if (visibleRows.size > 0) {
175
+ let topmost: HTMLElement | null = null;
176
+ let topmostOff = Infinity;
177
+ for (const el of visibleRows) {
178
+ const t = el.offsetTop;
179
+ if (t < topmostOff) {
180
+ topmostOff = t;
181
+ topmost = el;
182
+ }
183
+ }
184
+ if (topmost) {
185
+ const tOff = Number(topmost.getAttribute("data-sl-toffset"));
186
+ value = Number.isNaN(tOff) ? null : tOff;
187
+ }
188
+ }
189
+ setPlayheadMs(value);
190
+ onPlayheadChangeRef.current?.(value);
191
+ };
192
+
193
+ const observer = new IntersectionObserver(
194
+ (entries) => {
195
+ for (const e of entries) {
196
+ const el = e.target as HTMLElement;
197
+ if (e.isIntersecting) visibleRows.add(el);
198
+ else visibleRows.delete(el);
199
+ }
200
+ publishTopmost();
201
+ },
202
+ { root: main, rootMargin, threshold: 0 },
203
+ );
204
+
205
+ // Observe all current rows. Re-observes whenever the row count
206
+ // changes (i.e. filter switch, turn expand/collapse).
207
+ const rows = main.querySelectorAll<HTMLDivElement>("[data-sl-row-index]");
208
+ for (const r of rows) observer.observe(r);
209
+
210
+ return () => observer.disconnect();
211
+ }, [displayRows.length, headerOffset]);
212
+
213
+ // Offset window for the rendered timeline. `winEnd` is the absolute offset
214
+ // of the right edge (session/day end); `safeDur` is the window LENGTH used
215
+ // as the proportional-width divisor. For a single-day session (dayWindow
216
+ // null) these collapse to [0, durationMs] — identical to the old behavior.
217
+ const winStart = dayWindow?.startMs ?? 0;
218
+ const winEnd = dayWindow?.endMs ?? durationMs;
219
+ const safeDur = Math.max(winEnd - winStart, 1);
220
+
221
+ // Subagent/workflow lanes are scoped to the same window as the main
222
+ // timeline so a day view doesn't reserve empty lanes for runs that happened
223
+ // on other days. Overlap test, not containment — a run straddling the
224
+ // window edge still shows.
225
+ const winSubagents = useMemo(
226
+ () =>
227
+ (subagents ?? []).filter(
228
+ (s) =>
229
+ s.startTOffsetMs !== undefined &&
230
+ s.endTOffsetMs !== undefined &&
231
+ s.endTOffsetMs >= winStart &&
232
+ s.startTOffsetMs <= winEnd,
233
+ ),
234
+ [subagents, winStart, winEnd],
235
+ );
236
+ const winWorkflows = useMemo(
237
+ () =>
238
+ (workflows ?? []).filter(
239
+ (w) =>
240
+ w.startTOffsetMs !== undefined &&
241
+ w.startTOffsetMs <= winEnd &&
242
+ (w.endTOffsetMs ?? winEnd) >= winStart,
243
+ ),
244
+ [workflows, winStart, winEnd],
245
+ );
246
+
247
+ /* Build segments from the same display-row stream the transcript
248
+ uses. Collapsed turns become one wide segment; expanded-turn
249
+ headers are skipped; presentation rows (whether standalone or
250
+ indented children of an expanded turn) become atomic segments. */
251
+ const segs: MinimapSeg[] = useMemo(() => {
252
+ const out: MinimapSeg[] = [];
253
+
254
+ // Flatten to (item, start) pairs with the expanded-turn headers removed
255
+ // and collapsed turns preserved as single-item entries.
256
+ type WithTime =
257
+ | { kind: "row"; row: PresentationRow; start: number; gapMs: number }
258
+ | {
259
+ kind: "turn";
260
+ turn: TurnMegaRow;
261
+ start: number;
262
+ gapMs: number;
263
+ };
264
+
265
+ const withTime: WithTime[] = [];
266
+ for (const d of displayRows) {
267
+ if (d.kind === "turn-expanded-header") continue;
268
+ if (d.kind === "turn-expanded-footer") continue;
269
+ if (d.kind === "turn-collapsed") {
270
+ if (d.turn.tOffsetMs === undefined) continue;
271
+ if (d.turn.tOffsetMs < winStart || d.turn.tOffsetMs > winEnd) continue;
272
+ withTime.push({
273
+ kind: "turn",
274
+ turn: d.turn,
275
+ start: d.turn.tOffsetMs,
276
+ gapMs: d.turn.rows[0]?.gapMs ?? 0,
277
+ });
278
+ } else {
279
+ // presentation row
280
+ if (d.row.tOffsetMs === undefined) continue;
281
+ if (d.row.tOffsetMs < winStart || d.row.tOffsetMs > winEnd) continue;
282
+ withTime.push({
283
+ kind: "row",
284
+ row: d.row,
285
+ start: d.row.tOffsetMs,
286
+ gapMs: d.row.gapMs ?? 0,
287
+ });
288
+ }
289
+ }
290
+
291
+ // A collapsed turn is one block spanning its whole duration; the idle
292
+ // *inside* it (model thinking, a long tool call, a workflow the turn is
293
+ // waiting on) is part of that turn, not separate dead air. Without this,
294
+ // a turn with several internal gaps renders as a turn stub followed by a
295
+ // run of back-to-back hatched idle bars — there's no activity segment
296
+ // between them because the turn is a single item — and the turn block is
297
+ // clipped at the first gap instead of spanning across a workflow that ran
298
+ // inside it. Suppress any idle band that begins within a collapsed turn so
299
+ // the turn owns its internal time; between-turn idle (which begins at/after
300
+ // a turn's end) is untouched.
301
+ const turnSpans = withTime.flatMap((w) =>
302
+ w.kind === "turn"
303
+ ? [{ start: w.start, end: w.start + (w.turn.durationMs ?? 0) }]
304
+ : [],
305
+ );
306
+ const startsInsideTurn = (t: number) =>
307
+ turnSpans.some((s) => t >= s.start && t < s.end);
308
+
309
+ // Build a sorted copy of idle bands so we can clip row/turn ends to
310
+ // the start of the next idle band (or the next row, whichever comes
311
+ // first). This stops a row's rectangle from spanning across an idle
312
+ // gap and visually swallowing it.
313
+ const idleBands = (rawIdleBands ?? [])
314
+ .filter((b) => b.end > winStart && b.start < winEnd)
315
+ .filter((b) => !startsInsideTurn(b.start))
316
+ .sort((a, b) => a.start - b.start);
317
+ const nextIdleStartFrom = (t: number): number | undefined => {
318
+ for (const b of idleBands) {
319
+ if (b.start >= t) return b.start;
320
+ }
321
+ return undefined;
322
+ };
323
+
324
+ for (let i = 0; i < withTime.length; i++) {
325
+ const item = withTime[i];
326
+ const next = withTime[i + 1];
327
+ const start = item.start;
328
+ const nextRowStart = next?.start;
329
+ const nextIdleStart = nextIdleStartFrom(start + 1);
330
+ // Cap end to whichever comes first: next visible row, next idle band,
331
+ // or the window's right edge (so a day's last segment doesn't bleed
332
+ // past the scoped window into the next day).
333
+ const cap = (raw: number) =>
334
+ Math.min(
335
+ raw,
336
+ nextRowStart ?? Number.POSITIVE_INFINITY,
337
+ nextIdleStart ?? Number.POSITIVE_INFINITY,
338
+ winEnd,
339
+ );
340
+
341
+ if (item.kind === "turn") {
342
+ const turnEnd = start + (item.turn.durationMs ?? 0);
343
+ out.push({
344
+ kind: "turn",
345
+ turn: item.turn,
346
+ primaryIndex: item.turn.firstPrimaryIndex,
347
+ start,
348
+ end: Math.max(cap(turnEnd), start + 1),
349
+ });
350
+ } else {
351
+ const end = cap(nextRowStart ?? start + 800);
352
+ out.push({
353
+ kind: "row",
354
+ row: item.row,
355
+ primaryIndex: rowPrimaryIndex(item.row),
356
+ start,
357
+ end: Math.max(end, start + 1),
358
+ });
359
+ }
360
+ }
361
+
362
+ // Idle bands are computed from raw event timestamps (5s threshold) up
363
+ // in the parent — append them and let the sort below interleave by
364
+ // start time so they slot between rows correctly.
365
+ for (const band of idleBands) {
366
+ out.push({
367
+ kind: "idle",
368
+ start: band.start,
369
+ end: band.end,
370
+ durationMs: band.durationMs,
371
+ });
372
+ }
373
+ out.sort((a, b) => a.start - b.start);
374
+
375
+ // Fuse consecutive idle segments. Two idle bands with no activity segment
376
+ // between them should read as ONE idle block — e.g. a between-turn gap that
377
+ // rawIdleBands split in two because a lone meta/summary anchor sits in the
378
+ // middle (the part before it classifies as in-turn, the part after as
379
+ // between-turn). Safe to do here (post-window, post-sort): only segments
380
+ // genuinely adjacent in render order collapse, so a real row between two
381
+ // idle bands still keeps them apart.
382
+ const fused: MinimapSeg[] = [];
383
+ for (const seg of out) {
384
+ const prev = fused[fused.length - 1];
385
+ if (prev && prev.kind === "idle" && seg.kind === "idle") {
386
+ prev.end = Math.max(prev.end, seg.end);
387
+ prev.durationMs = (prev.durationMs ?? 0) + (seg.durationMs ?? 0);
388
+ } else {
389
+ fused.push(seg.kind === "idle" ? { ...seg } : seg);
390
+ }
391
+ }
392
+
393
+ // A windowed timeline shouldn't open on dead air. The gap leading INTO the
394
+ // first real row/turn — e.g. the overnight idle before a day's first
395
+ // message, which at day scope can be 60-70% of the whole bar — is the
396
+ // previous period's tail, not this view's content. Drop leading idle so the
397
+ // first activity anchors the left edge; a gap that FOLLOWS the first
398
+ // activity is real in-view dead air and is left in place.
399
+ while (fused.length && fused[0]!.kind === "idle") fused.shift();
400
+
401
+ // Cap the number of rendered segments. Each <rect> carries two
402
+ // event handlers (onMouseEnter + onClick), so 2000 raw-events in
403
+ // "All events" mode becomes 4000 DOM event listeners — enough to
404
+ // stall Chrome during paint. Sampling to MAX contiguous buckets
405
+ // loses per-item hover precision but keeps the overall shape and
406
+ // click-to-navigate behavior (clicks map to the first row inside
407
+ // the bucket, which is the right anchor for "jump here" UX).
408
+ const MAX_SEGMENTS = 600;
409
+ if (fused.length <= MAX_SEGMENTS) return fused;
410
+
411
+ const step = Math.ceil(fused.length / MAX_SEGMENTS);
412
+ const bucketed: MinimapSeg[] = [];
413
+ for (let i = 0; i < fused.length; i += step) {
414
+ const first = fused[i]!;
415
+ const last = fused[Math.min(i + step - 1, fused.length - 1)]!;
416
+ // Merge bucket: keep the first seg's kind + primaryIndex (so clicks
417
+ // jump to the first item of the bucket), but extend the end range
418
+ // to cover the whole bucket's time span.
419
+ if (first.kind === "idle") {
420
+ bucketed.push({
421
+ kind: "idle",
422
+ start: first.start,
423
+ end: last.end,
424
+ durationMs: last.end - first.start,
425
+ });
426
+ } else if (first.kind === "turn") {
427
+ bucketed.push({
428
+ kind: "turn",
429
+ turn: first.turn,
430
+ primaryIndex: first.primaryIndex,
431
+ start: first.start,
432
+ end: last.end,
433
+ });
434
+ } else {
435
+ bucketed.push({
436
+ kind: "row",
437
+ row: first.row,
438
+ primaryIndex: first.primaryIndex,
439
+ start: first.start,
440
+ end: last.end,
441
+ });
442
+ }
443
+ }
444
+ return bucketed;
445
+ }, [displayRows, safeDur, rawIdleBands, winStart, winEnd]);
446
+
447
+ /* Sequential layout with a minimum displayed width per segment.
448
+ - Raw proportional width = (seg.end - seg.start) / safeDur * WIDTH
449
+ - Enforced width = max(raw, MIN_DISPLAY_WIDTH)
450
+ - x positions are cumulative (not time-proportional), so tiny events
451
+ stay visible next to a 24-minute turn.
452
+ - If the cumulative width exceeds WIDTH, proportionally shrink so it
453
+ all fits. Time ordering is preserved; exact time-to-pixel mapping
454
+ is sacrificed. */
455
+ const positions: { x: number; w: number }[] = useMemo(() => {
456
+ if (segs.length === 0) return [];
457
+ const raws = segs.map((s) => ((s.end - s.start) / safeDur) * WIDTH);
458
+ const enforced = raws.map((w) => Math.max(w, MIN_DISPLAY_WIDTH));
459
+ const total = enforced.reduce((a, b) => a + b, 0);
460
+ const scale = WIDTH / total;
461
+ const out: { x: number; w: number }[] = [];
462
+ let cursor = 0;
463
+ for (const w of enforced) {
464
+ const scaled = w * scale;
465
+ out.push({ x: cursor, w: scaled });
466
+ cursor += scaled;
467
+ }
468
+ return out;
469
+ }, [segs, safeDur]);
470
+
471
+ /** Map a time offset (ms) to an x coordinate in the relaxed layout.
472
+ * Walks segments sequentially and interpolates within whichever one
473
+ * contains the target ms. Used for the playhead indicator. */
474
+ const msToX = (ms: number): number => {
475
+ if (segs.length === 0) return 0;
476
+ if (ms <= segs[0].start) return positions[0]?.x ?? 0;
477
+ for (let i = 0; i < segs.length; i++) {
478
+ const s = segs[i];
479
+ const p = positions[i];
480
+ if (!p) continue;
481
+ if (ms < s.start) return p.x;
482
+ if (ms <= s.end) {
483
+ const segDur = s.end - s.start;
484
+ const frac = segDur > 0 ? (ms - s.start) / segDur : 0;
485
+ return p.x + frac * p.w;
486
+ }
487
+ }
488
+ const last = positions[positions.length - 1];
489
+ return last ? last.x + last.w : WIDTH;
490
+ };
491
+
492
+ // ---- Sub-agent lane assignment -------------------------------------
493
+ // Place each subagent in the lowest lane index where it doesn't
494
+ // collide with the most recent bar in that lane. Greedy left→right
495
+ // sweep — O(N×L) where L is the number of lanes (small in practice).
496
+ // Returns a Map agentId → laneIndex plus the total lane count.
497
+ const { laneOf, laneCount } = useMemo(() => {
498
+ if (winSubagents.length === 0) {
499
+ return { laneOf: new Map<string, number>(), laneCount: 0 };
500
+ }
501
+ const sorted = [...winSubagents].sort(
502
+ (a, b) => (a.startTOffsetMs ?? 0) - (b.startTOffsetMs ?? 0),
503
+ );
504
+ const laneEnds: number[] = [];
505
+ const laneOf = new Map<string, number>();
506
+ for (const s of sorted) {
507
+ const start = s.startTOffsetMs ?? 0;
508
+ const end = s.endTOffsetMs ?? start + 1;
509
+ let assigned = -1;
510
+ for (let i = 0; i < laneEnds.length; i++) {
511
+ if (laneEnds[i]! <= start) {
512
+ assigned = i;
513
+ break;
514
+ }
515
+ }
516
+ if (assigned === -1) {
517
+ assigned = laneEnds.length;
518
+ laneEnds.push(0);
519
+ }
520
+ laneEnds[assigned] = end;
521
+ laneOf.set(s.agentId, assigned);
522
+ }
523
+ return { laneOf, laneCount: laneEnds.length };
524
+ }, [winSubagents]);
525
+
526
+ const SUB_BLOCK_TOP = MAIN_H + SUB_LANE_GAP;
527
+ const SUB_LANES_H = laneCount > 0 ? laneCount * SUB_LANE_H : 0;
528
+
529
+ // ---- Workflow lane assignment --------------------------------------
530
+ // Same greedy sweep as subagents. Workflow runs are mostly sequential
531
+ // milestones, so this usually collapses to one lane — but overlap is
532
+ // handled if two runs ever ran concurrently.
533
+ const { wfLaneOf, wfLaneCount } = useMemo(() => {
534
+ // Only place workflows we can actually draw (startTOffsetMs known). A
535
+ // running run has no end yet — it extends to the session end (safeDur),
536
+ // matching the open-ended bar the render draws. Counting only placeable
537
+ // runs keeps wfLaneCount in lockstep with what renders, so no empty
538
+ // reserved band appears.
539
+ const placeable = winWorkflows;
540
+ if (placeable.length === 0) {
541
+ return { wfLaneOf: new Map<string, number>(), wfLaneCount: 0 };
542
+ }
543
+ const sorted = [...placeable].sort(
544
+ (a, b) => (a.startTOffsetMs ?? 0) - (b.startTOffsetMs ?? 0),
545
+ );
546
+ const laneEnds: number[] = [];
547
+ const wfLaneOf = new Map<string, number>();
548
+ for (const w of sorted) {
549
+ const start = w.startTOffsetMs ?? 0;
550
+ const end = w.endTOffsetMs ?? winEnd;
551
+ let assigned = -1;
552
+ for (let i = 0; i < laneEnds.length; i++) {
553
+ if (laneEnds[i]! <= start) {
554
+ assigned = i;
555
+ break;
556
+ }
557
+ }
558
+ if (assigned === -1) {
559
+ assigned = laneEnds.length;
560
+ laneEnds.push(0);
561
+ }
562
+ laneEnds[assigned] = Math.max(end, start + 1);
563
+ wfLaneOf.set(w.runId, assigned);
564
+ }
565
+ return { wfLaneOf, wfLaneCount: laneEnds.length };
566
+ }, [winWorkflows, winEnd]);
567
+
568
+ const SUB_BLOCK_BOTTOM = MAIN_H + (laneCount > 0 ? SUB_LANE_GAP + SUB_LANES_H : 0);
569
+ const WF_BLOCK_TOP = SUB_BLOCK_BOTTOM + (wfLaneCount > 0 ? SUB_LANE_GAP : 0);
570
+ const WF_LANES_H = wfLaneCount > 0 ? wfLaneCount * WF_LANE_H : 0;
571
+ const TOTAL_H = SUB_BLOCK_BOTTOM + (wfLaneCount > 0 ? SUB_LANE_GAP + WF_LANES_H : 0);
572
+
573
+ return (
574
+ <div
575
+ ref={containerRef}
576
+ style={{
577
+ padding: "8px 10px",
578
+ position: "relative",
579
+ background: "var(--af-surface)",
580
+ border: "1px solid var(--af-border-subtle)",
581
+ borderRadius: 8,
582
+ marginTop: 2,
583
+ }}
584
+ onMouseLeave={() => setHover(null)}
585
+ >
586
+ <svg
587
+ viewBox={`0 0 ${WIDTH} ${TOTAL_H}`}
588
+ preserveAspectRatio="none"
589
+ style={{
590
+ width: "100%",
591
+ height: TOTAL_H,
592
+ display: "block",
593
+ overflow: "visible",
594
+ }}
595
+ >
596
+ <defs>
597
+ <pattern
598
+ id="stripes"
599
+ patternUnits="userSpaceOnUse"
600
+ width="6"
601
+ height="6"
602
+ patternTransform="rotate(45)"
603
+ >
604
+ <rect width="6" height="6" fill="rgba(120, 115, 108, 0.04)" />
605
+ <line x1="0" y1="0" x2="0" y2="6" stroke="rgba(120, 115, 108, 0.28)" strokeWidth="2" />
606
+ </pattern>
607
+ </defs>
608
+
609
+ {/* Segments — x/w come from the relaxed sequential layout, NOT
610
+ from raw time proportions. See the positions memo above. */}
611
+ {segs.map((seg, i) => {
612
+ const pos = positions[i];
613
+ if (!pos) return null;
614
+ const xRaw = pos.x;
615
+ const wRaw = pos.w;
616
+
617
+ if (seg.kind === "idle") {
618
+ // Idle block: fill its span minus a gap on each side, with a
619
+ // subtle border so the diagonal stripes read as a distinct block.
620
+ const w = Math.max(wRaw - GAP, MIN_BLOCK);
621
+ return (
622
+ <rect
623
+ key={`idle-${i}`}
624
+ x={xRaw + GAP / 2}
625
+ y={BAR_TOP + 1}
626
+ width={w}
627
+ height={BAR_H - 2}
628
+ fill="url(#stripes)"
629
+ stroke="rgba(120, 115, 108, 0.35)"
630
+ strokeWidth="0.6"
631
+ rx="3"
632
+ onMouseEnter={(e) =>
633
+ setHover({
634
+ clientX: e.clientX,
635
+ idleMs: seg.durationMs,
636
+ })
637
+ }
638
+ />
639
+ );
640
+ }
641
+
642
+ // Resolve the theme + selection state. Turn segments use the
643
+ // "agent" theme (a turn is the agent's work wrapped as one unit).
644
+ const rowKind: PresentationRowKind = seg.kind === "turn" ? "agent" : seg.row.kind;
645
+ const theme = ROLE_THEMES[rowKind];
646
+ const importance = ROW_IMPORTANCE[rowKind];
647
+ const h = BAR_H * importance;
648
+ const y = BAR_TOP + (BAR_H - h) / 2;
649
+ const isSelected = selectedIndex === seg.primaryIndex;
650
+
651
+ // Error/interrupt: render as a THIN vertical bar regardless of
652
+ // actual span. Consecutive errors become a visible comb of
653
+ // narrow strokes separated by gaps, matching Claude's UI.
654
+ const isThin =
655
+ seg.kind === "row" && (seg.row.kind === "error" || seg.row.kind === "interrupt");
656
+ let w: number;
657
+ let x: number;
658
+ if (isThin) {
659
+ w = Math.min(Math.max(wRaw - GAP, 2), THIN_BAR_MAX);
660
+ x = xRaw + Math.max((wRaw - w) / 2, GAP / 2);
661
+ } else {
662
+ w = Math.max(wRaw - GAP, MIN_BLOCK);
663
+ x = xRaw + GAP / 2;
664
+ }
665
+
666
+ const ringPad = 2.5;
667
+ const onHover = (e: React.MouseEvent) =>
668
+ seg.kind === "turn"
669
+ ? setHover({ clientX: e.clientX, turn: seg.turn })
670
+ : setHover({ clientX: e.clientX, row: seg.row });
671
+ return (
672
+ <g key={`seg-${seg.primaryIndex}-${i}`}>
673
+ <rect
674
+ x={x}
675
+ y={y}
676
+ width={w}
677
+ height={h}
678
+ fill={theme.mini}
679
+ rx="3"
680
+ style={{ cursor: "pointer" }}
681
+ onClick={() => onSelect(seg.primaryIndex)}
682
+ onMouseEnter={onHover}
683
+ />
684
+ {isSelected && (
685
+ <rect
686
+ x={x - ringPad}
687
+ y={y - ringPad}
688
+ width={w + ringPad * 2}
689
+ height={h + ringPad * 2}
690
+ fill="none"
691
+ stroke="#5C84C3"
692
+ strokeWidth="2"
693
+ rx="5"
694
+ pointerEvents="none"
695
+ />
696
+ )}
697
+ </g>
698
+ );
699
+ })}
700
+
701
+ {/* Sub-agent lanes — one row per lane, colored by agentType.
702
+ Background-mode runs are highlighted with a brighter fill +
703
+ extra dashed outline so you can spot true parallelism at a
704
+ glance. Bars use the same msToX mapping as the main timeline
705
+ so the subagent's start/end aligns vertically with whatever
706
+ was happening in the main session at that time. */}
707
+ {laneCount > 0 && winSubagents.length > 0 && (
708
+ <g>
709
+ {/* Faint divider line above the subagent lane block */}
710
+ <line
711
+ x1={0}
712
+ x2={WIDTH}
713
+ y1={MAIN_H + SUB_LANE_GAP / 2}
714
+ y2={MAIN_H + SUB_LANE_GAP / 2}
715
+ stroke="var(--af-border-subtle)"
716
+ strokeWidth="0.6"
717
+ strokeDasharray="2 4"
718
+ />
719
+ {winSubagents.map((s) => {
720
+ const lane = laneOf.get(s.agentId) ?? 0;
721
+ if (s.startTOffsetMs === undefined || s.endTOffsetMs === undefined) return null;
722
+ const startX = msToX(s.startTOffsetMs);
723
+ const endX = msToX(s.endTOffsetMs);
724
+ // Minimum 8px so a 78-second subagent in a 16-hour session is
725
+ // still wide enough to read + hit-test. Long subagents render
726
+ // at their actual proportional width.
727
+ const w = Math.max(endX - startX, 8);
728
+ const y = SUB_BLOCK_TOP + lane * SUB_LANE_H;
729
+ const h = SUB_LANE_H - 2;
730
+ const fill = subagentColor(s.agentType, s.runInBackground);
731
+ const isSelected = selectedSubagentId === s.agentId;
732
+ return (
733
+ <g key={s.agentId}>
734
+ <rect
735
+ x={startX}
736
+ y={y}
737
+ width={w}
738
+ height={h}
739
+ fill={fill}
740
+ stroke={s.runInBackground ? "#fff" : "transparent"}
741
+ strokeWidth={s.runInBackground ? 0.5 : 0}
742
+ strokeDasharray={s.runInBackground ? "1.5 1.5" : undefined}
743
+ rx="2"
744
+ style={{ cursor: "pointer" }}
745
+ onMouseEnter={(e) => setHover({ clientX: e.clientX, subagent: s })}
746
+ onClick={() => onSelectSubagent?.(isSelected ? null : s.agentId)}
747
+ />
748
+ {isSelected && (
749
+ <rect
750
+ x={startX - 2}
751
+ y={y - 2}
752
+ width={w + 4}
753
+ height={h + 4}
754
+ fill="none"
755
+ stroke="var(--af-accent)"
756
+ strokeWidth="1.5"
757
+ rx="4"
758
+ pointerEvents="none"
759
+ />
760
+ )}
761
+ </g>
762
+ );
763
+ })}
764
+ </g>
765
+ )}
766
+
767
+ {/* Workflow lanes — one bar per dynamic-workflow run, on the same
768
+ time axis as the main timeline, colored by status (orange tier,
769
+ distinct from the blue/purple subagents). The agent count is the
770
+ headline — a single Workflow tool call hides a whole fleet. */}
771
+ {wfLaneCount > 0 && winWorkflows.length > 0 && (
772
+ <g>
773
+ <line
774
+ x1={0}
775
+ x2={WIDTH}
776
+ y1={WF_BLOCK_TOP - SUB_LANE_GAP / 2}
777
+ y2={WF_BLOCK_TOP - SUB_LANE_GAP / 2}
778
+ stroke="var(--af-border-subtle)"
779
+ strokeWidth="0.6"
780
+ strokeDasharray="2 4"
781
+ />
782
+ {winWorkflows.map((w) => {
783
+ if (w.startTOffsetMs === undefined) return null;
784
+ const lane = wfLaneOf.get(w.runId) ?? 0;
785
+ const startX = msToX(w.startTOffsetMs);
786
+ // A still-running run has no endTOffsetMs — draw it open-ended to
787
+ // the session end so it's visible (and dashed, to read as ongoing)
788
+ // rather than vanishing while still occupying a reserved lane.
789
+ const inProgress = w.endTOffsetMs === undefined;
790
+ const endX = msToX(w.endTOffsetMs ?? winEnd);
791
+ const wWidth = Math.max(endX - startX, 8);
792
+ const y = WF_BLOCK_TOP + lane * WF_LANE_H;
793
+ const h = WF_LANE_H - 2;
794
+ const fill = workflowColor(w.status);
795
+ // Center the agent count label inside the bar when there's room.
796
+ const label = `${w.agentCount}`;
797
+ const showLabel = wWidth > 22;
798
+ return (
799
+ <g key={w.runId} style={{ cursor: "pointer" }}>
800
+ <rect
801
+ x={startX}
802
+ y={y}
803
+ width={wWidth}
804
+ height={h}
805
+ fill={fill}
806
+ fillOpacity={inProgress ? 0.55 : 1}
807
+ stroke={inProgress ? fill : "transparent"}
808
+ strokeWidth={inProgress ? 0.8 : 0}
809
+ strokeDasharray={inProgress ? "2 2" : undefined}
810
+ rx="2"
811
+ onMouseEnter={(e) => setHover({ clientX: e.clientX, workflow: w })}
812
+ onClick={() => onWorkflowClick?.(w.runId)}
813
+ />
814
+ {showLabel && (
815
+ <text
816
+ x={startX + wWidth / 2}
817
+ y={y + h / 2 + 3}
818
+ textAnchor="middle"
819
+ fontSize="8.5"
820
+ fontWeight="700"
821
+ fill="#fff"
822
+ pointerEvents="none"
823
+ style={{ fontFamily: "var(--font-mono)" }}
824
+ >
825
+ {label}
826
+ </text>
827
+ )}
828
+ </g>
829
+ );
830
+ })}
831
+ </g>
832
+ )}
833
+
834
+ {/* PR markers — diamond + vertical line at each `gh pr create` */}
835
+ {prMarkers?.map((pr, i) => {
836
+ if (pr.tOffsetMs === undefined) return null;
837
+ if (pr.tOffsetMs < winStart || pr.tOffsetMs > winEnd) return null;
838
+ const x = msToX(pr.tOffsetMs);
839
+ return (
840
+ <g
841
+ key={`pr-${i}`}
842
+ style={{ cursor: "pointer" }}
843
+ onMouseEnter={(e) => setHover({ clientX: e.clientX, pr })}
844
+ >
845
+ <line
846
+ x1={x}
847
+ x2={x}
848
+ y1={0}
849
+ y2={TOTAL_H}
850
+ stroke="#8b5cf6"
851
+ strokeWidth="1.5"
852
+ strokeDasharray="4 3"
853
+ opacity="0.7"
854
+ />
855
+ {/* Invisible wider hit area for easier hovering */}
856
+ <rect
857
+ x={x - 8}
858
+ y={0}
859
+ width={16}
860
+ height={TOTAL_H}
861
+ fill="transparent"
862
+ />
863
+ {/* Diamond marker at mid-height */}
864
+ <polygon
865
+ points={`${x},${MAIN_H / 2 - 5} ${x + 5},${MAIN_H / 2} ${x},${MAIN_H / 2 + 5} ${x - 5},${MAIN_H / 2}`}
866
+ fill="#8b5cf6"
867
+ stroke="var(--af-surface)"
868
+ strokeWidth="1.5"
869
+ />
870
+ </g>
871
+ );
872
+ })}
873
+
874
+ {coldResumeMarkers?.map((cr, i) => {
875
+ if (cr.tOffsetMs < winStart || cr.tOffsetMs > winEnd) return null;
876
+ const x = msToX(cr.tOffsetMs);
877
+ return (
878
+ <g
879
+ key={`cold-${i}`}
880
+ style={{ cursor: "pointer" }}
881
+ onMouseEnter={(e) => setHover({ clientX: e.clientX, cold: cr })}
882
+ >
883
+ <line
884
+ x1={x}
885
+ x2={x}
886
+ y1={0}
887
+ y2={TOTAL_H}
888
+ stroke="#D97706"
889
+ strokeWidth="1.5"
890
+ strokeDasharray="4 3"
891
+ opacity="0.75"
892
+ pointerEvents="none"
893
+ />
894
+ {/* Hover hit-area limited to the diamond itself — a wider/taller
895
+ target used to swallow hovers+clicks meant for adjacent
896
+ user-prompt markers sitting right beside the cache-rebuild
897
+ point. */}
898
+ <rect
899
+ x={x - 6}
900
+ y={MAIN_H / 2 - 8}
901
+ width={12}
902
+ height={16}
903
+ fill="transparent"
904
+ />
905
+ <polygon
906
+ points={`${x},${MAIN_H / 2 - 5} ${x + 5},${MAIN_H / 2} ${x},${MAIN_H / 2 + 5} ${x - 5},${MAIN_H / 2}`}
907
+ fill="#D97706"
908
+ stroke="var(--af-surface)"
909
+ strokeWidth="1.5"
910
+ pointerEvents="none"
911
+ />
912
+ </g>
913
+ );
914
+ })}
915
+
916
+ {/* Playhead — positioned against the relaxed layout, not raw time.
917
+ Spans the full SVG height (main + subagent lanes) so you can see
918
+ which subagents were running at the current scroll position. */}
919
+ {playheadMs !== null && (
920
+ <line
921
+ x1={msToX(playheadMs)}
922
+ x2={msToX(playheadMs)}
923
+ y1={0}
924
+ y2={TOTAL_H}
925
+ stroke="#0F172A"
926
+ strokeWidth="1.25"
927
+ strokeDasharray="2 2"
928
+ opacity="0.65"
929
+ />
930
+ )}
931
+ </svg>
932
+
933
+ {/* Hover card */}
934
+ {hover && (
935
+ <MinimapHoverCard
936
+ containerRef={containerRef}
937
+ clientX={hover.clientX}
938
+ row={hover.row}
939
+ turn={hover.turn}
940
+ idleMs={hover.idleMs}
941
+ subagent={hover.subagent}
942
+ workflow={hover.workflow}
943
+ pr={hover.pr}
944
+ cold={hover.cold}
945
+ model={model}
946
+ />
947
+ )}
948
+
949
+ {/* Sub-agent legend strip — only when there are subagent lanes. */}
950
+ {laneCount > 0 && subagents && (
951
+ <div
952
+ style={{
953
+ display: "flex",
954
+ alignItems: "center",
955
+ gap: 10,
956
+ marginTop: 6,
957
+ fontSize: 10,
958
+ color: "var(--af-text-tertiary)",
959
+ }}
960
+ >
961
+ <span style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>
962
+ Sub-agents
963
+ </span>
964
+ <span>·</span>
965
+ <span>
966
+ {subagents.length} run{subagents.length === 1 ? "" : "s"}
967
+ </span>
968
+ <span style={{ marginLeft: 8, display: "flex", gap: 8, flexWrap: "wrap" }}>
969
+ {Array.from(new Set(subagents.map((s) => s.agentType))).map((type) => (
970
+ <span
971
+ key={type}
972
+ style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
973
+ >
974
+ <span
975
+ style={{
976
+ display: "inline-block",
977
+ width: 8,
978
+ height: 8,
979
+ borderRadius: 2,
980
+ background: subagentColor(type, false),
981
+ }}
982
+ />
983
+ {type}
984
+ </span>
985
+ ))}
986
+ {subagents.some((s) => s.runInBackground) && (
987
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
988
+ <span
989
+ style={{
990
+ display: "inline-block",
991
+ width: 8,
992
+ height: 8,
993
+ borderRadius: 2,
994
+ background: "transparent",
995
+ border: "1px dashed var(--af-text-secondary)",
996
+ }}
997
+ />
998
+ background
999
+ </span>
1000
+ )}
1001
+ </span>
1002
+ </div>
1003
+ )}
1004
+ </div>
1005
+ );
1006
+ }
1007
+
1008
+ function MinimapHoverCard({
1009
+ containerRef,
1010
+ clientX,
1011
+ row,
1012
+ turn,
1013
+ idleMs,
1014
+ subagent,
1015
+ workflow,
1016
+ pr,
1017
+ cold,
1018
+ model,
1019
+ }: {
1020
+ containerRef: React.RefObject<HTMLDivElement | null>;
1021
+ clientX: number;
1022
+ row?: PresentationRow;
1023
+ turn?: TurnMegaRow;
1024
+ idleMs?: number;
1025
+ subagent?: SubagentRun;
1026
+ workflow?: WorkflowRun;
1027
+ pr?: PrMarker;
1028
+ cold?: { tOffsetMs: number; info: NonNullable<SessionEvent["coldResume"]> };
1029
+ model?: string;
1030
+ }) {
1031
+ const rect = containerRef.current?.getBoundingClientRect();
1032
+ const localX = rect ? clientX - rect.left : 0;
1033
+ const left = Math.min(Math.max(localX - 140, 8), (rect?.width ?? 1400) - 300);
1034
+ // Always open below the minimap. The minimap lives inside the sticky
1035
+ // header, so there's never reliable space above it for a tooltip.
1036
+ const posStyle = { top: "calc(100% + 8px)", left };
1037
+
1038
+ if (workflow) {
1039
+ const w = workflow;
1040
+ const startOff = formatOffset(w.startTOffsetMs);
1041
+ const endOff = formatOffset(w.endTOffsetMs);
1042
+ const dur = w.durationMs !== undefined ? formatGap(w.durationMs) : "";
1043
+ return (
1044
+ <div
1045
+ style={{
1046
+ position: "absolute",
1047
+ ...posStyle,
1048
+ zIndex: 100,
1049
+ background: "#0F172A",
1050
+ color: "#F1F5F9",
1051
+ borderRadius: 10,
1052
+ padding: "12px 14px",
1053
+ fontSize: 11,
1054
+ pointerEvents: "none",
1055
+ boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
1056
+ maxWidth: 440,
1057
+ minWidth: 280,
1058
+ lineHeight: 1.45,
1059
+ }}
1060
+ >
1061
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
1062
+ <span
1063
+ style={{
1064
+ fontSize: 10,
1065
+ fontWeight: 600,
1066
+ padding: "2px 8px",
1067
+ borderRadius: 4,
1068
+ background: workflowColor(w.status),
1069
+ color: "#fff",
1070
+ textTransform: "uppercase",
1071
+ letterSpacing: "0.04em",
1072
+ }}
1073
+ >
1074
+ workflow · {w.status}
1075
+ </span>
1076
+ <span
1077
+ style={{
1078
+ fontFamily: "var(--font-mono)",
1079
+ fontSize: 10,
1080
+ opacity: 0.7,
1081
+ marginLeft: "auto",
1082
+ }}
1083
+ >
1084
+ {startOff} → {endOff} · {dur}
1085
+ </span>
1086
+ </div>
1087
+ <div style={{ fontWeight: 600, marginBottom: 4, fontFamily: "var(--font-mono)" }}>
1088
+ {w.name}
1089
+ </div>
1090
+ {w.description && (
1091
+ <div style={{ opacity: 0.8, marginBottom: 6 }}>{w.description}</div>
1092
+ )}
1093
+ <div
1094
+ style={{
1095
+ fontSize: 10,
1096
+ opacity: 0.75,
1097
+ display: "flex",
1098
+ gap: 8,
1099
+ flexWrap: "wrap",
1100
+ paddingTop: 6,
1101
+ borderTop: "1px solid rgba(241,245,249,0.08)",
1102
+ fontFamily: "var(--font-mono)",
1103
+ }}
1104
+ >
1105
+ <span style={{ color: "#FB923C", fontWeight: 600 }}>{w.agentCount} agents</span>
1106
+ <span>·</span>
1107
+ <span>{w.toolCallCount.toLocaleString()} tools</span>
1108
+ <span>·</span>
1109
+ <span>{formatTokens(w.totalTokens)} tok</span>
1110
+ {w.phases.length > 0 && (
1111
+ <>
1112
+ <span>·</span>
1113
+ <span>{w.phases.length} phases</span>
1114
+ </>
1115
+ )}
1116
+ </div>
1117
+ <div style={{ marginTop: 6, fontSize: 10, opacity: 0.55, fontStyle: "italic" }}>
1118
+ Click to open this run in the Workflows tab.
1119
+ </div>
1120
+ </div>
1121
+ );
1122
+ }
1123
+
1124
+ if (subagent) {
1125
+ const startOff = formatOffset(subagent.startTOffsetMs);
1126
+ const endOff = formatOffset(subagent.endTOffsetMs);
1127
+ const dur = subagent.durationMs !== undefined ? formatGap(subagent.durationMs) : "";
1128
+ const totalIn =
1129
+ subagent.totalUsage.input + subagent.totalUsage.cacheRead + subagent.totalUsage.cacheWrite;
1130
+ return (
1131
+ <div
1132
+ style={{
1133
+ position: "absolute",
1134
+ ...posStyle,
1135
+ zIndex: 100,
1136
+ background: "#0F172A",
1137
+ color: "#F1F5F9",
1138
+ borderRadius: 10,
1139
+ padding: "12px 14px",
1140
+ fontSize: 11,
1141
+ pointerEvents: "none",
1142
+ boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
1143
+ maxWidth: 440,
1144
+ minWidth: 280,
1145
+ lineHeight: 1.45,
1146
+ }}
1147
+ >
1148
+ <div
1149
+ style={{
1150
+ display: "flex",
1151
+ alignItems: "center",
1152
+ gap: 8,
1153
+ marginBottom: 8,
1154
+ }}
1155
+ >
1156
+ <span
1157
+ style={{
1158
+ fontSize: 10,
1159
+ fontWeight: 600,
1160
+ padding: "2px 8px",
1161
+ borderRadius: 4,
1162
+ background: subagentColor(subagent.agentType, subagent.runInBackground),
1163
+ color: "#fff",
1164
+ }}
1165
+ >
1166
+ {subagent.agentType}
1167
+ </span>
1168
+ {subagent.runInBackground && (
1169
+ <span
1170
+ style={{
1171
+ fontSize: 9,
1172
+ fontWeight: 600,
1173
+ padding: "1px 6px",
1174
+ borderRadius: 3,
1175
+ background: "rgba(251, 191, 36, 0.18)",
1176
+ color: "#FBBF24",
1177
+ textTransform: "uppercase",
1178
+ letterSpacing: "0.04em",
1179
+ }}
1180
+ >
1181
+ background
1182
+ </span>
1183
+ )}
1184
+ <span
1185
+ style={{
1186
+ fontFamily: "var(--font-mono)",
1187
+ fontSize: 10,
1188
+ opacity: 0.7,
1189
+ marginLeft: "auto",
1190
+ }}
1191
+ >
1192
+ {startOff} → {endOff} · {dur}
1193
+ </span>
1194
+ </div>
1195
+ <div style={{ fontWeight: 500, marginBottom: 6 }}>{subagent.description}</div>
1196
+ <div
1197
+ style={{
1198
+ fontSize: 10,
1199
+ opacity: 0.65,
1200
+ display: "flex",
1201
+ gap: 8,
1202
+ paddingTop: 6,
1203
+ borderTop: "1px solid rgba(241,245,249,0.08)",
1204
+ }}
1205
+ >
1206
+ <span>{subagent.eventCount} events</span>
1207
+ <span>·</span>
1208
+ <span style={{ fontFamily: "var(--font-mono)" }}>
1209
+ {formatTokens(totalIn)}/{formatTokens(subagent.totalUsage.output)} tok
1210
+ </span>
1211
+ </div>
1212
+ {subagent.finalPreview && (
1213
+ <div
1214
+ style={{
1215
+ marginTop: 6,
1216
+ opacity: 0.78,
1217
+ fontStyle: "italic",
1218
+ display: "-webkit-box",
1219
+ WebkitLineClamp: 2,
1220
+ WebkitBoxOrient: "vertical",
1221
+ overflow: "hidden",
1222
+ }}
1223
+ >
1224
+ → {subagent.finalPreview}
1225
+ </div>
1226
+ )}
1227
+ </div>
1228
+ );
1229
+ }
1230
+
1231
+ if (cold) {
1232
+ const { trigger, gapMs, writeTokens, compact } = cold.info;
1233
+ const isCompact = trigger === "compact";
1234
+ const estUsd = estimateCost(
1235
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: writeTokens },
1236
+ model,
1237
+ );
1238
+ let label: string;
1239
+ if (!isCompact) label = "⚡ CACHE REBUILD";
1240
+ else if (compact?.trigger === "auto") label = "⚡ AUTO-COMPACT";
1241
+ else label = "⚡ /COMPACT";
1242
+ const detailLine = isCompact
1243
+ ? `${formatTokens(writeTokens)} rewritten · pre-compact ${formatTokens(compact?.preTokens ?? 0)}`
1244
+ : `${formatTokens(writeTokens)} rewritten · idle ${formatGap(gapMs)}`;
1245
+ const hint = isCompact
1246
+ ? "Conversation was summarized; prefix had to be rewritten into a fresh cache."
1247
+ : "Prompt cache expired during idle; resuming within 5 min keeps it warm.";
1248
+ return (
1249
+ <div
1250
+ style={{
1251
+ position: "absolute",
1252
+ ...posStyle,
1253
+ zIndex: 100,
1254
+ background: "#0F172A",
1255
+ color: "#F1F5F9",
1256
+ borderRadius: 10,
1257
+ padding: "10px 14px",
1258
+ fontSize: 11,
1259
+ pointerEvents: "none",
1260
+ boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
1261
+ maxWidth: 340,
1262
+ minWidth: 220,
1263
+ lineHeight: 1.45,
1264
+ }}
1265
+ >
1266
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
1267
+ <span
1268
+ style={{
1269
+ fontSize: 10,
1270
+ fontWeight: 600,
1271
+ padding: "2px 8px",
1272
+ borderRadius: 4,
1273
+ background: "#D97706",
1274
+ color: "#fff",
1275
+ }}
1276
+ >
1277
+ {label}
1278
+ </span>
1279
+ <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, opacity: 0.7 }}>
1280
+ at {formatOffset(cold.tOffsetMs)}
1281
+ </span>
1282
+ </div>
1283
+ <div style={{ fontWeight: 500, fontFamily: "var(--font-mono)" }}>
1284
+ {detailLine}
1285
+ {estUsd >= 0.005 && ` · est. ${formatCost(estUsd)}`}
1286
+ </div>
1287
+ <div
1288
+ style={{
1289
+ marginTop: 4,
1290
+ fontSize: 10,
1291
+ opacity: 0.65,
1292
+ fontStyle: "italic",
1293
+ whiteSpace: "normal",
1294
+ }}
1295
+ >
1296
+ {hint}
1297
+ </div>
1298
+ </div>
1299
+ );
1300
+ }
1301
+
1302
+ if (pr) {
1303
+ const label = pr.title
1304
+ ? `PR${pr.prNumber ? ` #${pr.prNumber}` : ""}: ${pr.title}`
1305
+ : pr.prNumber
1306
+ ? `PR #${pr.prNumber}`
1307
+ : "PR created";
1308
+ return (
1309
+ <div
1310
+ style={{
1311
+ position: "absolute",
1312
+ ...posStyle,
1313
+ zIndex: 100,
1314
+ background: "#0F172A",
1315
+ color: "#F1F5F9",
1316
+ borderRadius: 10,
1317
+ padding: "10px 14px",
1318
+ fontSize: 11,
1319
+ pointerEvents: "none",
1320
+ boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
1321
+ maxWidth: 360,
1322
+ minWidth: 200,
1323
+ lineHeight: 1.45,
1324
+ }}
1325
+ >
1326
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
1327
+ <span
1328
+ style={{
1329
+ fontSize: 10,
1330
+ fontWeight: 600,
1331
+ padding: "2px 8px",
1332
+ borderRadius: 4,
1333
+ background: "#8b5cf6",
1334
+ color: "#fff",
1335
+ }}
1336
+ >
1337
+ PR
1338
+ </span>
1339
+ {pr.tOffsetMs !== undefined && (
1340
+ <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, opacity: 0.7 }}>
1341
+ at {formatOffset(pr.tOffsetMs)}
1342
+ </span>
1343
+ )}
1344
+ </div>
1345
+ <div style={{ fontWeight: 500 }}>{label}</div>
1346
+ </div>
1347
+ );
1348
+ }
1349
+
1350
+ if (idleMs !== undefined) {
1351
+ return (
1352
+ <div
1353
+ style={{
1354
+ position: "absolute",
1355
+ ...posStyle,
1356
+ zIndex: 100,
1357
+ background: "#0F172A",
1358
+ color: "#F1F5F9",
1359
+ borderRadius: 10,
1360
+ padding: "8px 12px",
1361
+ fontSize: 11,
1362
+ pointerEvents: "none",
1363
+ boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
1364
+ }}
1365
+ >
1366
+ <div style={{ fontWeight: 600, marginBottom: 2 }}>Session idle</div>
1367
+ <div style={{ opacity: 0.78, fontFamily: "var(--font-mono)" }}>{formatGap(idleMs)}</div>
1368
+ </div>
1369
+ );
1370
+ }
1371
+
1372
+ if (turn) {
1373
+ const theme = ROLE_THEMES.agent;
1374
+ const s = turn.summary;
1375
+ const dur = turn.durationMs !== undefined ? formatGap(turn.durationMs) : "";
1376
+ const startOff = formatOffset(turn.tOffsetMs);
1377
+ const endOff =
1378
+ turn.tOffsetMs !== undefined && turn.durationMs !== undefined
1379
+ ? formatOffset(turn.tOffsetMs + turn.durationMs)
1380
+ : undefined;
1381
+ const hasFirstLast =
1382
+ s.firstAgentPreview && s.finalAgentPreview && s.firstAgentPreview !== s.finalAgentPreview;
1383
+ // Top tools aggregated summary line (up to 3 entries)
1384
+ const topTools = s.toolNames
1385
+ .slice(0, 3)
1386
+ .map((t) =>
1387
+ t.count > 1 ? `${shortenToolName(t.name)} ×${t.count}` : shortenToolName(t.name),
1388
+ )
1389
+ .join(" · ");
1390
+
1391
+ return (
1392
+ <div
1393
+ style={{
1394
+ position: "absolute",
1395
+ ...posStyle,
1396
+ zIndex: 100,
1397
+ background: "#0F172A",
1398
+ color: "#F1F5F9",
1399
+ borderRadius: 10,
1400
+ padding: "12px 14px",
1401
+ fontSize: 11,
1402
+ pointerEvents: "none",
1403
+ boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
1404
+ maxWidth: 440,
1405
+ minWidth: 280,
1406
+ lineHeight: 1.45,
1407
+ }}
1408
+ >
1409
+ {/* Header: Turn pill + start→end range */}
1410
+ <div
1411
+ style={{
1412
+ display: "flex",
1413
+ alignItems: "center",
1414
+ gap: 8,
1415
+ marginBottom: 8,
1416
+ }}
1417
+ >
1418
+ <span
1419
+ style={{
1420
+ fontSize: 10,
1421
+ fontWeight: 600,
1422
+ padding: "2px 8px",
1423
+ borderRadius: 4,
1424
+ background: theme.mini,
1425
+ color: "#fff",
1426
+ }}
1427
+ >
1428
+ Turn
1429
+ </span>
1430
+ <span
1431
+ style={{
1432
+ fontFamily: "var(--font-mono)",
1433
+ fontSize: 10,
1434
+ opacity: 0.7,
1435
+ }}
1436
+ >
1437
+ {endOff ? `${startOff} → ${endOff}` : startOff}
1438
+ </span>
1439
+ {dur && (
1440
+ <span
1441
+ style={{
1442
+ fontFamily: "var(--font-mono)",
1443
+ fontSize: 10,
1444
+ opacity: 0.55,
1445
+ }}
1446
+ >
1447
+ · {dur}
1448
+ </span>
1449
+ )}
1450
+ </div>
1451
+
1452
+ {/* First agent message */}
1453
+ {s.firstAgentPreview && (
1454
+ <div
1455
+ style={{
1456
+ opacity: 0.95,
1457
+ fontWeight: 500,
1458
+ display: "-webkit-box",
1459
+ WebkitLineClamp: 2,
1460
+ WebkitBoxOrient: "vertical",
1461
+ overflow: "hidden",
1462
+ marginBottom: 6,
1463
+ }}
1464
+ >
1465
+ {s.firstAgentPreview}
1466
+ </div>
1467
+ )}
1468
+
1469
+ {/* Middle: stats + top tools */}
1470
+ <div
1471
+ style={{
1472
+ fontSize: 10,
1473
+ opacity: 0.65,
1474
+ marginBottom: hasFirstLast ? 6 : 0,
1475
+ borderTop: s.firstAgentPreview ? "1px solid rgba(241,245,249,0.08)" : undefined,
1476
+ paddingTop: s.firstAgentPreview ? 6 : 0,
1477
+ }}
1478
+ >
1479
+ {s.agentMessages} msg · {s.toolCalls} tools
1480
+ {s.errors > 0 ? ` · ${s.errors} err` : ""}
1481
+ {topTools && (
1482
+ <>
1483
+ <span style={{ opacity: 0.5, margin: "0 4px" }}>·</span>
1484
+ <span style={{ fontFamily: "var(--font-mono)" }}>{topTools}</span>
1485
+ {s.toolNames.length > 3 && (
1486
+ <span style={{ opacity: 0.65 }}> +{s.toolNames.length - 3}</span>
1487
+ )}
1488
+ </>
1489
+ )}
1490
+ </div>
1491
+
1492
+ {/* Last agent message */}
1493
+ {hasFirstLast && s.finalAgentPreview && (
1494
+ <div
1495
+ style={{
1496
+ opacity: 0.92,
1497
+ display: "flex",
1498
+ gap: 6,
1499
+ alignItems: "flex-start",
1500
+ borderTop: "1px solid rgba(241,245,249,0.08)",
1501
+ paddingTop: 6,
1502
+ }}
1503
+ >
1504
+ <span style={{ opacity: 0.5 }}>→</span>
1505
+ <span
1506
+ style={{
1507
+ display: "-webkit-box",
1508
+ WebkitLineClamp: 2,
1509
+ WebkitBoxOrient: "vertical",
1510
+ overflow: "hidden",
1511
+ }}
1512
+ >
1513
+ {s.finalAgentPreview}
1514
+ </span>
1515
+ </div>
1516
+ )}
1517
+ </div>
1518
+ );
1519
+ }
1520
+
1521
+ if (!row) return null;
1522
+
1523
+ const theme = ROLE_THEMES[row.kind];
1524
+ const preview = rowPreview(row);
1525
+
1526
+ return (
1527
+ <div
1528
+ style={{
1529
+ position: "absolute",
1530
+ ...posStyle,
1531
+ zIndex: 100,
1532
+ background: "#0F172A",
1533
+ color: "#F1F5F9",
1534
+ borderRadius: 10,
1535
+ padding: "10px 14px",
1536
+ fontSize: 11,
1537
+ pointerEvents: "none",
1538
+ boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
1539
+ maxWidth: 360,
1540
+ minWidth: 220,
1541
+ }}
1542
+ >
1543
+ <div
1544
+ style={{
1545
+ display: "flex",
1546
+ alignItems: "center",
1547
+ gap: 8,
1548
+ marginBottom: 6,
1549
+ }}
1550
+ >
1551
+ <span
1552
+ style={{
1553
+ fontSize: 10,
1554
+ fontWeight: 600,
1555
+ padding: "2px 8px",
1556
+ borderRadius: 4,
1557
+ background: theme.mini,
1558
+ color: "#fff",
1559
+ }}
1560
+ >
1561
+ {theme.label}
1562
+ </span>
1563
+ <span
1564
+ style={{
1565
+ fontFamily: "var(--font-mono)",
1566
+ fontSize: 10,
1567
+ opacity: 0.65,
1568
+ }}
1569
+ >
1570
+ {formatOffset(row.tOffsetMs)}
1571
+ </span>
1572
+ </div>
1573
+ <div
1574
+ style={{
1575
+ lineHeight: 1.45,
1576
+ opacity: 0.92,
1577
+ display: "-webkit-box",
1578
+ WebkitLineClamp: 3,
1579
+ WebkitBoxOrient: "vertical",
1580
+ overflow: "hidden",
1581
+ }}
1582
+ >
1583
+ {preview}
1584
+ </div>
1585
+ </div>
1586
+ );
1587
+ }