fleetlens 0.12.3 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) 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 +2 -1
  27. package/app/apps/web/.next/server/app/api/events/route.js.nft.json +1 -1
  28. package/app/apps/web/.next/server/app/api/jobs/route.js.nft.json +1 -1
  29. package/app/apps/web/.next/server/app/api/pdf/[key]/route.js.nft.json +1 -1
  30. package/app/apps/web/.next/server/app/api/runs/[runId]/route.js.nft.json +1 -1
  31. package/app/apps/web/.next/server/app/api/runs/[runId]/stream/route.js.nft.json +1 -1
  32. package/app/apps/web/.next/server/app/api/runs/route.js.nft.json +1 -1
  33. package/app/apps/web/.next/server/app/api/settings/route.js.nft.json +1 -1
  34. package/app/apps/web/.next/server/app/api/team-config/route.js.nft.json +1 -1
  35. package/app/apps/web/.next/server/app/api/workflow-agent/route.js.nft.json +1 -1
  36. package/app/apps/web/.next/server/app/changelog/page/build-manifest.json +5 -5
  37. package/app/apps/web/.next/server/app/changelog/page.js +1 -1
  38. package/app/apps/web/.next/server/app/changelog/page.js.nft.json +1 -1
  39. package/app/apps/web/.next/server/app/changelog/page_client-reference-manifest.js +1 -1
  40. package/app/apps/web/.next/server/app/day/[date]/page/build-manifest.json +5 -5
  41. package/app/apps/web/.next/server/app/day/[date]/page.js +1 -1
  42. package/app/apps/web/.next/server/app/day/[date]/page.js.nft.json +1 -1
  43. package/app/apps/web/.next/server/app/day/[date]/page_client-reference-manifest.js +1 -1
  44. package/app/apps/web/.next/server/app/day/page/build-manifest.json +5 -5
  45. package/app/apps/web/.next/server/app/day/page.js +1 -1
  46. package/app/apps/web/.next/server/app/day/page.js.nft.json +1 -1
  47. package/app/apps/web/.next/server/app/day/page_client-reference-manifest.js +1 -1
  48. package/app/apps/web/.next/server/app/digest/[date]/page/build-manifest.json +5 -5
  49. package/app/apps/web/.next/server/app/digest/[date]/page.js +1 -1
  50. package/app/apps/web/.next/server/app/digest/[date]/page.js.nft.json +1 -1
  51. package/app/apps/web/.next/server/app/digest/[date]/page_client-reference-manifest.js +1 -1
  52. package/app/apps/web/.next/server/app/digest/page/build-manifest.json +5 -5
  53. package/app/apps/web/.next/server/app/digest/page.js +1 -1
  54. package/app/apps/web/.next/server/app/digest/page.js.nft.json +1 -1
  55. package/app/apps/web/.next/server/app/digest/page_client-reference-manifest.js +1 -1
  56. package/app/apps/web/.next/server/app/insights/[key]/page/build-manifest.json +5 -5
  57. package/app/apps/web/.next/server/app/insights/[key]/page.js +1 -1
  58. package/app/apps/web/.next/server/app/insights/[key]/page.js.nft.json +1 -1
  59. package/app/apps/web/.next/server/app/insights/[key]/page_client-reference-manifest.js +1 -1
  60. package/app/apps/web/.next/server/app/insights/page/build-manifest.json +5 -5
  61. package/app/apps/web/.next/server/app/insights/page.js +3 -2
  62. package/app/apps/web/.next/server/app/insights/page.js.nft.json +1 -1
  63. package/app/apps/web/.next/server/app/insights/page_client-reference-manifest.js +1 -1
  64. package/app/apps/web/.next/server/app/page/build-manifest.json +5 -5
  65. package/app/apps/web/.next/server/app/page.js +1 -1
  66. package/app/apps/web/.next/server/app/page.js.nft.json +1 -1
  67. package/app/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  68. package/app/apps/web/.next/server/app/parallelism/page/build-manifest.json +5 -5
  69. package/app/apps/web/.next/server/app/parallelism/page.js +1 -1
  70. package/app/apps/web/.next/server/app/parallelism/page.js.nft.json +1 -1
  71. package/app/apps/web/.next/server/app/parallelism/page_client-reference-manifest.js +1 -1
  72. package/app/apps/web/.next/server/app/print/[key]/page/build-manifest.json +5 -5
  73. package/app/apps/web/.next/server/app/print/[key]/page.js +1 -1
  74. package/app/apps/web/.next/server/app/print/[key]/page.js.nft.json +1 -1
  75. package/app/apps/web/.next/server/app/print/[key]/page_client-reference-manifest.js +1 -1
  76. package/app/apps/web/.next/server/app/projects/[slug]/page/build-manifest.json +5 -5
  77. package/app/apps/web/.next/server/app/projects/[slug]/page.js +1 -1
  78. package/app/apps/web/.next/server/app/projects/[slug]/page.js.nft.json +1 -1
  79. package/app/apps/web/.next/server/app/projects/[slug]/page_client-reference-manifest.js +1 -1
  80. package/app/apps/web/.next/server/app/projects/page/build-manifest.json +5 -5
  81. package/app/apps/web/.next/server/app/projects/page.js +1 -1
  82. package/app/apps/web/.next/server/app/projects/page.js.nft.json +1 -1
  83. package/app/apps/web/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  84. package/app/apps/web/.next/server/app/runs/page/build-manifest.json +5 -5
  85. package/app/apps/web/.next/server/app/runs/page.js +1 -1
  86. package/app/apps/web/.next/server/app/runs/page.js.nft.json +1 -1
  87. package/app/apps/web/.next/server/app/runs/page_client-reference-manifest.js +1 -1
  88. package/app/apps/web/.next/server/app/sessions/[id]/page/build-manifest.json +5 -5
  89. package/app/apps/web/.next/server/app/sessions/[id]/page.js +1 -1
  90. package/app/apps/web/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  91. package/app/apps/web/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
  92. package/app/apps/web/.next/server/app/sessions/page/build-manifest.json +5 -5
  93. package/app/apps/web/.next/server/app/sessions/page.js +1 -1
  94. package/app/apps/web/.next/server/app/sessions/page.js.nft.json +1 -1
  95. package/app/apps/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  96. package/app/apps/web/.next/server/app/settings/page/build-manifest.json +5 -5
  97. package/app/apps/web/.next/server/app/settings/page.js +1 -1
  98. package/app/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  99. package/app/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  100. package/app/apps/web/.next/server/app/team/page/build-manifest.json +5 -5
  101. package/app/apps/web/.next/server/app/team/page.js +1 -1
  102. package/app/apps/web/.next/server/app/team/page.js.nft.json +1 -1
  103. package/app/apps/web/.next/server/app/team/page_client-reference-manifest.js +1 -1
  104. package/app/apps/web/.next/server/app/usage/page/build-manifest.json +5 -5
  105. package/app/apps/web/.next/server/app/usage/page.js +1 -1
  106. package/app/apps/web/.next/server/app/usage/page.js.nft.json +1 -1
  107. package/app/apps/web/.next/server/app/usage/page_client-reference-manifest.js +1 -1
  108. package/app/apps/web/.next/server/chunks/[externals]__0rv9ct5._.js +3 -0
  109. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0gril0u._.js +9 -0
  110. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0n79hpm._.js +9 -0
  111. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0~utv~v._.js +9 -0
  112. package/app/apps/web/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_02bw8.n.js +5 -0
  113. package/app/apps/web/.next/server/chunks/packages_parser_dist_06d9se1._.js +1 -1
  114. package/app/apps/web/.next/server/chunks/packages_parser_dist_10ihc3~._.js +1 -1
  115. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0exckd4._.js +1 -1
  116. 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
  117. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0lhwp-k._.js +8 -0
  118. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0n-ax-5._.js +1 -1
  119. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0o03ymy._.js +1 -1
  120. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0~.f_3z._.js +3 -3
  121. package/app/apps/web/.next/server/chunks/ssr/_0kajk31._.js +1 -1
  122. package/app/apps/web/.next/server/chunks/ssr/_0s3n5mz._.js +8 -0
  123. package/app/apps/web/.next/server/chunks/ssr/_0xu2pvc._.js +1 -1
  124. package/app/apps/web/.next/server/chunks/ssr/{_0jw2sm7._.js → _12mbgs5._.js} +2 -2
  125. package/app/apps/web/.next/server/chunks/ssr/apps_web_0.mu7c7._.js +1 -1
  126. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_day_[date]_day-view_tsx_0wjoe1u._.js +1 -1
  127. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_insights_page_tsx_071kwyw._.js +3 -0
  128. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_sessions_sessions-grid_tsx_0bri5x.._.js +3 -0
  129. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_0b-4-4j._.js +1 -1
  130. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_0c-tpft._.js +1 -1
  131. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_0l6hsch._.js +6 -0
  132. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_0txtsv5._.js +1 -1
  133. package/app/apps/web/.next/server/chunks/ssr/apps_web_lib_changelog_ts_0n7_p1d._.js +1 -1
  134. package/app/apps/web/.next/server/chunks/ssr/apps_web_lib_changelog_ts_0nb5_-s._.js +1 -1
  135. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0cepfi8._.js +3 -0
  136. package/app/apps/web/.next/server/chunks/ssr/packages_entries_dist_node_0_271yr.js +1 -1
  137. package/app/apps/web/.next/server/chunks/ssr/packages_parser_dist_0mx_509._.js +1 -1
  138. package/app/apps/web/.next/server/middleware-build-manifest.js +8 -8
  139. package/app/apps/web/.next/server/middleware-manifest.json +5 -5
  140. package/app/apps/web/.next/server/pages/500.html +1 -1
  141. package/app/apps/web/.next/server/server-reference-manifest.js +1 -1
  142. package/app/apps/web/.next/server/server-reference-manifest.json +1 -1
  143. package/app/apps/web/.next/static/chunks/02bv_ejzhhzvp.js +10 -0
  144. package/app/apps/web/.next/static/chunks/{07lbm-sdh9c2j.js → 0389wb_f0j78j.js} +1 -1
  145. package/app/apps/web/.next/static/chunks/03gy1evbn94qu.js +6 -0
  146. package/app/apps/web/.next/static/chunks/03rd.3cc_t~yf.js +4 -0
  147. package/app/apps/web/.next/static/chunks/{12jvvc_w.cb45.css → 083~hzkv577.5.css} +1 -1
  148. package/app/apps/web/.next/static/chunks/{0osdgl2.9cl1q.js → 0d2x-wfu.m0-z.js} +1 -1
  149. package/app/apps/web/.next/static/chunks/{0lziv8y_6yp5w.js → 0e_fzy0n8ij-o.js} +1 -1
  150. package/app/apps/web/.next/static/chunks/{0l4e3as18eexy.js → 0g223u9amib0q.js} +1 -1
  151. package/app/apps/web/.next/static/chunks/{0h3.-_s-k0mj3.js → 0i.hc~2bj0_3d.js} +1 -1
  152. package/app/apps/web/.next/static/chunks/0m7dd-2zd9vl2.js +4 -0
  153. package/app/apps/web/.next/static/chunks/0nqklmbs5s3ax.js +6 -0
  154. package/app/apps/web/.next/static/chunks/{073081qv4uyn8.js → 0p--hzrway8~3.js} +1 -1
  155. package/app/apps/web/.next/static/chunks/0q8dg4txlzno0.js +1 -0
  156. package/app/apps/web/.next/static/chunks/{0gblp_v.kiu-..js → 0qv-gw888_5nu.js} +1 -1
  157. package/app/apps/web/.next/static/chunks/{14bjditmmrn7l.js → 0tirv_vo6q8uc.js} +1 -1
  158. package/app/apps/web/.next/static/chunks/{0qmh5da6gfm56.js → 0vz4~a~~hq399.js} +1 -1
  159. package/app/apps/web/.next/static/chunks/{13po66tt.68f3.js → 0wwcx42ng8i2e.js} +3 -3
  160. package/app/apps/web/.next/static/chunks/{0n0n.0279ot6c.js → 14vx702c32kn6.js} +1 -1
  161. package/app/apps/web/.next/static/chunks/{turbopack-0lb9w090w5ezx.js → turbopack-0mxu3~9vk.brh.js} +1 -1
  162. package/app/apps/web/app/api/digest/_shared/digest-route-helpers.ts +178 -0
  163. package/app/apps/web/app/api/digest/day/[date]/route.ts +34 -128
  164. package/app/apps/web/app/api/digest/month/[yearMonth]/route.ts +36 -121
  165. package/app/apps/web/app/api/digest/week/[startDate]/route.ts +36 -123
  166. package/app/apps/web/app/api/events/route.ts +47 -4
  167. package/app/apps/web/app/globals.css +23 -0
  168. package/app/apps/web/app/insights/page.tsx +20 -5
  169. package/app/apps/web/app/layout.tsx +1 -0
  170. package/app/apps/web/app/page.tsx +1 -1
  171. package/app/apps/web/app/parallelism/burst-detail-modal.tsx +637 -0
  172. package/app/apps/web/app/parallelism/concurrency-info-modal.tsx +228 -0
  173. package/app/apps/web/app/parallelism/gantt-chart-utils.ts +49 -0
  174. package/app/apps/web/app/parallelism/gantt-chart.tsx +16 -902
  175. package/app/apps/web/app/projects/[slug]/page.tsx +1 -1
  176. package/app/apps/web/app/sessions/[id]/session-view/colors.ts +31 -0
  177. package/app/apps/web/app/sessions/[id]/session-view/debug-list.tsx +78 -0
  178. package/app/apps/web/app/sessions/[id]/session-view/drawer.tsx +414 -0
  179. package/app/apps/web/app/sessions/[id]/session-view/entry-day-strip.tsx +124 -0
  180. package/app/apps/web/app/sessions/[id]/session-view/header-stats.tsx +217 -0
  181. package/app/apps/web/app/sessions/[id]/session-view/helpers.ts +135 -0
  182. package/app/apps/web/app/sessions/[id]/session-view/subagent-drawer.tsx +339 -0
  183. package/app/apps/web/app/sessions/[id]/session-view/token-stats.tsx +111 -0
  184. package/app/apps/web/app/sessions/[id]/session-view/tooltip.tsx +63 -0
  185. package/app/apps/web/app/sessions/[id]/session-view/types.ts +46 -0
  186. package/app/apps/web/app/sessions/[id]/session-view/workflows-shared.tsx +104 -0
  187. package/app/apps/web/app/sessions/[id]/session-view.tsx +701 -1577
  188. package/app/apps/web/app/sessions/[id]/team-tab/minimap-hover-card.tsx +201 -0
  189. package/app/apps/web/app/sessions/[id]/team-tab/minimap-idle-band.tsx +53 -0
  190. package/app/apps/web/app/sessions/[id]/team-tab/minimap-shared.ts +20 -0
  191. package/app/apps/web/app/sessions/[id]/team-tab/team-minimap.tsx +10 -265
  192. package/app/apps/web/app/sessions/sessions-grid.tsx +43 -19
  193. package/app/apps/web/components/digest-actions.tsx +103 -0
  194. package/app/apps/web/components/job-queue-widget.tsx +1 -1
  195. package/app/apps/web/components/live-badge.tsx +11 -3
  196. package/app/apps/web/components/live-sessions-widget.tsx +9 -3
  197. package/app/apps/web/components/month-digest-view.tsx +19 -64
  198. package/app/apps/web/components/outcome-pill.tsx +24 -4
  199. package/app/apps/web/components/searchable-select.tsx +215 -0
  200. package/app/apps/web/components/tail-mode.tsx +39 -8
  201. package/app/apps/web/components/week-digest-view.tsx +16 -71
  202. package/app/apps/web/lib/changelog-data.generated.ts +1 -1
  203. package/app/apps/web/lib/entries-index.ts +7 -8
  204. package/app/apps/web/lib/entries.ts +0 -9
  205. package/app/apps/web/lib/format.test.ts +221 -0
  206. package/app/apps/web/lib/utilization-tone.test.ts +56 -0
  207. package/app/apps/web/package.json +1 -1
  208. package/app/apps/web/tsconfig.tsbuildinfo +1 -1
  209. package/dist/daemon-worker.js +83 -45
  210. package/dist/index.js +452 -198
  211. package/package.json +1 -1
  212. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0455vu3._.js +0 -5
  213. package/app/apps/web/.next/server/chunks/[root-of-the-server]__04j3tng._.js +0 -9
  214. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0cnh3k4._.js +0 -9
  215. package/app/apps/web/.next/server/chunks/[root-of-the-server]__132eosx._.js +0 -9
  216. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__136-0me._.js +0 -8
  217. package/app/apps/web/.next/server/chunks/ssr/_0w.cv64._.js +0 -8
  218. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_01d-iwx._.js +0 -6
  219. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0-lmm4p._.js +0 -3
  220. package/app/apps/web/.next/static/chunks/00xkqnvk4-d1-.js +0 -10
  221. package/app/apps/web/.next/static/chunks/0htgc_hr1bw67.js +0 -1
  222. package/app/apps/web/.next/static/chunks/0nnjldasalwdu.js +0 -4
  223. package/app/apps/web/.next/static/chunks/0okg13phslr1..js +0 -4
  224. package/app/apps/web/.next/static/chunks/0vf-3rauafbek.js +0 -6
  225. package/app/apps/web/.next/static/chunks/0vl27-vjob2_9.js +0 -6
  226. /package/app/apps/web/.next/static/{7kQ9mNICnVHhf4-LmSGcy → VEfemGVAQ0xCHjj15orZU}/_buildManifest.js +0 -0
  227. /package/app/apps/web/.next/static/{7kQ9mNICnVHhf4-LmSGcy → VEfemGVAQ0xCHjj15orZU}/_clientMiddlewareManifest.js +0 -0
  228. /package/app/apps/web/.next/static/{7kQ9mNICnVHhf4-LmSGcy → VEfemGVAQ0xCHjj15orZU}/_ssgManifest.js +0 -0
@@ -5,6 +5,7 @@ import React, { useEffect, useMemo, useRef, useState, type CSSProperties } from
5
5
  import {
6
6
  ArrowLeft,
7
7
  ChevronDown,
8
+ ChevronLeft,
8
9
  ChevronRight,
9
10
  ChevronUp,
10
11
  Clock,
@@ -19,7 +20,6 @@ import {
19
20
  import ReactMarkdown from "react-markdown";
20
21
  import remarkGfm from "remark-gfm";
21
22
  import type {
22
- ContentBlock,
23
23
  SessionDetail,
24
24
  SessionEvent,
25
25
  SubagentRun,
@@ -32,13 +32,44 @@ import {
32
32
  buildPresentation,
33
33
  buildMegaRows,
34
34
  detectPrMarkers,
35
- rowPrimaryIndex as rowPrimaryIndexFromLib,
36
35
  type MegaRow,
37
36
  type PresentationRow,
38
37
  type PresentationRowKind,
39
38
  type TurnMegaRow,
40
39
  type TurnSummary,
41
40
  } from "@claude-lens/parser";
41
+ import {
42
+ rowPrimaryIndex,
43
+ type DisplayRow,
44
+ type MinimapSeg,
45
+ } from "./session-view/types";
46
+ import { subagentColor, workflowColor } from "./session-view/colors";
47
+ import {
48
+ formatDayKey,
49
+ flattenMegaRows,
50
+ allRowsAsRawRows,
51
+ formatDurationHeader,
52
+ } from "./session-view/helpers";
53
+ import { EntryDayStrip } from "./session-view/entry-day-strip";
54
+ import { DebugList } from "./session-view/debug-list";
55
+ import { Tooltip, TooltipRow } from "./session-view/tooltip";
56
+ import { TokenChip, TurnTokenChip } from "./session-view/token-stats";
57
+ import {
58
+ InlineStat,
59
+ InlineStatDivider,
60
+ EntrypointBadge,
61
+ InlineTokenStat,
62
+ useAnchoredTooltip,
63
+ AnchoredTooltip,
64
+ } from "./session-view/header-stats";
65
+ import { Drawer } from "./session-view/drawer";
66
+ import {
67
+ WfMiniStat,
68
+ SectionLabel,
69
+ StatCell,
70
+ TokenLine,
71
+ } from "./session-view/workflows-shared";
72
+ import { SubagentDrawer } from "./session-view/subagent-drawer";
42
73
  import { estimateCost, formatCost, formatGap, formatOffset, formatRelative, formatTokens, shortId } from "@/lib/format";
43
74
  import { LiveBadge } from "@/components/live-badge";
44
75
  import { AskButton, AskDrawer } from "@/components/ask";
@@ -169,7 +200,20 @@ export function SessionView({
169
200
  /** When a timeline click sets the index we also want to scroll. Track that
170
201
  * intent separately so selection via row-click doesn't auto-scroll. */
171
202
  const [scrollIntent, setScrollIntent] = useState(0);
203
+ /** Scroll-only target (no selection) — used by day navigation so jumping to
204
+ * a day scrolls the transcript there WITHOUT popping the event drawer the
205
+ * way a row/minimap selection does. `block` aligns the strip to the top
206
+ * ("start", normal day jump) or the bottom ("end", used by "go to end of the
207
+ * previous day" — scrolling the current day's strip to the viewport bottom
208
+ * reveals the previous day's tail above it). `n` forces the effect to re-run
209
+ * even when the same target is requested twice. */
210
+ const [dayScrollTarget, setDayScrollTarget] = useState<
211
+ { index: number; key: string; block: "start" | "end"; n: number } | null
212
+ >(null);
172
213
  const rowRefs = useRef<Record<number, HTMLDivElement | null>>({});
214
+ // Per-day digest cards (EntryDayStrip) are interleaved into the transcript at
215
+ // each day's first row, so day-nav scrolls to the card itself when present.
216
+ const dayStripRefs = useRef<Record<string, HTMLDivElement | null>>({});
173
217
 
174
218
  // The event/subagent drawers are transcript-scoped (position: fixed, shown
175
219
  // whenever a selection exists). Clear the selection when leaving the
@@ -192,6 +236,12 @@ export function SessionView({
192
236
  * at mount + on resize via ResizeObserver — no magic constants. */
193
237
  const headerRef = useRef<HTMLDivElement>(null);
194
238
  const [headerH, setHeaderH] = useState(STICKY_HEADER_HEIGHT);
239
+ // Live mirror so the day-scroll alignment can read the current header height
240
+ // WITHOUT taking headerH as an effect dep — collapse flips headerH on every
241
+ // scroll direction change, and a dep there would re-fire the day scroll mid-
242
+ // free-scroll and yank the view back to the last nav target.
243
+ const headerHRef = useRef(headerH);
244
+ headerHRef.current = headerH;
195
245
  useEffect(() => {
196
246
  const el = headerRef.current;
197
247
  if (!el) return;
@@ -212,6 +262,10 @@ export function SessionView({
212
262
  // When the user explicitly clicks the toggle, suppress auto-collapse
213
263
  // for a short period so the scroll listener doesn't immediately undo it.
214
264
  const manualPinRef = useRef(0);
265
+ // Stamped on every explicit day-jump so TailMode's live-follow observer
266
+ // bails for a beat — otherwise a streaming live event yanks the view back
267
+ // to the bottom mid-navigation. See gotoDay / jumpAcrossDay.
268
+ const tailNavLockRef = useRef(0);
215
269
  const toggleCollapsed = () => {
216
270
  setCollapsed((v) => !v);
217
271
  manualPinRef.current = Date.now();
@@ -289,6 +343,51 @@ export function SessionView({
289
343
  el.scrollIntoView({ block: "start", behavior: "auto" });
290
344
  }, [scrollIntent, selectedIndex]);
291
345
 
346
+ // Scroll-only effect for day navigation — scrolls to a row without setting
347
+ // selectedIndex (so no drawer opens). Same scrollIntoView contract as above.
348
+ useEffect(() => {
349
+ if (!dayScrollTarget) return;
350
+ const main = headerRef.current?.closest("main") as HTMLElement | null;
351
+ // Prefer the day's digest card (it leads the day); fall back to the first
352
+ // row when that day has no entry.
353
+ const el =
354
+ dayStripRefs.current[dayScrollTarget.key] ?? rowRefs.current[dayScrollTarget.index];
355
+ if (!el || !main) return;
356
+ el.scrollIntoView({ block: dayScrollTarget.block, behavior: "auto" });
357
+
358
+ // scrollIntoView aims using the content-visibility ESTIMATED heights of the
359
+ // rows ABOVE the target, so on a long transcript (worst at mount, when none
360
+ // have been laid out yet) it undershoots by 100–200px — leaving the PREVIOUS
361
+ // day's tall trailing turn-block still straddling the header line. The
362
+ // playhead observer then reads THAT block's offset and followDayToScroll
363
+ // reverts the day-jump. For a top-aligned jump, re-measure on the next few
364
+ // frames (real heights now settle progressively) and nudge the target to sit
365
+ // exactly at the header line so the prev block clears it. block:"end" (go to
366
+ // end of prev day) intentionally leaves the prev-day tail on the line, so it
367
+ // needs no correction.
368
+ if (dayScrollTarget.block !== "start") return;
369
+ let frames = 0;
370
+ let settled = 0;
371
+ let raf = 0;
372
+ const align = () => {
373
+ const delta = Math.round(
374
+ el.getBoundingClientRect().top - main.getBoundingClientRect().top - headerHRef.current,
375
+ );
376
+ if (Math.abs(delta) > 1) {
377
+ main.scrollTop += delta;
378
+ settled = 0;
379
+ } else {
380
+ settled++;
381
+ }
382
+ // Keep correcting until it holds for two consecutive frames (rows above
383
+ // the target settle their real content-visibility heights progressively,
384
+ // and on a long transcript that can take many frames). Cap as a backstop.
385
+ if (settled < 2 && ++frames < 20) raf = requestAnimationFrame(align);
386
+ };
387
+ raf = requestAnimationFrame(align);
388
+ return () => cancelAnimationFrame(raf);
389
+ }, [dayScrollTarget]);
390
+
292
391
  const { events, durationMs, totalUsage, model, eventCount, projectName } = session;
293
392
  const airTimeMs = session.airTimeMs ?? durationMs;
294
393
  const workflowCount = session.workflows?.length ?? 0;
@@ -310,11 +409,16 @@ export function SessionView({
310
409
  [events, teammateCount],
311
410
  );
312
411
 
313
- // A session is "live" if its last event was within 45 seconds.
412
+ // A session is "live" if its last activity was within 45 seconds — where
413
+ // "activity" includes nested background-agent/workflow writes, not just the
414
+ // main transcript's last event (lastActivityMs).
314
415
  const isSessionLive = (() => {
315
- if (!session.lastTimestamp) return false;
316
- const ms = Date.parse(session.lastTimestamp);
317
- return !Number.isNaN(ms) && Date.now() - ms < 45_000;
416
+ const isoMs = session.lastTimestamp ? Date.parse(session.lastTimestamp) : NaN;
417
+ const ms = Math.max(
418
+ Number.isNaN(isoMs) ? 0 : isoMs,
419
+ session.lastActivityMs && Number.isFinite(session.lastActivityMs) ? session.lastActivityMs : 0,
420
+ );
421
+ return ms > 0 && Date.now() - ms < 45_000;
318
422
  })();
319
423
 
320
424
  /** Detect PR creations in this session. */
@@ -488,6 +592,135 @@ export function SessionView({
488
592
  return out;
489
593
  }, [events]);
490
594
 
595
+ /** Local-day buckets the session spans, in event order. Each day's
596
+ * `startMs`/`endMs` are tOffsetMs bounds (offset from session start) so
597
+ * they plug straight into the minimap's offset coordinate space. Keys are
598
+ * derived as literal `YYYY-MM-DD` from local date parts so SSR and client
599
+ * agree (no locale/Date formatting that could drift across the hydration
600
+ * boundary). Long multi-day sessions use this to scope the minimap to one
601
+ * day at a time instead of squashing days into a single bar. */
602
+ const sessionDays = useMemo(() => {
603
+ const byDay = new Map<string, { startMs: number; endMs: number; count: number }>();
604
+ for (const e of events) {
605
+ if (e.tOffsetMs === undefined || !e.timestamp) continue;
606
+ const ms = Date.parse(e.timestamp);
607
+ if (!Number.isFinite(ms)) continue;
608
+ const d = new Date(ms);
609
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
610
+ const cur = byDay.get(key);
611
+ if (!cur) byDay.set(key, { startMs: e.tOffsetMs, endMs: e.tOffsetMs, count: 1 });
612
+ else {
613
+ cur.startMs = Math.min(cur.startMs, e.tOffsetMs);
614
+ cur.endMs = Math.max(cur.endMs, e.tOffsetMs);
615
+ cur.count += 1;
616
+ }
617
+ }
618
+ return [...byDay.entries()]
619
+ .map(([key, v]) => ({ key, ...v }))
620
+ .sort((a, b) => a.startMs - b.startMs);
621
+ }, [events]);
622
+
623
+ /** local_day → per-day digest card. `entry.local_day` and `sessionDays` keys
624
+ * are both built from local-time getFullYear/Month/Date (see parser's
625
+ * toLocalDay), so the strings match exactly and the card lands on the right
626
+ * day's first transcript row. */
627
+ const entryByDay = useMemo(() => {
628
+ const m = new Map<string, Entry>();
629
+ for (const e of entries) m.set(e.local_day, e);
630
+ return m;
631
+ }, [entries]);
632
+
633
+ // Selected timeline day. Defaults to the LAST (most recent) active day so a
634
+ // long, multi-day transcript opens where the action is rather than at the
635
+ // top of an old day; the initial-jump effect below scrolls there instantly,
636
+ // and the day then follows the scroll position as the user reads in either
637
+ // direction (see followDayToScroll). The lazy initializer runs identically on
638
+ // server + client (sessionDays is deterministic), so there's no hydration
639
+ // mismatch; the effect re-pins it when the component is reused across
640
+ // /sessions/[id] navigations. "all" shows the full session.
641
+ const didInitialDayJump = useRef(false);
642
+ const [selectedDayKey, setSelectedDayKey] = useState<string>(
643
+ () => (sessionDays.length ? sessionDays[sessionDays.length - 1]!.key : "all"),
644
+ );
645
+ useEffect(() => {
646
+ setSelectedDayKey(sessionDays.length ? sessionDays[sessionDays.length - 1]!.key : "all");
647
+ didInitialDayJump.current = false;
648
+ // eslint-disable-next-line react-hooks/exhaustive-deps
649
+ }, [session.sessionId]);
650
+
651
+ const dayIndex = sessionDays.findIndex((d) => d.key === selectedDayKey);
652
+ // Latest sessionDays, read by the initial-jump effect so it doesn't re-run on
653
+ // every render. On LIVE sessions SSE router.refresh() rebuilds sessionDays each
654
+ // tick; reading it via ref keeps the once-per-session jump from re-firing.
655
+ const sessionDaysRef = useRef(sessionDays);
656
+ sessionDaysRef.current = sessionDays;
657
+ // Read by followDayToScroll (the scroll→day follower) without stale closure.
658
+ const selectedDayKeyRef = useRef(selectedDayKey);
659
+ selectedDayKeyRef.current = selectedDayKey;
660
+
661
+ // Initial jump: on first paint of a multi-day session, snap straight to the
662
+ // most recent day (the default selectedDayKey is already the last day, so the
663
+ // minimap matches). Routed through gotoDay rather than scrolling to the day's
664
+ // strip directly: in "turns" mode the most recent day can be absorbed into a
665
+ // turn anchored on an earlier day and so have NO inline strip — gotoDay finds
666
+ // the day's first event, expands its turn, and falls back to the row ref, so
667
+ // it lands reliably either way. Single-day sessions stay at the top. Runs once
668
+ // per session; the re-pin effect above clears didInitialDayJump on nav.
669
+ //
670
+ // SKIPPED for live sessions: their destination is the live tail, which
671
+ // TailMode scrolls to on mount. Jumping to the most-recent-day's TOP would
672
+ // scroll the view UP, and that programmatic scroll trips TailMode's scroll-up
673
+ // detector (check()) into turning live-follow off — and it would race
674
+ // TailMode's own mount scroll. followDayToScroll still keeps the minimap on
675
+ // the last day, since the tail sits there.
676
+ useEffect(() => {
677
+ if (tab !== "transcript" || didInitialDayJump.current) return;
678
+ const days = sessionDaysRef.current;
679
+ if (days.length <= 1 || isSessionLive) {
680
+ didInitialDayJump.current = true;
681
+ return;
682
+ }
683
+ didInitialDayJump.current = true;
684
+ gotoDay(days[days.length - 1]!.key);
685
+ // eslint-disable-next-line react-hooks/exhaustive-deps
686
+ }, [tab, sessionDays]);
687
+
688
+ // The displayed day is a single function of scroll position. The Minimap
689
+ // reports the offset (ms) of the topmost on-screen row — the SAME signal it
690
+ // draws the playhead from — and we select that row's day. Display only: this
691
+ // never scrolls the transcript. Navigation (gotoDay / the inline day-boundary
692
+ // buttons) moves the scroll, and the day then follows this one signal, so
693
+ // there's no second writer that could revert a day-jump. (The old scroll-spy
694
+ // used a separate pixel heuristic that disagreed with where nav scrolled to,
695
+ // which is what made "next day" snap back.) The tailNavLock guard lets an
696
+ // explicit jump's scroll settle first, and the key-unchanged no-op keeps a
697
+ // LIVE session's SSE refreshes from churning the selection.
698
+ function followDayToScroll(ms: number | null) {
699
+ if (ms === null) return;
700
+ if (Date.now() - tailNavLockRef.current < 1500) return;
701
+ const days = sessionDaysRef.current;
702
+ if (days.length <= 1) return;
703
+ const cur = selectedDayKeyRef.current;
704
+ if (cur === "all") return; // respect an explicit "show all days" choice
705
+ // Days are sorted by startMs; the last one starting at/below ms is the
706
+ // containing day (and tolerates the overnight gap between days).
707
+ let hit = days[0]!;
708
+ for (const d of days) {
709
+ if (ms >= d.startMs) hit = d;
710
+ else break;
711
+ }
712
+ if (hit.key !== cur) setSelectedDayKey(hit.key);
713
+ }
714
+
715
+ /** Offset window for the selected day, or null when there's a single day
716
+ * or "all" is chosen — null tells the minimap to render the full session
717
+ * exactly as before (no regression for single-day sessions). */
718
+ const dayWindow = useMemo(() => {
719
+ if (sessionDays.length <= 1 || selectedDayKey === "all") return null;
720
+ const d = sessionDays.find((x) => x.key === selectedDayKey);
721
+ return d ? { startMs: d.startMs, endMs: d.endMs } : null;
722
+ }, [sessionDays, selectedDayKey]);
723
+
491
724
  /** Build the full presentation stream once. */
492
725
  const allRows = useMemo(() => buildPresentation(visibleEvents), [visibleEvents]);
493
726
 
@@ -568,6 +801,67 @@ export function SessionView({
568
801
  setScrollIntent((n) => n + 1);
569
802
  }
570
803
 
804
+ /** Switch the minimap to a given day (or "all"). Also scrolls the transcript
805
+ * to that day's first row so "jump to a day" navigates the body too. Called
806
+ * on user day-picks and once on mount by the initial-jump effect (for non-live
807
+ * multi-day sessions, to open on the most recent day). */
808
+ function gotoDay(key: string) {
809
+ setSelectedDayKey(key);
810
+ tailNavLockRef.current = Date.now();
811
+ // Paging days scrolls the transcript; that programmatic scroll (often
812
+ // upward to an earlier day's first row) would otherwise read as a
813
+ // user scroll-up and pop the sticky header open. Force it collapsed and
814
+ // pin so the scroll listener leaves it alone — day-nav always stays
815
+ // collapsed.
816
+ setCollapsed(true);
817
+ manualPinRef.current = Date.now();
818
+ if (key === "all") return;
819
+ const d = sessionDays.find((x) => x.key === key);
820
+ if (!d) return;
821
+ const ev = events.find((e) => e.tOffsetMs !== undefined && e.tOffsetMs >= d.startMs);
822
+ if (!ev) return;
823
+ // In turns mode the day's first row may be inside a collapsed turn — expand
824
+ // it so the row exists in the DOM before the scroll effect runs.
825
+ if (filter === "turns") {
826
+ const turnIdx = turnByRowIndex.get(ev.index);
827
+ if (turnIdx !== undefined && !expandedTurns.has(turnIdx)) {
828
+ setExpandedTurns((prev) => new Set(prev).add(turnIdx));
829
+ }
830
+ }
831
+ setDayScrollTarget((prev) => ({ index: ev.index, key, block: "start", n: (prev?.n ?? 0) + 1 }));
832
+ }
833
+
834
+ /** Cross-day hop used by the inline day-boundary buttons. Selecting a day can
835
+ * differ from where we scroll: "go to end of previous day" selects the
836
+ * previous day but scrolls the CURRENT day's strip to the viewport bottom
837
+ * (block:"end"), revealing the previous day's tail above it. "go to start of
838
+ * next day" selects + scrolls to that day's strip (block:"start"). */
839
+ function jumpAcrossDay(
840
+ selectKey: string,
841
+ scrollKey: string,
842
+ block: "start" | "end",
843
+ fallbackIndex: number,
844
+ ) {
845
+ setSelectedDayKey(selectKey);
846
+ setCollapsed(true);
847
+ manualPinRef.current = Date.now();
848
+ tailNavLockRef.current = Date.now();
849
+ // The fallback row may live inside a collapsed turn — expand it so the
850
+ // element exists if the strip ref is unavailable.
851
+ if (filter === "turns") {
852
+ const turnIdx = turnByRowIndex.get(fallbackIndex);
853
+ if (turnIdx !== undefined && !expandedTurns.has(turnIdx)) {
854
+ setExpandedTurns((prev) => new Set(prev).add(turnIdx));
855
+ }
856
+ }
857
+ setDayScrollTarget((prev) => ({
858
+ index: fallbackIndex,
859
+ key: scrollKey,
860
+ block,
861
+ n: (prev?.n ?? 0) + 1,
862
+ }));
863
+ }
864
+
571
865
  /** Scroll the transcript to the row whose assistant message issued a given
572
866
  * tool_use id. Used by workflow cards / lanes to jump to the originating
573
867
  * Workflow tool call. */
@@ -657,7 +951,7 @@ export function SessionView({
657
951
  className={`af-tab-btn ${tab === "transcript" ? "active" : ""}`}
658
952
  onClick={() => setTab("transcript")}
659
953
  >
660
- Transcript
954
+ Timeline
661
955
  </button>
662
956
  {hasWorkflows && (
663
957
  <button
@@ -693,7 +987,7 @@ export function SessionView({
693
987
  className={`af-tab-btn ${tab === "debug" ? "active" : ""}`}
694
988
  onClick={() => setTab("debug")}
695
989
  >
696
- Debug
990
+ Log
697
991
  </button>
698
992
  </div>
699
993
  <button
@@ -754,7 +1048,7 @@ export function SessionView({
754
1048
  >
755
1049
  sesn_{session.id.replace(/-/g, "").slice(0, 22)}
756
1050
  </h1>
757
- <LiveBadge mtimeIso={session.lastTimestamp} size="md" />
1051
+ <LiveBadge mtimeIso={session.lastTimestamp} activityMs={session.lastActivityMs} size="md" />
758
1052
  <span
759
1053
  style={{
760
1054
  fontSize: 10.5,
@@ -767,7 +1061,7 @@ export function SessionView({
767
1061
  top: -1,
768
1062
  }}
769
1063
  >
770
- {session.status === "running" ? "Running" : "Idle"}
1064
+ {isSessionLive ? "Running" : "Idle"}
771
1065
  </span>
772
1066
  <InlineStatDivider />
773
1067
  {model && <InlineStat icon={<Cpu size={12} />} value={model} mono />}
@@ -921,6 +1215,63 @@ export function SessionView({
921
1215
  transition: "padding-bottom 0.2s ease",
922
1216
  }}
923
1217
  >
1218
+ {/* Day navigator — only for multi-day sessions on the non-team
1219
+ minimap. Lets you step the timeline through each local day (default
1220
+ = the most recent) so a long-running session reads one day at a
1221
+ time instead of squashed into one bar. */}
1222
+ {tab !== "team" && sessionDays.length > 1 && (
1223
+ <div
1224
+ style={{
1225
+ display: "flex",
1226
+ alignItems: "center",
1227
+ gap: 8,
1228
+ marginBottom: 6,
1229
+ fontSize: 12,
1230
+ color: "var(--af-text-secondary)",
1231
+ }}
1232
+ >
1233
+ <span style={{ color: "var(--af-text-tertiary)" }}>Timeline day</span>
1234
+ <button
1235
+ type="button"
1236
+ title="Previous day"
1237
+ aria-label="Previous day"
1238
+ onClick={() => dayIndex > 0 && gotoDay(sessionDays[dayIndex - 1]!.key)}
1239
+ disabled={selectedDayKey === "all" || dayIndex <= 0}
1240
+ className="sl-day-nav-btn"
1241
+ >
1242
+ <ChevronLeft size={13} />
1243
+ </button>
1244
+ <select
1245
+ value={selectedDayKey}
1246
+ onChange={(e) => gotoDay(e.target.value)}
1247
+ className="sl-compact-select"
1248
+ >
1249
+ {sessionDays.map((d, i) => (
1250
+ <option key={d.key} value={d.key}>
1251
+ {formatDayKey(d.key)} · {d.count} event{d.count === 1 ? "" : "s"} (day {i + 1}/{sessionDays.length})
1252
+ </option>
1253
+ ))}
1254
+ <option value="all">All days ({sessionDays.length})</option>
1255
+ </select>
1256
+ <button
1257
+ type="button"
1258
+ title="Next day"
1259
+ aria-label="Next day"
1260
+ onClick={() =>
1261
+ dayIndex >= 0 && dayIndex < sessionDays.length - 1 && gotoDay(sessionDays[dayIndex + 1]!.key)
1262
+ }
1263
+ disabled={selectedDayKey === "all" || dayIndex < 0 || dayIndex >= sessionDays.length - 1}
1264
+ className="sl-day-nav-btn"
1265
+ >
1266
+ <ChevronRight size={13} />
1267
+ </button>
1268
+ <span style={{ color: "var(--af-text-tertiary)", marginLeft: 2 }}>
1269
+ {selectedDayKey === "all"
1270
+ ? `showing all ${sessionDays.length} days`
1271
+ : `day ${dayIndex + 1} of ${sessionDays.length}`}
1272
+ </span>
1273
+ </div>
1274
+ )}
924
1275
  {tab === "team" && team ? (
925
1276
  <TeamMinimap
926
1277
  data={team}
@@ -937,6 +1288,7 @@ export function SessionView({
937
1288
  <Minimap
938
1289
  displayRows={displayRows}
939
1290
  durationMs={durationMs ?? 0}
1291
+ dayWindow={dayWindow}
940
1292
  selectedIndex={selectedIndex}
941
1293
  onSelect={scrollToIndex}
942
1294
  headerOffset={headerH}
@@ -958,6 +1310,7 @@ export function SessionView({
958
1310
  // for the same right-side real estate.
959
1311
  if (id) setSelectedIndex(null);
960
1312
  }}
1313
+ onPlayheadChange={followDayToScroll}
961
1314
  />
962
1315
  )}
963
1316
  </div>
@@ -1002,17 +1355,13 @@ export function SessionView({
1002
1355
  )
1003
1356
  }
1004
1357
  onJumpToParent={jumpToToolUse}
1358
+ onClearFocus={() => setSelectedWorkflowId(null)}
1005
1359
  />
1006
1360
  </div>
1007
1361
  ) : tab === "debug" ? (
1008
1362
  <DebugList events={events} />
1009
1363
  ) : (
1010
1364
  <>
1011
- {entries.length > 0 && (
1012
- <div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 14 }}>
1013
- {entries.map((e) => <EntryDayStrip key={e.local_day} entry={e} />)}
1014
- </div>
1015
- )}
1016
1365
  {teammateCount > 0 && (
1017
1366
  <div
1018
1367
  style={{
@@ -1055,6 +1404,10 @@ export function SessionView({
1055
1404
  team={team}
1056
1405
  model={model}
1057
1406
  rawIdleBands={rawIdleBands}
1407
+ sessionDays={sessionDays}
1408
+ entryByDay={entryByDay}
1409
+ dayStripRefs={dayStripRefs}
1410
+ onJumpAcrossDay={jumpAcrossDay}
1058
1411
  />
1059
1412
  </>
1060
1413
  )}
@@ -1185,7 +1538,7 @@ export function SessionView({
1185
1538
  )}
1186
1539
 
1187
1540
  {/* Tail mode FAB — auto-scroll to follow live events */}
1188
- <TailMode isLive={isSessionLive} />
1541
+ <TailMode isLive={isSessionLive} navLockRef={tailNavLockRef} />
1189
1542
  </div>
1190
1543
  );
1191
1544
  }
@@ -1194,353 +1547,19 @@ export function SessionView({
1194
1547
  /* Row helpers */
1195
1548
  /* ------------------------------------------------------------------ */
1196
1549
 
1197
- const rowPrimaryIndex = rowPrimaryIndexFromLib;
1198
-
1199
- /* ------------------------------------------------------------------ */
1200
- /* DisplayRow: one line in the transcript list */
1201
- /* */
1202
- /* Flat list item type — either a presentation row (user, agent, */
1203
- /* tool-group, error, ...), a collapsed turn summary, or an */
1204
- /* expanded-turn header with its inner rows following. */
1205
- /* ------------------------------------------------------------------ */
1206
-
1207
- type DisplayRow =
1208
- | { kind: "presentation"; row: PresentationRow; indented?: boolean }
1209
- | { kind: "turn-collapsed"; turn: TurnMegaRow }
1210
- | { kind: "turn-expanded-header"; turn: TurnMegaRow }
1211
- | { kind: "turn-expanded-footer"; turn: TurnMegaRow };
1212
-
1213
- function flattenMegaRows(megaRows: MegaRow[], expanded: Set<number>): DisplayRow[] {
1214
- const out: DisplayRow[] = [];
1215
- for (const m of megaRows) {
1216
- if (m.kind === "turn") {
1217
- if (expanded.has(m.firstPrimaryIndex)) {
1218
- out.push({ kind: "turn-expanded-header", turn: m });
1219
- for (const r of m.rows) {
1220
- out.push({ kind: "presentation", row: r, indented: true });
1221
- }
1222
- out.push({ kind: "turn-expanded-footer", turn: m });
1223
- } else {
1224
- out.push({ kind: "turn-collapsed", turn: m });
1225
- }
1226
- } else {
1227
- out.push({ kind: "presentation", row: m });
1228
- }
1229
- }
1230
- return out;
1231
- }
1232
-
1233
- /** Fallback for "All events" filter mode — wraps each raw event in a
1234
- * synthetic PresentationRow so the transcript component can render
1235
- * everything (attachments, meta, thinking, tool_result) without
1236
- * going through the meaningful-transformation logic. */
1237
- function allRowsAsRawRows(events: SessionEvent[]): PresentationRow[] {
1238
- const out: PresentationRow[] = [];
1239
- for (const e of events) {
1240
- // Coerce every raw event to the closest presentation kind.
1241
- let kind: PresentationRowKind;
1242
- switch (e.role) {
1243
- case "user":
1244
- kind = "user";
1245
- break;
1246
- case "agent":
1247
- case "agent-thinking":
1248
- kind = "agent";
1249
- break;
1250
- case "tool-call":
1251
- case "tool-result":
1252
- kind = "tool-group";
1253
- break;
1254
- case "system":
1255
- case "meta":
1256
- kind = "model";
1257
- break;
1258
- }
1259
- if (kind === "tool-group") {
1260
- out.push({
1261
- kind: "tool-group",
1262
- toolNames: [{ name: e.toolName ?? e.rawType, count: 1 }],
1263
- count: 1,
1264
- events: [e],
1265
- tOffsetMs: e.tOffsetMs,
1266
- gapMs: e.gapMs,
1267
- });
1268
- } else if (kind === "agent") {
1269
- out.push({
1270
- kind: "agent",
1271
- event: e,
1272
- groupedEvents: [e],
1273
- tOffsetMs: e.tOffsetMs,
1274
- gapMs: e.gapMs,
1275
- });
1276
- } else if (kind === "user") {
1277
- out.push({
1278
- kind: "user",
1279
- event: e,
1280
- tOffsetMs: e.tOffsetMs,
1281
- gapMs: e.gapMs,
1282
- });
1283
- } else {
1284
- out.push({
1285
- kind: "model",
1286
- event: e,
1287
- tOffsetMs: e.tOffsetMs,
1288
- gapMs: e.gapMs,
1289
- });
1290
- }
1291
- }
1292
- return out;
1293
- }
1550
+ // rowPrimaryIndex, DisplayRow, flattenMegaRows, allRowsAsRawRows moved to ./session-view/{types,helpers}.ts
1294
1551
 
1295
1552
  /* ------------------------------------------------------------------ */
1296
1553
  /* Header stats + token stat w/ tooltip */
1297
1554
  /* ------------------------------------------------------------------ */
1298
1555
 
1299
- /** Compact inline header stat — icon + value, separated by dots.
1300
- * Used in the single-line session header, modeled on Claude's Sessions page. */
1301
- function InlineStat({
1302
- icon,
1303
- value,
1304
- mono,
1305
- truncate,
1306
- }: {
1307
- icon?: React.ReactNode;
1308
- value: string;
1309
- mono?: boolean;
1310
- truncate?: boolean;
1311
- }) {
1312
- return (
1313
- <span
1314
- style={{
1315
- display: "inline-flex",
1316
- alignItems: "center",
1317
- gap: 5,
1318
- fontSize: 12,
1319
- color: "var(--af-text-secondary)",
1320
- maxWidth: truncate ? 260 : undefined,
1321
- overflow: "hidden",
1322
- textOverflow: "ellipsis",
1323
- whiteSpace: "nowrap",
1324
- }}
1325
- >
1326
- {icon && (
1327
- <span
1328
- style={{
1329
- display: "inline-flex",
1330
- color: "var(--af-text-tertiary)",
1331
- }}
1332
- >
1333
- {icon}
1334
- </span>
1335
- )}
1336
- <span
1337
- style={{
1338
- fontFamily: mono ? "var(--font-mono)" : "inherit",
1339
- overflow: "hidden",
1340
- textOverflow: "ellipsis",
1341
- whiteSpace: "nowrap",
1342
- }}
1343
- >
1344
- {value}
1345
- </span>
1346
- </span>
1347
- );
1348
- }
1349
-
1350
- function InlineStatDivider() {
1351
- return (
1352
- <span
1353
- style={{
1354
- color: "var(--af-text-tertiary)",
1355
- fontSize: 11,
1356
- opacity: 0.6,
1357
- }}
1358
- >
1359
- ·
1360
- </span>
1361
- );
1362
- }
1363
-
1364
- function EntrypointBadge({ entrypoint }: { entrypoint: string }) {
1365
- // cli + claude-desktop are human-driven; sdk-* are programmatic.
1366
- const isSdk = entrypoint.startsWith("sdk-");
1367
- const tone = isSdk
1368
- ? { bg: "rgba(245, 158, 11, 0.16)", fg: "#b45309" }
1369
- : { bg: "rgba(16, 185, 129, 0.16)", fg: "#047857" };
1370
- return (
1371
- <span
1372
- title={`entrypoint: ${entrypoint}`}
1373
- style={{
1374
- fontSize: 10.5,
1375
- padding: "2px 7px",
1376
- borderRadius: 4,
1377
- background: tone.bg,
1378
- color: tone.fg,
1379
- fontWeight: 600,
1380
- fontFamily: "var(--font-mono)",
1381
- letterSpacing: 0.2,
1382
- }}
1383
- >
1384
- {entrypoint}
1385
- </span>
1386
- );
1387
- }
1388
-
1389
- function InlineTokenStat({ usage }: { usage: SessionEvent["usage"] }) {
1390
- const { ref, anchor, open, close } = useAnchoredTooltip();
1391
- const u = usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1392
- const totalIn = u.input + u.cacheRead + u.cacheWrite;
1393
- const pctRead = totalIn > 0 ? Math.round((u.cacheRead / totalIn) * 100) : 0;
1394
- const cached = u.cacheRead + u.cacheWrite;
1395
-
1396
- return (
1397
- <span
1398
- ref={ref}
1399
- style={{
1400
- display: "inline-flex",
1401
- alignItems: "center",
1402
- gap: 6,
1403
- fontSize: 12,
1404
- color: "var(--af-text-secondary)",
1405
- cursor: "default",
1406
- fontFamily: "var(--font-mono)",
1407
- }}
1408
- onMouseEnter={open}
1409
- onMouseLeave={close}
1410
- >
1411
- <span>
1412
- {formatTokens(u.input)}
1413
- <span style={{ color: "var(--af-text-tertiary)", margin: "0 3px" }}>in</span>
1414
- {formatTokens(u.output)}
1415
- <span style={{ color: "var(--af-text-tertiary)", marginLeft: 3 }}>out</span>
1416
- </span>
1417
- {cached > 0 && (
1418
- <span
1419
- style={{
1420
- fontSize: 10.5,
1421
- color: "var(--af-text-tertiary)",
1422
- paddingLeft: 6,
1423
- borderLeft: "1px solid var(--af-border-subtle)",
1424
- }}
1425
- >
1426
- +{formatTokens(cached)} cached
1427
- </span>
1428
- )}
1429
- {anchor && (
1430
- <AnchoredTooltip anchor={anchor} width={280}>
1431
- <TooltipRow label="Input (fresh)" value={u.input.toLocaleString()} />
1432
- <TooltipRow label="Output" value={u.output.toLocaleString()} />
1433
- <TooltipRow label="Cache read" value={`${u.cacheRead.toLocaleString()} (${pctRead}%)`} />
1434
- <TooltipRow label="Cache write" value={u.cacheWrite.toLocaleString()} />
1435
- <div
1436
- style={{
1437
- marginTop: 6,
1438
- paddingTop: 6,
1439
- borderTop: "1px solid rgba(241,245,249,0.12)",
1440
- opacity: 0.65,
1441
- fontSize: 10,
1442
- whiteSpace: "normal",
1443
- lineHeight: 1.4,
1444
- }}
1445
- >
1446
- Cache reads are cumulative across all API requests and billed at ~10% of regular input.
1447
- </div>
1448
- </AnchoredTooltip>
1449
- )}
1450
- </span>
1451
- );
1452
- }
1453
-
1454
- // Used for tooltips that live inside an overflow:hidden ancestor (like the
1455
- // session-header row). getBoundingClientRect + position:fixed lets the
1456
- // tooltip escape the clipping context instead of being cropped.
1457
- function useAnchoredTooltip() {
1458
- const ref = useRef<HTMLSpanElement>(null);
1459
- const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
1460
- const open = () => {
1461
- if (!ref.current) return;
1462
- const r = ref.current.getBoundingClientRect();
1463
- setAnchor({ top: r.bottom + 6, left: r.left });
1464
- };
1465
- const close = () => setAnchor(null);
1466
- return { ref, anchor, open, close };
1467
- }
1468
-
1469
- function AnchoredTooltip({
1470
- anchor,
1471
- width,
1472
- children,
1473
- }: {
1474
- anchor: { top: number; left: number };
1475
- width: number;
1476
- children: React.ReactNode;
1477
- }) {
1478
- return (
1479
- <div
1480
- style={{
1481
- position: "fixed",
1482
- top: anchor.top,
1483
- left: anchor.left,
1484
- zIndex: 1000,
1485
- background: "#1A1A1A",
1486
- color: "#F5F1EC",
1487
- padding: "8px 12px",
1488
- borderRadius: 6,
1489
- fontSize: 11,
1490
- fontFamily: "var(--font-mono)",
1491
- lineHeight: 1.5,
1492
- pointerEvents: "none",
1493
- boxShadow: "0 4px 16px rgba(15,23,42,0.24)",
1494
- width,
1495
- }}
1496
- >
1497
- {children}
1498
- </div>
1499
- );
1500
- }
1556
+ // InlineStat, InlineStatDivider, EntrypointBadge, InlineTokenStat, useAnchoredTooltip, AnchoredTooltip moved to ./session-view/header-stats.tsx
1501
1557
 
1502
1558
  /* ------------------------------------------------------------------ */
1503
1559
  /* Generic Tooltip */
1504
1560
  /* ------------------------------------------------------------------ */
1505
1561
 
1506
- function Tooltip({ children, style }: { children: React.ReactNode; style?: CSSProperties }) {
1507
- return (
1508
- <div
1509
- style={{
1510
- position: "absolute",
1511
- zIndex: 100,
1512
- background: "#1A1A1A",
1513
- color: "#F5F1EC",
1514
- borderRadius: 8,
1515
- padding: "10px 12px",
1516
- fontSize: 11,
1517
- fontFamily: "var(--font-mono)",
1518
- lineHeight: 1.5,
1519
- boxShadow: "0 4px 20px rgba(0,0,0,0.18)",
1520
- pointerEvents: "none",
1521
- whiteSpace: "nowrap",
1522
- ...style,
1523
- }}
1524
- >
1525
- {children}
1526
- </div>
1527
- );
1528
- }
1529
-
1530
- function TooltipRow({ label, value }: { label: string; value: string }) {
1531
- return (
1532
- <div
1533
- style={{
1534
- display: "flex",
1535
- justifyContent: "space-between",
1536
- gap: 12,
1537
- }}
1538
- >
1539
- <span style={{ opacity: 0.7 }}>{label}</span>
1540
- <span>{value}</span>
1541
- </div>
1542
- );
1543
- }
1562
+ // Tooltip, TooltipRow moved to ./session-view/tooltip.tsx
1544
1563
 
1545
1564
  /* ------------------------------------------------------------------ */
1546
1565
  /* Mini-map */
@@ -1561,22 +1580,7 @@ function TooltipRow({ label, value }: { label: string; value: string }) {
1561
1580
  /* - Rich hover card (role pill + preview) instead of bare SVG title. */
1562
1581
  /* ------------------------------------------------------------------ */
1563
1582
 
1564
- type MinimapSeg =
1565
- | {
1566
- kind: "row";
1567
- row: PresentationRow;
1568
- primaryIndex: number;
1569
- start: number;
1570
- end: number;
1571
- }
1572
- | {
1573
- kind: "turn";
1574
- turn: TurnMegaRow;
1575
- primaryIndex: number;
1576
- start: number;
1577
- end: number;
1578
- }
1579
- | { kind: "idle"; start: number; end: number; durationMs: number };
1583
+ // MinimapSeg moved to ./session-view/types.ts
1580
1584
 
1581
1585
  /** All rows render at full bar height — distinction comes from color/pattern
1582
1586
  * alone, matching Claude Managed Agents' unified-height timeline. */
@@ -1593,6 +1597,7 @@ const ROW_IMPORTANCE: Record<PresentationRowKind, number> = {
1593
1597
  function Minimap({
1594
1598
  displayRows,
1595
1599
  durationMs,
1600
+ dayWindow,
1596
1601
  selectedIndex,
1597
1602
  onSelect,
1598
1603
  headerOffset,
@@ -1606,9 +1611,13 @@ function Minimap({
1606
1611
  coldResumeMarkers,
1607
1612
  rawIdleBands,
1608
1613
  model,
1614
+ onPlayheadChange,
1609
1615
  }: {
1610
1616
  displayRows: DisplayRow[];
1611
1617
  durationMs: number;
1618
+ /** When set, scope the timeline to this offset window (one local day).
1619
+ * null renders the full session. */
1620
+ dayWindow?: { startMs: number; endMs: number } | null;
1612
1621
  selectedIndex: number | null;
1613
1622
  onSelect: (i: number) => void;
1614
1623
  headerOffset: number;
@@ -1629,6 +1638,10 @@ function Minimap({
1629
1638
  * surface their dead-air on the minimap. */
1630
1639
  rawIdleBands?: { start: number; end: number; durationMs: number }[];
1631
1640
  model?: string;
1641
+ /** Fires with the offset (ms) of the topmost on-screen transcript row — the
1642
+ * same scroll signal that draws the playhead. SessionView uses it to make
1643
+ * the selected day follow scroll. */
1644
+ onPlayheadChange?: (ms: number | null) => void;
1632
1645
  }) {
1633
1646
  const WIDTH = 1400;
1634
1647
  /** Main timeline height. Sub-agent lanes stack below this. */
@@ -1667,6 +1680,10 @@ function Minimap({
1667
1680
  };
1668
1681
  } | null>(null);
1669
1682
  const [playheadMs, setPlayheadMs] = useState<number | null>(null);
1683
+ // Read inside the observer so the latest callback fires without re-subscribing
1684
+ // the IntersectionObserver (its effect deps stay [displayRows.length, headerOffset]).
1685
+ const onPlayheadChangeRef = useRef(onPlayheadChange);
1686
+ onPlayheadChangeRef.current = onPlayheadChange;
1670
1687
  const containerRef = useRef<HTMLDivElement>(null);
1671
1688
 
1672
1689
  /* Playhead — track which transcript row is at the top of the viewport.
@@ -1675,60 +1692,54 @@ function Minimap({
1675
1692
  * on every scroll event. For a session with ~2000 rows that's thousands
1676
1693
  * of DOM queries per second during scroll — enough to pin a CPU core
1677
1694
  * and make Chrome ask to kill the tab. IntersectionObserver is event-
1678
- * driven: the browser only notifies us when rows cross a narrow band
1679
- * near the top of the viewport (the "playhead line"), so the cost
1680
- * scales with *changes* instead of with scroll rate × row count.
1695
+ * driven: the browser only notifies us when rows enter or leave the region
1696
+ * below the sticky header, so the cost scales with *changes* instead of
1697
+ * with scroll rate × row count.
1681
1698
  */
1682
1699
  useEffect(() => {
1683
1700
  const main = containerRef.current?.closest("main") as HTMLElement | null;
1684
1701
  if (!main) return;
1685
1702
 
1686
- // A thin horizontal band just below the sticky header. A row is
1687
- // "at the playhead" when it enters this band from the bottom.
1688
- // rootMargin: top crop = headerOffset (so the band starts where the
1689
- // transcript actually starts), bottom crop = everything except the
1690
- // band, so only rows inside the band trigger callbacks.
1691
- const BAND_HEIGHT = 12;
1692
- const rootMargin = `-${headerOffset}px 0px -${Math.max(
1693
- 0,
1694
- main.clientHeight - headerOffset - BAND_HEIGHT,
1695
- )}px 0px`;
1696
-
1697
- // Track which rows are currently inside the band. The topmost is
1698
- // the playhead. Using a Set keeps updates O(1).
1699
- const inBand = new Set<HTMLElement>();
1703
+ // Crop the observation region by the sticky header at the top and keep the
1704
+ // whole viewport below it. Every row visible below the header intersects
1705
+ // this region; the topmost (smallest offsetTop) is the row sitting at the
1706
+ // header line — the playhead. Using the full area (not a thin band) means
1707
+ // the playhead never blanks out in the gaps between sparse rows, so the
1708
+ // scroll→timeline highlight — and the day-follow that rides on it — stays
1709
+ // continuous. Still event-driven: a row only fires when it enters or
1710
+ // leaves, so cost scales with scroll distance, not row count.
1711
+ const rootMargin = `-${headerOffset}px 0px 0px 0px`;
1712
+
1713
+ // Rows currently visible below the header. The topmost is the playhead.
1714
+ const visibleRows = new Set<HTMLElement>();
1700
1715
 
1701
1716
  const publishTopmost = () => {
1702
- if (inBand.size === 0) {
1703
- // Fall back to the row just above the band by scanning the
1704
- // few immediately-adjacent rows — cheap because the band is
1705
- // narrow.
1706
- setPlayheadMs(null);
1707
- return;
1708
- }
1709
- let topmost: HTMLElement | null = null;
1710
- let topmostOff = Infinity;
1711
- for (const el of inBand) {
1712
- const t = el.offsetTop;
1713
- if (t < topmostOff) {
1714
- topmostOff = t;
1715
- topmost = el;
1717
+ let value: number | null = null;
1718
+ if (visibleRows.size > 0) {
1719
+ let topmost: HTMLElement | null = null;
1720
+ let topmostOff = Infinity;
1721
+ for (const el of visibleRows) {
1722
+ const t = el.offsetTop;
1723
+ if (t < topmostOff) {
1724
+ topmostOff = t;
1725
+ topmost = el;
1726
+ }
1727
+ }
1728
+ if (topmost) {
1729
+ const tOff = Number(topmost.getAttribute("data-sl-toffset"));
1730
+ value = Number.isNaN(tOff) ? null : tOff;
1716
1731
  }
1717
1732
  }
1718
- if (!topmost) {
1719
- setPlayheadMs(null);
1720
- return;
1721
- }
1722
- const tOff = Number(topmost.getAttribute("data-sl-toffset"));
1723
- setPlayheadMs(Number.isNaN(tOff) ? null : tOff);
1733
+ setPlayheadMs(value);
1734
+ onPlayheadChangeRef.current?.(value);
1724
1735
  };
1725
1736
 
1726
1737
  const observer = new IntersectionObserver(
1727
1738
  (entries) => {
1728
1739
  for (const e of entries) {
1729
1740
  const el = e.target as HTMLElement;
1730
- if (e.isIntersecting) inBand.add(el);
1731
- else inBand.delete(el);
1741
+ if (e.isIntersecting) visibleRows.add(el);
1742
+ else visibleRows.delete(el);
1732
1743
  }
1733
1744
  publishTopmost();
1734
1745
  },
@@ -1743,7 +1754,39 @@ function Minimap({
1743
1754
  return () => observer.disconnect();
1744
1755
  }, [displayRows.length, headerOffset]);
1745
1756
 
1746
- const safeDur = Math.max(durationMs, 1);
1757
+ // Offset window for the rendered timeline. `winEnd` is the absolute offset
1758
+ // of the right edge (session/day end); `safeDur` is the window LENGTH used
1759
+ // as the proportional-width divisor. For a single-day session (dayWindow
1760
+ // null) these collapse to [0, durationMs] — identical to the old behavior.
1761
+ const winStart = dayWindow?.startMs ?? 0;
1762
+ const winEnd = dayWindow?.endMs ?? durationMs;
1763
+ const safeDur = Math.max(winEnd - winStart, 1);
1764
+
1765
+ // Subagent/workflow lanes are scoped to the same window as the main
1766
+ // timeline so a day view doesn't reserve empty lanes for runs that happened
1767
+ // on other days. Overlap test, not containment — a run straddling the
1768
+ // window edge still shows.
1769
+ const winSubagents = useMemo(
1770
+ () =>
1771
+ (subagents ?? []).filter(
1772
+ (s) =>
1773
+ s.startTOffsetMs !== undefined &&
1774
+ s.endTOffsetMs !== undefined &&
1775
+ s.endTOffsetMs >= winStart &&
1776
+ s.startTOffsetMs <= winEnd,
1777
+ ),
1778
+ [subagents, winStart, winEnd],
1779
+ );
1780
+ const winWorkflows = useMemo(
1781
+ () =>
1782
+ (workflows ?? []).filter(
1783
+ (w) =>
1784
+ w.startTOffsetMs !== undefined &&
1785
+ w.startTOffsetMs <= winEnd &&
1786
+ (w.endTOffsetMs ?? winEnd) >= winStart,
1787
+ ),
1788
+ [workflows, winStart, winEnd],
1789
+ );
1747
1790
 
1748
1791
  /* Build segments from the same display-row stream the transcript
1749
1792
  uses. Collapsed turns become one wide segment; expanded-turn
@@ -1769,6 +1812,7 @@ function Minimap({
1769
1812
  if (d.kind === "turn-expanded-footer") continue;
1770
1813
  if (d.kind === "turn-collapsed") {
1771
1814
  if (d.turn.tOffsetMs === undefined) continue;
1815
+ if (d.turn.tOffsetMs < winStart || d.turn.tOffsetMs > winEnd) continue;
1772
1816
  withTime.push({
1773
1817
  kind: "turn",
1774
1818
  turn: d.turn,
@@ -1778,6 +1822,7 @@ function Minimap({
1778
1822
  } else {
1779
1823
  // presentation row
1780
1824
  if (d.row.tOffsetMs === undefined) continue;
1825
+ if (d.row.tOffsetMs < winStart || d.row.tOffsetMs > winEnd) continue;
1781
1826
  withTime.push({
1782
1827
  kind: "row",
1783
1828
  row: d.row,
@@ -1791,7 +1836,9 @@ function Minimap({
1791
1836
  // the start of the next idle band (or the next row, whichever comes
1792
1837
  // first). This stops a row's rectangle from spanning across an idle
1793
1838
  // gap and visually swallowing it.
1794
- const idleBands = (rawIdleBands ?? []).slice().sort((a, b) => a.start - b.start);
1839
+ const idleBands = (rawIdleBands ?? [])
1840
+ .filter((b) => b.end > winStart && b.start < winEnd)
1841
+ .sort((a, b) => a.start - b.start);
1795
1842
  const nextIdleStartFrom = (t: number): number | undefined => {
1796
1843
  for (const b of idleBands) {
1797
1844
  if (b.start >= t) return b.start;
@@ -1805,12 +1852,15 @@ function Minimap({
1805
1852
  const start = item.start;
1806
1853
  const nextRowStart = next?.start;
1807
1854
  const nextIdleStart = nextIdleStartFrom(start + 1);
1808
- // Cap end to whichever comes first: next visible row, next idle band.
1855
+ // Cap end to whichever comes first: next visible row, next idle band,
1856
+ // or the window's right edge (so a day's last segment doesn't bleed
1857
+ // past the scoped window into the next day).
1809
1858
  const cap = (raw: number) =>
1810
1859
  Math.min(
1811
1860
  raw,
1812
1861
  nextRowStart ?? Number.POSITIVE_INFINITY,
1813
1862
  nextIdleStart ?? Number.POSITIVE_INFINITY,
1863
+ winEnd,
1814
1864
  );
1815
1865
 
1816
1866
  if (item.kind === "turn") {
@@ -1891,7 +1941,7 @@ function Minimap({
1891
1941
  }
1892
1942
  }
1893
1943
  return bucketed;
1894
- }, [displayRows, safeDur, rawIdleBands]);
1944
+ }, [displayRows, safeDur, rawIdleBands, winStart, winEnd]);
1895
1945
 
1896
1946
  /* Sequential layout with a minimum displayed width per segment.
1897
1947
  - Raw proportional width = (seg.end - seg.start) / safeDur * WIDTH
@@ -1944,10 +1994,10 @@ function Minimap({
1944
1994
  // sweep — O(N×L) where L is the number of lanes (small in practice).
1945
1995
  // Returns a Map agentId → laneIndex plus the total lane count.
1946
1996
  const { laneOf, laneCount } = useMemo(() => {
1947
- if (!subagents || subagents.length === 0) {
1997
+ if (winSubagents.length === 0) {
1948
1998
  return { laneOf: new Map<string, number>(), laneCount: 0 };
1949
1999
  }
1950
- const sorted = [...subagents].sort(
2000
+ const sorted = [...winSubagents].sort(
1951
2001
  (a, b) => (a.startTOffsetMs ?? 0) - (b.startTOffsetMs ?? 0),
1952
2002
  );
1953
2003
  const laneEnds: number[] = [];
@@ -1970,7 +2020,7 @@ function Minimap({
1970
2020
  laneOf.set(s.agentId, assigned);
1971
2021
  }
1972
2022
  return { laneOf, laneCount: laneEnds.length };
1973
- }, [subagents]);
2023
+ }, [winSubagents]);
1974
2024
 
1975
2025
  const SUB_BLOCK_TOP = MAIN_H + SUB_LANE_GAP;
1976
2026
  const SUB_LANES_H = laneCount > 0 ? laneCount * SUB_LANE_H : 0;
@@ -1985,7 +2035,7 @@ function Minimap({
1985
2035
  // matching the open-ended bar the render draws. Counting only placeable
1986
2036
  // runs keeps wfLaneCount in lockstep with what renders, so no empty
1987
2037
  // reserved band appears.
1988
- const placeable = (workflows ?? []).filter((w) => w.startTOffsetMs !== undefined);
2038
+ const placeable = winWorkflows;
1989
2039
  if (placeable.length === 0) {
1990
2040
  return { wfLaneOf: new Map<string, number>(), wfLaneCount: 0 };
1991
2041
  }
@@ -1996,7 +2046,7 @@ function Minimap({
1996
2046
  const wfLaneOf = new Map<string, number>();
1997
2047
  for (const w of sorted) {
1998
2048
  const start = w.startTOffsetMs ?? 0;
1999
- const end = w.endTOffsetMs ?? safeDur;
2049
+ const end = w.endTOffsetMs ?? winEnd;
2000
2050
  let assigned = -1;
2001
2051
  for (let i = 0; i < laneEnds.length; i++) {
2002
2052
  if (laneEnds[i]! <= start) {
@@ -2012,7 +2062,7 @@ function Minimap({
2012
2062
  wfLaneOf.set(w.runId, assigned);
2013
2063
  }
2014
2064
  return { wfLaneOf, wfLaneCount: laneEnds.length };
2015
- }, [workflows, safeDur]);
2065
+ }, [winWorkflows, winEnd]);
2016
2066
 
2017
2067
  const SUB_BLOCK_BOTTOM = MAIN_H + (laneCount > 0 ? SUB_LANE_GAP + SUB_LANES_H : 0);
2018
2068
  const WF_BLOCK_TOP = SUB_BLOCK_BOTTOM + (wfLaneCount > 0 ? SUB_LANE_GAP : 0);
@@ -2153,7 +2203,7 @@ function Minimap({
2153
2203
  glance. Bars use the same msToX mapping as the main timeline
2154
2204
  so the subagent's start/end aligns vertically with whatever
2155
2205
  was happening in the main session at that time. */}
2156
- {laneCount > 0 && subagents && (
2206
+ {laneCount > 0 && winSubagents.length > 0 && (
2157
2207
  <g>
2158
2208
  {/* Faint divider line above the subagent lane block */}
2159
2209
  <line
@@ -2165,7 +2215,7 @@ function Minimap({
2165
2215
  strokeWidth="0.6"
2166
2216
  strokeDasharray="2 4"
2167
2217
  />
2168
- {subagents.map((s) => {
2218
+ {winSubagents.map((s) => {
2169
2219
  const lane = laneOf.get(s.agentId) ?? 0;
2170
2220
  if (s.startTOffsetMs === undefined || s.endTOffsetMs === undefined) return null;
2171
2221
  const startX = msToX(s.startTOffsetMs);
@@ -2217,7 +2267,7 @@ function Minimap({
2217
2267
  time axis as the main timeline, colored by status (orange tier,
2218
2268
  distinct from the blue/purple subagents). The agent count is the
2219
2269
  headline — a single Workflow tool call hides a whole fleet. */}
2220
- {wfLaneCount > 0 && workflows && (
2270
+ {wfLaneCount > 0 && winWorkflows.length > 0 && (
2221
2271
  <g>
2222
2272
  <line
2223
2273
  x1={0}
@@ -2228,7 +2278,7 @@ function Minimap({
2228
2278
  strokeWidth="0.6"
2229
2279
  strokeDasharray="2 4"
2230
2280
  />
2231
- {workflows.map((w) => {
2281
+ {winWorkflows.map((w) => {
2232
2282
  if (w.startTOffsetMs === undefined) return null;
2233
2283
  const lane = wfLaneOf.get(w.runId) ?? 0;
2234
2284
  const startX = msToX(w.startTOffsetMs);
@@ -2236,7 +2286,7 @@ function Minimap({
2236
2286
  // the session end so it's visible (and dashed, to read as ongoing)
2237
2287
  // rather than vanishing while still occupying a reserved lane.
2238
2288
  const inProgress = w.endTOffsetMs === undefined;
2239
- const endX = msToX(w.endTOffsetMs ?? safeDur);
2289
+ const endX = msToX(w.endTOffsetMs ?? winEnd);
2240
2290
  const wWidth = Math.max(endX - startX, 8);
2241
2291
  const y = WF_BLOCK_TOP + lane * WF_LANE_H;
2242
2292
  const h = WF_LANE_H - 2;
@@ -2283,6 +2333,7 @@ function Minimap({
2283
2333
  {/* PR markers — diamond + vertical line at each `gh pr create` */}
2284
2334
  {prMarkers?.map((pr, i) => {
2285
2335
  if (pr.tOffsetMs === undefined) return null;
2336
+ if (pr.tOffsetMs < winStart || pr.tOffsetMs > winEnd) return null;
2286
2337
  const x = msToX(pr.tOffsetMs);
2287
2338
  return (
2288
2339
  <g
@@ -2320,6 +2371,7 @@ function Minimap({
2320
2371
  })}
2321
2372
 
2322
2373
  {coldResumeMarkers?.map((cr, i) => {
2374
+ if (cr.tOffsetMs < winStart || cr.tOffsetMs > winEnd) return null;
2323
2375
  const x = msToX(cr.tOffsetMs);
2324
2376
  return (
2325
2377
  <g
@@ -2336,12 +2388,17 @@ function Minimap({
2336
2388
  strokeWidth="1.5"
2337
2389
  strokeDasharray="4 3"
2338
2390
  opacity="0.75"
2391
+ pointerEvents="none"
2339
2392
  />
2393
+ {/* Hover hit-area limited to the diamond itself — a wider/taller
2394
+ target used to swallow hovers+clicks meant for adjacent
2395
+ user-prompt markers sitting right beside the cache-rebuild
2396
+ point. */}
2340
2397
  <rect
2341
- x={x - 8}
2342
- y={0}
2343
- width={16}
2344
- height={TOTAL_H}
2398
+ x={x - 6}
2399
+ y={MAIN_H / 2 - 8}
2400
+ width={12}
2401
+ height={16}
2345
2402
  fill="transparent"
2346
2403
  />
2347
2404
  <polygon
@@ -2349,6 +2406,7 @@ function Minimap({
2349
2406
  fill="#D97706"
2350
2407
  stroke="var(--af-surface)"
2351
2408
  strokeWidth="1.5"
2409
+ pointerEvents="none"
2352
2410
  />
2353
2411
  </g>
2354
2412
  );
@@ -2446,33 +2504,7 @@ function Minimap({
2446
2504
  );
2447
2505
  }
2448
2506
 
2449
- /** Color a sub-agent bar by its type. Background runs use a brighter
2450
- * variant of their color so true parallelism is visually distinct from
2451
- * blocking subagent calls (where the parent waits and there's no
2452
- * meaningful overlap with main session work). */
2453
- function subagentColor(agentType: string, background?: boolean): string {
2454
- const palette: Record<string, [string, string]> = {
2455
- "general-purpose": ["#5C84C3", "#7BA3DC"],
2456
- Explore: ["#A855F7", "#C57BFF"],
2457
- Plan: ["#F59E0B", "#FFBD3D"],
2458
- "code-reviewer": ["#34D399", "#5EE5B0"],
2459
- "playwright-qa-verifier": ["#22D3EE", "#67E8F9"],
2460
- "claude-code-guide": ["#EC4899", "#F472B6"],
2461
- };
2462
- const [base, bright] = palette[agentType] ?? ["#8A8580", "#A8A19A"];
2463
- return background ? bright : base;
2464
- }
2465
-
2466
- /** Workflow signature color, modulated by run status. Deliberately a warm
2467
- * orange — distinct from the blue/purple subagent palette so workflow lanes
2468
- * read as a different tier on the minimap. Failed runs go red, in-flight
2469
- * runs go green. */
2470
- function workflowColor(status: string): string {
2471
- const s = status.toLowerCase();
2472
- if (s === "failed" || s === "aborted" || s === "error") return "#EF4444";
2473
- if (s === "running" || s === "in_progress" || s === "active") return "#10B981";
2474
- return "#EA580C";
2475
- }
2507
+ // subagentColor, workflowColor moved to ./session-view/colors.ts
2476
2508
 
2477
2509
  function MinimapHoverCard({
2478
2510
  containerRef,
@@ -3070,6 +3102,10 @@ function TranscriptList({
3070
3102
  team,
3071
3103
  model,
3072
3104
  rawIdleBands,
3105
+ sessionDays,
3106
+ entryByDay,
3107
+ dayStripRefs,
3108
+ onJumpAcrossDay,
3073
3109
  }: {
3074
3110
  displayRows: DisplayRow[];
3075
3111
  rowRefs: React.MutableRefObject<Record<number, HTMLDivElement | null>>;
@@ -3084,6 +3120,20 @@ function TranscriptList({
3084
3120
  * row whose start matches a band's end, so Codex sessions get the same
3085
3121
  * "Session idle X minutes" markers Claude has always had. */
3086
3122
  rawIdleBands?: { start: number; end: number; durationMs: number }[];
3123
+ /** Day buckets (sorted by startMs) + per-day digest cards. The body drops a
3124
+ * card at each day's first row so per-day perception sits inline with that
3125
+ * day's work instead of stacked at the top. */
3126
+ sessionDays: { key: string; startMs: number; endMs: number; count: number }[];
3127
+ entryByDay: Map<string, Entry>;
3128
+ dayStripRefs: React.MutableRefObject<Record<string, HTMLDivElement | null>>;
3129
+ /** Hop to an adjacent day from an inline day-boundary button.
3130
+ * (selectKey, scrollKey, block, fallbackRowIndex). */
3131
+ onJumpAcrossDay: (
3132
+ selectKey: string,
3133
+ scrollKey: string,
3134
+ block: "start" | "end",
3135
+ fallbackIndex: number,
3136
+ ) => void;
3087
3137
  }) {
3088
3138
  // Find the last collapsed turn index so we can mark it as in-progress
3089
3139
  // when the session is live.
@@ -3116,8 +3166,26 @@ function TranscriptList({
3116
3166
  if (d.kind === "turn-expanded-footer") return undefined;
3117
3167
  return d.row.tOffsetMs;
3118
3168
  };
3169
+ // A display row → its event PRIMARY index (what jumpAcrossDay's turn/row
3170
+ // lookups are keyed by — NOT the displayRows loop position).
3171
+ const rowPrimaryIndexOf = (d: DisplayRow): number =>
3172
+ d.kind === "presentation" ? rowPrimaryIndex(d.row) : d.turn.firstPrimaryIndex;
3173
+ /** Which local day a row falls in: the last day bucket whose start is at or
3174
+ * before the row's offset. sessionDays is pre-sorted by startMs. */
3175
+ const dayKeyForOffset = (tOff: number | undefined): string | null => {
3176
+ if (tOff === undefined) return null;
3177
+ let key: string | null = null;
3178
+ for (const dd of sessionDays) {
3179
+ if (dd.startMs <= tOff) key = dd.key;
3180
+ else break;
3181
+ }
3182
+ return key;
3183
+ };
3119
3184
 
3120
3185
  const out: React.ReactNode[] = [];
3186
+ // Tracks the day of the last emitted row so a day's digest card is dropped
3187
+ // once, before that day's first non-indented row.
3188
+ let currentDayKey: string | null = null;
3121
3189
  // A user row and the turn-collapsed row that follows it share a
3122
3190
  // tOffsetMs (turn anchors at the user message — see buildMegaRows), so
3123
3191
  // both would match the same between-turn band without this guard. Track
@@ -3139,6 +3207,54 @@ function TranscriptList({
3139
3207
  emittedBandStarts.add(band.start);
3140
3208
  out.push(<IdleDivider key={`idle-before-${i}`} gapMs={band.durationMs} />);
3141
3209
  }
3210
+ // Day boundary: drop this day's digest card at its first row (after any
3211
+ // overnight idle divider that led into the day) so per-day perception
3212
+ // sits inline with that day's work, not stacked at the top. The boundary
3213
+ // also gets inline jump controls so you can page across days while
3214
+ // reading — "↓ start of next day" sits at the bottom of the day that just
3215
+ // ended, "↑ end of previous day" at the top of the new one.
3216
+ const dk = dayKeyForOffset(rowTOffset(d));
3217
+ if (dk && dk !== currentDayKey) {
3218
+ const endedKey = currentDayKey; // day that just ended; null on the first day
3219
+ currentDayKey = dk;
3220
+ // Fallback scroll target for days with no digest card: the day's first
3221
+ // row by PRIMARY index (i is the displayRows position — wrong key space).
3222
+ const firstIdx = rowPrimaryIndexOf(d);
3223
+ if (endedKey) {
3224
+ out.push(
3225
+ <DayJumpRow
3226
+ key={`daydown-${dk}`}
3227
+ dir="down"
3228
+ label={formatDayKey(dk)}
3229
+ onClick={() => onJumpAcrossDay(dk, dk, "start", firstIdx)}
3230
+ />,
3231
+ );
3232
+ }
3233
+ const dayEntry = entryByDay.get(dk);
3234
+ if (dayEntry) {
3235
+ out.push(
3236
+ <div
3237
+ key={`daystrip-${dk}`}
3238
+ ref={(el) => {
3239
+ dayStripRefs.current[dk] = el;
3240
+ }}
3241
+ style={{ marginBottom: 12, scrollMarginTop: stickyOffset }}
3242
+ >
3243
+ <EntryDayStrip entry={dayEntry} />
3244
+ </div>,
3245
+ );
3246
+ }
3247
+ if (endedKey) {
3248
+ out.push(
3249
+ <DayJumpRow
3250
+ key={`dayup-${dk}`}
3251
+ dir="up"
3252
+ label={formatDayKey(endedKey)}
3253
+ onClick={() => onJumpAcrossDay(endedKey, dk, "end", firstIdx)}
3254
+ />,
3255
+ );
3256
+ }
3257
+ }
3142
3258
  }
3143
3259
 
3144
3260
  // Collapsed turn summary row
@@ -3235,6 +3351,51 @@ function IdleDivider({ gapMs }: { gapMs: number }) {
3235
3351
  );
3236
3352
  }
3237
3353
 
3354
+ /** Inline cross-day jump control rendered at each day boundary. "down" sits at
3355
+ * the bottom of the day that just ended and jumps to the start of the next day;
3356
+ * "up" sits at the top of the new day and jumps to the end of the previous one.
3357
+ * The day always follows the scroll position (see followDayToScroll); these are
3358
+ * a one-click shortcut across the long idle gap between days so you don't have
3359
+ * to hand-scroll the whole span. "down" (start of next day) lands that day's
3360
+ * first row at the header line so the day-follow agrees; "up" (end of previous
3361
+ * day) scrolls the new day's strip to the viewport bottom, leaving the previous
3362
+ * day's tail above the header line. */
3363
+ function DayJumpRow({
3364
+ dir,
3365
+ label,
3366
+ onClick,
3367
+ }: {
3368
+ dir: "up" | "down";
3369
+ label: string;
3370
+ onClick: () => void;
3371
+ }) {
3372
+ return (
3373
+ <div style={{ display: "flex", justifyContent: "center", margin: "6px 0" }}>
3374
+ <button
3375
+ type="button"
3376
+ onClick={onClick}
3377
+ title={dir === "up" ? `Go to the end of ${label}` : `Go to the start of ${label}`}
3378
+ style={{
3379
+ display: "inline-flex",
3380
+ alignItems: "center",
3381
+ gap: 6,
3382
+ fontSize: 11,
3383
+ fontFamily: "var(--font-mono)",
3384
+ color: "var(--af-text-secondary)",
3385
+ background: "var(--af-surface)",
3386
+ border: "1px solid var(--af-border-subtle)",
3387
+ borderRadius: 100,
3388
+ padding: "3px 12px",
3389
+ cursor: "pointer",
3390
+ }}
3391
+ >
3392
+ {dir === "up" ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
3393
+ {dir === "up" ? `End of ${label}` : `Start of ${label}`}
3394
+ </button>
3395
+ </div>
3396
+ );
3397
+ }
3398
+
3238
3399
  function findColdResumeInDisplayRow(
3239
3400
  d: DisplayRow,
3240
3401
  ): SessionEvent["coldResume"] | null {
@@ -4328,170 +4489,13 @@ function ToolGroupLabel({
4328
4489
  );
4329
4490
  }
4330
4491
 
4331
- function TokenChip({ usage }: { usage: NonNullable<SessionEvent["usage"]> }) {
4332
- const [hover, setHover] = useState(false);
4333
- const totalIn = usage.input + usage.cacheRead + usage.cacheWrite;
4334
- const pctRead = totalIn > 0 ? Math.round((usage.cacheRead / totalIn) * 100) : 0;
4335
- return (
4336
- <span
4337
- style={{
4338
- position: "relative",
4339
- fontFamily: "var(--font-mono)",
4340
- fontSize: 11,
4341
- color: "var(--af-text-secondary)",
4342
- display: "inline-flex",
4343
- alignItems: "center",
4344
- gap: 4,
4345
- whiteSpace: "nowrap",
4346
- }}
4347
- onMouseEnter={() => setHover(true)}
4348
- onMouseLeave={() => setHover(false)}
4349
- >
4350
- <span style={{ opacity: 0.6 }}>▤</span>
4351
- {formatTokens(totalIn)} / {formatTokens(usage.output)}
4352
- {hover && (
4353
- <Tooltip style={{ right: 0, top: "calc(100% + 6px)", minWidth: 180 }}>
4354
- <TooltipRow label="Input" value={usage.input.toLocaleString()} />
4355
- <TooltipRow
4356
- label="Cache read"
4357
- value={`${usage.cacheRead.toLocaleString()} (${pctRead}%)`}
4358
- />
4359
- <TooltipRow label="Cache write" value={usage.cacheWrite.toLocaleString()} />
4360
- <TooltipRow label="Output" value={usage.output.toLocaleString()} />
4361
- </Tooltip>
4362
- )}
4363
- </span>
4364
- );
4365
- }
4366
-
4367
- /** Like TokenChip but for per-turn aggregates. Primary display is
4368
- * fresh input / output (not the cache-inflated sum), consistent with
4369
- * the session header. Tooltip shows the full breakdown plus a footnote
4370
- * reminding that cache reads are cumulative. */
4371
- function TurnTokenChip({
4372
- usage,
4373
- }: {
4374
- usage: { input: number; output: number; cacheRead: number; cacheWrite: number };
4375
- }) {
4376
- const [hover, setHover] = useState(false);
4377
- const totalIn = usage.input + usage.cacheRead + usage.cacheWrite;
4378
- const pctRead = totalIn > 0 ? Math.round((usage.cacheRead / totalIn) * 100) : 0;
4379
- return (
4380
- <span
4381
- style={{
4382
- position: "relative",
4383
- fontFamily: "var(--font-mono)",
4384
- fontSize: 11,
4385
- color: "var(--af-text-tertiary)",
4386
- display: "inline-flex",
4387
- alignItems: "center",
4388
- gap: 4,
4389
- whiteSpace: "nowrap",
4390
- cursor: "default",
4391
- }}
4392
- onMouseEnter={() => setHover(true)}
4393
- onMouseLeave={() => setHover(false)}
4394
- >
4395
- <span style={{ opacity: 0.6 }}>▤</span>
4396
- {formatTokens(usage.input)} / {formatTokens(usage.output)}
4397
- {hover && (
4398
- <Tooltip style={{ right: 0, top: "calc(100% + 6px)", minWidth: 220 }}>
4399
- <TooltipRow label="Input (fresh)" value={usage.input.toLocaleString()} />
4400
- <TooltipRow label="Output" value={usage.output.toLocaleString()} />
4401
- <TooltipRow
4402
- label="Cache read"
4403
- value={`${usage.cacheRead.toLocaleString()} (${pctRead}%)`}
4404
- />
4405
- <TooltipRow label="Cache write" value={usage.cacheWrite.toLocaleString()} />
4406
- <div
4407
- style={{
4408
- marginTop: 6,
4409
- paddingTop: 6,
4410
- borderTop: "1px solid rgba(241,245,249,0.12)",
4411
- opacity: 0.65,
4412
- fontSize: 10,
4413
- whiteSpace: "normal",
4414
- lineHeight: 1.4,
4415
- }}
4416
- >
4417
- Cumulative across all requests in this turn.
4418
- </div>
4419
- </Tooltip>
4420
- )}
4421
- </span>
4422
- );
4423
- }
4492
+ // TokenChip, TurnTokenChip moved to ./session-view/token-stats.tsx
4424
4493
 
4425
4494
  /* ------------------------------------------------------------------ */
4426
4495
  /* Debug list */
4427
4496
  /* ------------------------------------------------------------------ */
4428
4497
 
4429
- function DebugList({ events }: { events: SessionEvent[] }) {
4430
- // Rebuild a JSON view from the structured fields on the event. The
4431
- // original `raw` field is stripped server-side before serialization
4432
- // (see app/sessions/[id]/page.tsx) because including the full JSONL
4433
- // line per event doubles the RSC payload on large sessions. All the
4434
- // useful data is already on the structured event; this view surfaces
4435
- // it in the same shape you'd get from the raw JSONL.
4436
- const shapeForDebug = (e: SessionEvent) => ({
4437
- type: e.rawType,
4438
- index: e.index,
4439
- uuid: e.uuid,
4440
- parentUuid: e.parentUuid,
4441
- timestamp: e.timestamp,
4442
- role: e.role,
4443
- messageId: e.messageId,
4444
- stopReason: e.stopReason,
4445
- model: e.model,
4446
- requestId: e.requestId,
4447
- toolName: e.toolName,
4448
- toolUseId: e.toolUseId,
4449
- attachmentType: e.attachmentType,
4450
- usage: e.usage,
4451
- blocks: e.blocks,
4452
- });
4453
-
4454
- return (
4455
- <div style={{ padding: "8px 0" }}>
4456
- {events.map((e) => (
4457
- <details
4458
- key={e.index}
4459
- style={{
4460
- borderBottom: "1px solid var(--af-border-subtle)",
4461
- padding: "8px 12px",
4462
- }}
4463
- >
4464
- <summary
4465
- style={{
4466
- cursor: "pointer",
4467
- fontFamily: "var(--font-mono)",
4468
- fontSize: 12,
4469
- color: "var(--af-text-secondary)",
4470
- }}
4471
- >
4472
- #{e.index} · {e.rawType}
4473
- {e.attachmentType ? `/${e.attachmentType}` : ""} · {e.timestamp ?? "(no ts)"}
4474
- </summary>
4475
- <pre
4476
- style={{
4477
- marginTop: 8,
4478
- padding: 12,
4479
- background: "var(--background)",
4480
- border: "1px solid var(--af-border-subtle)",
4481
- borderRadius: 6,
4482
- fontSize: 11,
4483
- overflow: "auto",
4484
- maxHeight: 400,
4485
- color: "var(--af-text-secondary)",
4486
- }}
4487
- >
4488
- {JSON.stringify(shapeForDebug(e), null, 2)}
4489
- </pre>
4490
- </details>
4491
- ))}
4492
- </div>
4493
- );
4494
- }
4498
+ // DebugList moved to ./session-view/debug-list.tsx
4495
4499
 
4496
4500
  /* ------------------------------------------------------------------ */
4497
4501
  /* SubagentDrawer */
@@ -4504,412 +4508,98 @@ function DebugList({ events }: { events: SessionEvent[] }) {
4504
4508
  /* this subagent off. */
4505
4509
  /* ------------------------------------------------------------------ */
4506
4510
 
4507
- function SubagentDrawer({
4508
- subagent,
4509
- onClose,
4511
+ // SubagentDrawer moved to ./session-view/subagent-drawer.tsx
4512
+
4513
+ /* ------------------------------------------------------------------ */
4514
+ /* Workflows panel */
4515
+ /* */
4516
+ /* A single `Workflow` tool call collapses a whole dynamic-workflow */
4517
+ /* fan-out into one transcript row. This panel surfaces what that row */
4518
+ /* actually did: each run's spawned-agent count, tool calls, tokens, */
4519
+ /* phases, and progress log — the fleet work the transcript hides. */
4520
+ /* ------------------------------------------------------------------ */
4521
+
4522
+ function WorkflowsPanel({
4523
+ workflows,
4524
+ spawnedAgentCount,
4525
+ focusRunId,
4526
+ selectedAgentKey,
4527
+ onOpenAgent,
4510
4528
  onJumpToParent,
4529
+ onClearFocus,
4511
4530
  }: {
4512
- subagent: SubagentRun;
4513
- onClose: () => void;
4514
- onJumpToParent: () => void;
4531
+ workflows: WorkflowRun[];
4532
+ spawnedAgentCount: number;
4533
+ focusRunId?: string | null;
4534
+ selectedAgentKey?: string | null;
4535
+ onOpenAgent: (runId: string, agent: WorkflowRun["agents"][number]) => void;
4536
+ onJumpToParent: (toolUseId?: string) => void;
4537
+ onClearFocus?: () => void;
4515
4538
  }) {
4516
- const s = subagent;
4517
- const fill = subagentColor(s.agentType, s.runInBackground);
4518
- const startOff = formatOffset(s.startTOffsetMs);
4519
- const endOff = formatOffset(s.endTOffsetMs);
4520
- const dur = s.durationMs !== undefined ? formatGap(s.durationMs) : "—";
4521
- const totalIn = s.totalUsage.input + s.totalUsage.cacheRead + s.totalUsage.cacheWrite;
4522
- const pctRead = totalIn > 0 ? Math.round((s.totalUsage.cacheRead / totalIn) * 100) : 0;
4523
-
4539
+ const totalTokens = workflows.reduce((n, w) => n + w.totalTokens, 0);
4540
+ const totalTools = workflows.reduce((n, w) => n + w.toolCallCount, 0);
4524
4541
  return (
4525
- <div>
4526
- {/* Sticky title bar */}
4542
+ <section
4543
+ style={{
4544
+ marginBottom: 16,
4545
+ border: "1px solid var(--af-border-subtle)",
4546
+ borderRadius: 10,
4547
+ background: "var(--af-surface)",
4548
+ overflow: "hidden",
4549
+ }}
4550
+ >
4527
4551
  <div
4528
4552
  style={{
4529
- padding: "14px 20px",
4530
- borderBottom: "1px solid var(--af-border-subtle)",
4531
4553
  display: "flex",
4532
4554
  alignItems: "center",
4533
4555
  gap: 10,
4534
- background: "var(--af-surface)",
4535
- position: "sticky",
4536
- top: 0,
4537
- zIndex: 1,
4538
4556
  flexWrap: "wrap",
4557
+ padding: "10px 14px",
4558
+ borderBottom: "1px solid var(--af-border-subtle)",
4559
+ background: "var(--af-surface-subtle, rgba(234,88,12,0.04))",
4539
4560
  }}
4540
4561
  >
4541
4562
  <span
4542
4563
  style={{
4543
- fontSize: 11,
4544
- fontWeight: 600,
4545
- padding: "3px 10px",
4546
- borderRadius: 4,
4547
- background: fill,
4548
- color: "#fff",
4549
- }}
4550
- >
4551
- {s.agentType}
4552
- </span>
4553
- {s.runInBackground && (
4554
- <span
4555
- style={{
4556
- fontSize: 9,
4557
- fontWeight: 600,
4558
- padding: "2px 8px",
4559
- borderRadius: 4,
4560
- background: "var(--af-warning-subtle)",
4561
- color: "var(--af-warning)",
4562
- textTransform: "uppercase",
4563
- letterSpacing: "0.04em",
4564
- }}
4565
- >
4566
- background
4567
- </span>
4568
- )}
4569
- <span
4570
- style={{
4571
- fontSize: 14,
4564
+ display: "inline-flex",
4565
+ alignItems: "center",
4566
+ gap: 7,
4567
+ fontSize: 13,
4572
4568
  fontWeight: 600,
4573
4569
  color: "var(--af-text)",
4574
- flex: 1,
4575
- minWidth: 0,
4576
- overflow: "hidden",
4577
- textOverflow: "ellipsis",
4578
- whiteSpace: "nowrap",
4579
4570
  }}
4580
- title={s.description}
4581
4571
  >
4582
- {s.description}
4572
+ <WorkflowIcon size={14} style={{ color: "#EA580C" }} />
4573
+ Workflows
4583
4574
  </span>
4584
- <button
4585
- onClick={onClose}
4575
+ <span
4586
4576
  style={{
4587
- background: "transparent",
4588
- border: "none",
4589
- cursor: "pointer",
4577
+ fontSize: 11.5,
4590
4578
  color: "var(--af-text-tertiary)",
4591
- padding: 4,
4592
- borderRadius: 4,
4579
+ fontFamily: "var(--font-mono)",
4593
4580
  }}
4594
- aria-label="Close"
4595
4581
  >
4596
- <X size={16} />
4597
- </button>
4598
- </div>
4599
-
4600
- {/* Meta strip */}
4601
- <div
4602
- style={{
4603
- padding: "10px 20px",
4604
- fontFamily: "var(--font-mono)",
4605
- fontSize: 11,
4606
- color: "var(--af-text-tertiary)",
4607
- borderBottom: "1px solid var(--af-border-subtle)",
4608
- display: "grid",
4609
- gridTemplateColumns: "auto 1fr",
4610
- columnGap: 10,
4611
- rowGap: 3,
4612
- }}
4613
- >
4614
- <span style={{ opacity: 0.7 }}>range</span>
4615
- <span style={{ color: "var(--af-text-secondary)" }}>
4616
- {startOff} → {endOff}
4582
+ {workflows.length} run{workflows.length === 1 ? "" : "s"} ·{" "}
4583
+ {spawnedAgentCount.toLocaleString()} agents · {totalTools.toLocaleString()} tool calls ·{" "}
4584
+ {formatTokens(totalTokens)} tok orchestrated
4617
4585
  </span>
4618
- <span style={{ opacity: 0.7 }}>duration</span>
4619
- <span style={{ color: "var(--af-text-secondary)" }}>{dur}</span>
4620
- {s.model && (
4621
- <>
4622
- <span style={{ opacity: 0.7 }}>model</span>
4623
- <span style={{ color: "var(--af-text-secondary)" }}>{s.model}</span>
4624
- </>
4625
- )}
4626
- {s.parentToolUseId && (
4627
- <>
4628
- <span style={{ opacity: 0.7 }}>parent</span>
4629
- <span style={{ color: "var(--af-text-secondary)" }}>
4630
- {s.parentToolUseId.slice(0, 24)}…
4631
- </span>
4632
- </>
4633
- )}
4634
4586
  </div>
4635
-
4636
- {/* Activity stats */}
4637
- <div
4638
- style={{
4639
- padding: "14px 20px",
4640
- borderBottom: "1px solid var(--af-border-subtle)",
4641
- display: "grid",
4642
- gridTemplateColumns: "1fr 1fr 1fr",
4643
- gap: 10,
4644
- }}
4645
- >
4646
- <StatCell label="Events" value={String(s.eventCount)} />
4647
- <StatCell label="Messages" value={String(s.assistantMessageCount ?? 0)} />
4648
- <StatCell label="Tool calls" value={String(s.toolCallCount ?? 0)} />
4587
+ <div style={{ display: "flex", flexDirection: "column" }}>
4588
+ {workflows.map((w) => (
4589
+ <WorkflowCard
4590
+ key={w.runId}
4591
+ w={w}
4592
+ focused={focusRunId === w.runId}
4593
+ selectedAgentKey={selectedAgentKey}
4594
+ onOpenAgent={onOpenAgent}
4595
+ onJumpToParent={onJumpToParent}
4596
+ onClearFocus={onClearFocus}
4597
+ />
4598
+ ))}
4649
4599
  </div>
4650
-
4651
- {/* Token breakdown */}
4652
- <div
4653
- style={{
4654
- padding: "12px 20px",
4655
- borderBottom: "1px solid var(--af-border-subtle)",
4656
- fontSize: 11,
4657
- fontFamily: "var(--font-mono)",
4658
- color: "var(--af-text-secondary)",
4659
- display: "flex",
4660
- flexDirection: "column",
4661
- gap: 3,
4662
- }}
4663
- >
4664
- <TokenLine label="Input (fresh)" value={s.totalUsage.input} />
4665
- <TokenLine label="Output" value={s.totalUsage.output} />
4666
- <TokenLine
4667
- label="Cache read"
4668
- value={s.totalUsage.cacheRead}
4669
- suffix={` (${pctRead}%)`}
4670
- />
4671
- <TokenLine label="Cache write" value={s.totalUsage.cacheWrite} />
4672
- </div>
4673
-
4674
- {/* Tool breakdown */}
4675
- {s.toolCalls && s.toolCalls.length > 0 && (
4676
- <div
4677
- style={{
4678
- padding: "14px 20px",
4679
- borderBottom: "1px solid var(--af-border-subtle)",
4680
- }}
4681
- >
4682
- <div
4683
- style={{
4684
- fontSize: 10,
4685
- fontWeight: 600,
4686
- textTransform: "uppercase",
4687
- letterSpacing: "0.04em",
4688
- color: "var(--af-text-tertiary)",
4689
- marginBottom: 8,
4690
- }}
4691
- >
4692
- Tools used
4693
- </div>
4694
- <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
4695
- {s.toolCalls.map((t) => (
4696
- <span
4697
- key={t.name}
4698
- style={{
4699
- display: "inline-flex",
4700
- alignItems: "center",
4701
- gap: 4,
4702
- fontSize: 11,
4703
- padding: "2px 8px",
4704
- borderRadius: 4,
4705
- background: "var(--af-border-subtle)",
4706
- color: "var(--af-text)",
4707
- fontFamily: "var(--font-mono)",
4708
- }}
4709
- >
4710
- <b style={{ fontWeight: 600 }}>{shortenToolName(t.name)}</b>
4711
- <span style={{ color: "var(--af-text-tertiary)" }}>×{t.count}</span>
4712
- </span>
4713
- ))}
4714
- </div>
4715
- </div>
4716
- )}
4717
-
4718
- {/* Parent prompt (what the parent asked the subagent to do) */}
4719
- {s.prompt && (
4720
- <div
4721
- style={{
4722
- padding: "14px 20px",
4723
- borderBottom: "1px solid var(--af-border-subtle)",
4724
- }}
4725
- >
4726
- <div
4727
- style={{
4728
- display: "flex",
4729
- alignItems: "center",
4730
- justifyContent: "space-between",
4731
- marginBottom: 8,
4732
- }}
4733
- >
4734
- <div
4735
- style={{
4736
- fontSize: 10,
4737
- fontWeight: 600,
4738
- textTransform: "uppercase",
4739
- letterSpacing: "0.04em",
4740
- color: "var(--af-text-tertiary)",
4741
- }}
4742
- >
4743
- Prompt
4744
- </div>
4745
- {s.parentToolUseId && (
4746
- <button
4747
- type="button"
4748
- onClick={onJumpToParent}
4749
- style={{
4750
- fontSize: 10,
4751
- color: "var(--af-accent)",
4752
- background: "transparent",
4753
- border: "none",
4754
- cursor: "pointer",
4755
- padding: 0,
4756
- }}
4757
- >
4758
- Jump to parent →
4759
- </button>
4760
- )}
4761
- </div>
4762
- <div className="sl-prose" style={{ fontSize: 12.5 }}>
4763
- <ReactMarkdown
4764
- remarkPlugins={[remarkGfm]}
4765
- components={{
4766
- a: (props) => (
4767
- <a
4768
- {...props}
4769
- target="_blank"
4770
- rel="noopener noreferrer"
4771
- style={{
4772
- color: "var(--af-accent)",
4773
- textDecoration: "underline",
4774
- }}
4775
- />
4776
- ),
4777
- }}
4778
- >
4779
- {s.prompt}
4780
- </ReactMarkdown>
4781
- </div>
4782
- </div>
4783
- )}
4784
-
4785
- {/* Final text */}
4786
- {s.finalText && (
4787
- <div style={{ padding: "14px 20px" }}>
4788
- <div
4789
- style={{
4790
- fontSize: 10,
4791
- fontWeight: 600,
4792
- textTransform: "uppercase",
4793
- letterSpacing: "0.04em",
4794
- color: "var(--af-text-tertiary)",
4795
- marginBottom: 8,
4796
- }}
4797
- >
4798
- Final result
4799
- </div>
4800
- <div className="sl-prose" style={{ fontSize: 13 }}>
4801
- <ReactMarkdown
4802
- remarkPlugins={[remarkGfm]}
4803
- components={{
4804
- a: (props) => (
4805
- <a
4806
- {...props}
4807
- target="_blank"
4808
- rel="noopener noreferrer"
4809
- style={{
4810
- color: "var(--af-accent)",
4811
- textDecoration: "underline",
4812
- }}
4813
- />
4814
- ),
4815
- }}
4816
- >
4817
- {s.finalText}
4818
- </ReactMarkdown>
4819
- </div>
4820
- </div>
4821
- )}
4822
- </div>
4823
- );
4824
- }
4825
-
4826
- /* ------------------------------------------------------------------ */
4827
- /* Workflows panel */
4828
- /* */
4829
- /* A single `Workflow` tool call collapses a whole dynamic-workflow */
4830
- /* fan-out into one transcript row. This panel surfaces what that row */
4831
- /* actually did: each run's spawned-agent count, tool calls, tokens, */
4832
- /* phases, and progress log — the fleet work the transcript hides. */
4833
- /* ------------------------------------------------------------------ */
4834
-
4835
- function WorkflowsPanel({
4836
- workflows,
4837
- spawnedAgentCount,
4838
- focusRunId,
4839
- selectedAgentKey,
4840
- onOpenAgent,
4841
- onJumpToParent,
4842
- }: {
4843
- workflows: WorkflowRun[];
4844
- spawnedAgentCount: number;
4845
- focusRunId?: string | null;
4846
- selectedAgentKey?: string | null;
4847
- onOpenAgent: (runId: string, agent: WorkflowRun["agents"][number]) => void;
4848
- onJumpToParent: (toolUseId?: string) => void;
4849
- }) {
4850
- const totalTokens = workflows.reduce((n, w) => n + w.totalTokens, 0);
4851
- const totalTools = workflows.reduce((n, w) => n + w.toolCallCount, 0);
4852
- return (
4853
- <section
4854
- style={{
4855
- marginBottom: 16,
4856
- border: "1px solid var(--af-border-subtle)",
4857
- borderRadius: 10,
4858
- background: "var(--af-surface)",
4859
- overflow: "hidden",
4860
- }}
4861
- >
4862
- <div
4863
- style={{
4864
- display: "flex",
4865
- alignItems: "center",
4866
- gap: 10,
4867
- flexWrap: "wrap",
4868
- padding: "10px 14px",
4869
- borderBottom: "1px solid var(--af-border-subtle)",
4870
- background: "var(--af-surface-subtle, rgba(234,88,12,0.04))",
4871
- }}
4872
- >
4873
- <span
4874
- style={{
4875
- display: "inline-flex",
4876
- alignItems: "center",
4877
- gap: 7,
4878
- fontSize: 13,
4879
- fontWeight: 600,
4880
- color: "var(--af-text)",
4881
- }}
4882
- >
4883
- <WorkflowIcon size={14} style={{ color: "#EA580C" }} />
4884
- Workflows
4885
- </span>
4886
- <span
4887
- style={{
4888
- fontSize: 11.5,
4889
- color: "var(--af-text-tertiary)",
4890
- fontFamily: "var(--font-mono)",
4891
- }}
4892
- >
4893
- {workflows.length} run{workflows.length === 1 ? "" : "s"} ·{" "}
4894
- {spawnedAgentCount.toLocaleString()} agents · {totalTools.toLocaleString()} tool calls ·{" "}
4895
- {formatTokens(totalTokens)} tok orchestrated
4896
- </span>
4897
- </div>
4898
- <div style={{ display: "flex", flexDirection: "column" }}>
4899
- {workflows.map((w) => (
4900
- <WorkflowCard
4901
- key={w.runId}
4902
- w={w}
4903
- focused={focusRunId === w.runId}
4904
- selectedAgentKey={selectedAgentKey}
4905
- onOpenAgent={onOpenAgent}
4906
- onJumpToParent={onJumpToParent}
4907
- />
4908
- ))}
4909
- </div>
4910
- </section>
4911
- );
4912
- }
4600
+ </section>
4601
+ );
4602
+ }
4913
4603
 
4914
4604
  function WorkflowCard({
4915
4605
  w,
@@ -4917,12 +4607,14 @@ function WorkflowCard({
4917
4607
  selectedAgentKey,
4918
4608
  onOpenAgent,
4919
4609
  onJumpToParent,
4610
+ onClearFocus,
4920
4611
  }: {
4921
4612
  w: WorkflowRun;
4922
4613
  focused?: boolean;
4923
4614
  selectedAgentKey?: string | null;
4924
4615
  onOpenAgent: (runId: string, agent: WorkflowRun["agents"][number]) => void;
4925
4616
  onJumpToParent: (toolUseId?: string) => void;
4617
+ onClearFocus?: () => void;
4926
4618
  }) {
4927
4619
  const [open, setOpen] = useState(focused);
4928
4620
  const cardRef = useRef<HTMLDivElement>(null);
@@ -4942,14 +4634,17 @@ function WorkflowCard({
4942
4634
  ref={cardRef}
4943
4635
  style={{
4944
4636
  borderTop: "1px solid var(--af-border-subtle)",
4945
- background: focused ? "rgba(234,88,12,0.07)" : "transparent",
4946
- transition: "background 0.5s ease",
4947
4637
  scrollMarginTop: 120,
4948
4638
  }}
4949
4639
  >
4950
4640
  {/* Collapsed header — click to expand */}
4951
4641
  <button
4952
- onClick={() => setOpen((v) => !v)}
4642
+ onClick={() => {
4643
+ setOpen((v) => !v);
4644
+ // Any toggle is the user taking over from the timeline jump — drop the
4645
+ // focus highlight so it doesn't linger on a card they've moved past.
4646
+ onClearFocus?.();
4647
+ }}
4953
4648
  style={{
4954
4649
  width: "100%",
4955
4650
  display: "flex",
@@ -4972,7 +4667,11 @@ function WorkflowCard({
4972
4667
  fontFamily: "var(--font-mono)",
4973
4668
  fontSize: 13,
4974
4669
  fontWeight: 600,
4975
- color: "var(--af-text)",
4670
+ color: focused ? "#EA580C" : "var(--af-text)",
4671
+ background: focused ? "rgba(234,88,12,0.16)" : "transparent",
4672
+ padding: "2px 8px",
4673
+ borderRadius: 6,
4674
+ transition: "background 0.4s ease, color 0.4s ease",
4976
4675
  minWidth: 0,
4977
4676
  overflow: "hidden",
4978
4677
  textOverflow: "ellipsis",
@@ -5054,7 +4753,7 @@ function WorkflowCard({
5054
4753
  fontFamily: "var(--font-mono)",
5055
4754
  }}
5056
4755
  >
5057
- Jump to Workflow call →
4756
+ Jump to timeline →
5058
4757
  </button>
5059
4758
  )}
5060
4759
  </div>
@@ -5705,593 +5404,18 @@ function WorkflowRunLog({ logs }: { logs: string[] }) {
5705
5404
  );
5706
5405
  }
5707
5406
 
5708
- function WfMiniStat({ icon, value }: { icon?: React.ReactNode; value: string }) {
5709
- return (
5710
- <span
5711
- style={{
5712
- display: "inline-flex",
5713
- alignItems: "center",
5714
- gap: 4,
5715
- fontSize: 11.5,
5716
- color: "var(--af-text-secondary)",
5717
- fontFamily: "var(--font-mono)",
5718
- whiteSpace: "nowrap",
5719
- }}
5720
- >
5721
- {icon && <span style={{ color: "var(--af-text-tertiary)", display: "inline-flex" }}>{icon}</span>}
5722
- {value}
5723
- </span>
5724
- );
5725
- }
5726
-
5727
- function SectionLabel({ children }: { children: React.ReactNode }) {
5728
- return (
5729
- <div
5730
- style={{
5731
- fontSize: 10,
5732
- fontWeight: 600,
5733
- textTransform: "uppercase",
5734
- letterSpacing: "0.04em",
5735
- color: "var(--af-text-tertiary)",
5736
- marginBottom: 8,
5737
- }}
5738
- >
5739
- {children}
5740
- </div>
5741
- );
5742
- }
5743
-
5744
- function StatCell({ label, value }: { label: string; value: string }) {
5745
- return (
5746
- <div>
5747
- <div
5748
- style={{
5749
- fontSize: 9,
5750
- color: "var(--af-text-tertiary)",
5751
- textTransform: "uppercase",
5752
- letterSpacing: "0.04em",
5753
- fontWeight: 600,
5754
- }}
5755
- >
5756
- {label}
5757
- </div>
5758
- <div
5759
- style={{
5760
- fontSize: 18,
5761
- fontWeight: 700,
5762
- fontFamily: "var(--font-mono)",
5763
- color: "var(--af-text)",
5764
- marginTop: 2,
5765
- }}
5766
- >
5767
- {value}
5768
- </div>
5769
- </div>
5770
- );
5771
- }
5772
-
5773
- function TokenLine({
5774
- label,
5775
- value,
5776
- suffix,
5777
- }: {
5778
- label: string;
5779
- value: number;
5780
- suffix?: string;
5781
- }) {
5782
- return (
5783
- <div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
5784
- <span style={{ opacity: 0.7 }}>{label}</span>
5785
- <span>
5786
- {value.toLocaleString()}
5787
- {suffix && <span style={{ opacity: 0.65 }}>{suffix}</span>}
5788
- </span>
5789
- </div>
5790
- );
5791
- }
5407
+ // WfMiniStat, SectionLabel, StatCell, TokenLine moved to ./session-view/workflows-shared.tsx
5792
5408
 
5793
5409
  /* ------------------------------------------------------------------ */
5794
5410
  /* Drawer */
5795
5411
  /* ------------------------------------------------------------------ */
5796
5412
 
5797
- function Drawer({
5798
- event,
5799
- row,
5800
- onClose,
5801
- }: {
5802
- event: SessionEvent;
5803
- row: PresentationRow | null;
5804
- onClose: () => void;
5805
- }) {
5806
- const [showDev, setShowDev] = useState(false);
5807
- const kind: PresentationRowKind = row?.kind ?? "agent";
5808
- const theme = ROLE_THEMES[kind];
5809
- const title = drawerTitle(row, event);
5810
- const hasUsage = !!event.usage && (event.usage.input > 0 || event.usage.output > 0);
5811
-
5812
- return (
5813
- <div>
5814
- {/* Sticky title bar */}
5815
- <div
5816
- style={{
5817
- padding: "14px 20px",
5818
- borderBottom: "1px solid var(--af-border-subtle)",
5819
- display: "flex",
5820
- alignItems: "center",
5821
- gap: 10,
5822
- background: "var(--af-surface)",
5823
- position: "sticky",
5824
- top: 0,
5825
- zIndex: 1,
5826
- }}
5827
- >
5828
- <span
5829
- style={{
5830
- fontSize: 11,
5831
- fontWeight: 600,
5832
- padding: "3px 10px",
5833
- borderRadius: 4,
5834
- background: theme.bg,
5835
- color: theme.fg,
5836
- }}
5837
- >
5838
- {theme.label}
5839
- </span>
5840
- <span
5841
- style={{
5842
- fontSize: 14,
5843
- fontWeight: 600,
5844
- color: "var(--af-text)",
5845
- }}
5846
- >
5847
- {title}
5848
- </span>
5849
- <button
5850
- onClick={onClose}
5851
- style={{
5852
- marginLeft: "auto",
5853
- background: "transparent",
5854
- border: "none",
5855
- cursor: "pointer",
5856
- color: "var(--af-text-tertiary)",
5857
- padding: 4,
5858
- borderRadius: 4,
5859
- }}
5860
- >
5861
- <X size={16} />
5862
- </button>
5863
- </div>
5864
-
5865
- {/* Meta line — compact */}
5866
- <div
5867
- style={{
5868
- padding: "8px 20px",
5869
- fontFamily: "var(--font-mono)",
5870
- fontSize: 11,
5871
- color: "var(--af-text-tertiary)",
5872
- display: "flex",
5873
- gap: 10,
5874
- alignItems: "center",
5875
- flexWrap: "wrap",
5876
- borderBottom: "1px solid var(--af-border-subtle)",
5877
- }}
5878
- >
5879
- <span>{formatOffset(event.tOffsetMs)}</span>
5880
- {event.gapMs !== undefined && event.gapMs > 0 && <span>· {formatGap(event.gapMs)}</span>}
5881
- {hasUsage && event.usage && (
5882
- <span style={{ color: "var(--af-text-secondary)" }}>
5883
- · {formatTokens(event.usage.input + event.usage.cacheRead + event.usage.cacheWrite)}/
5884
- {formatTokens(event.usage.output)} tokens
5885
- </span>
5886
- )}
5887
- <button
5888
- onClick={() => setShowDev((s) => !s)}
5889
- style={{
5890
- marginLeft: "auto",
5891
- background: "transparent",
5892
- border: "none",
5893
- fontSize: 10,
5894
- color: "var(--af-text-tertiary)",
5895
- cursor: "pointer",
5896
- padding: 0,
5897
- fontFamily: "inherit",
5898
- textDecoration: "underline",
5899
- textUnderlineOffset: 2,
5900
- }}
5901
- >
5902
- {showDev ? "hide details" : "details"}
5903
- </button>
5904
- </div>
5905
-
5906
- {/* Developer panel — collapsed by default */}
5907
- {showDev && (
5908
- <div
5909
- style={{
5910
- padding: "10px 20px 14px",
5911
- borderBottom: "1px solid var(--af-border-subtle)",
5912
- fontSize: 11,
5913
- color: "var(--af-text-secondary)",
5914
- fontFamily: "var(--font-mono)",
5915
- display: "flex",
5916
- flexDirection: "column",
5917
- gap: 4,
5918
- }}
5919
- >
5920
- {event.model && <div>model: {event.model}</div>}
5921
- {event.requestId && <div>request: {event.requestId}</div>}
5922
- {event.messageId && <div>message: {event.messageId}</div>}
5923
- {event.stopReason && <div>stop_reason: {event.stopReason}</div>}
5924
- {event.usage && (
5925
- <>
5926
- <div style={{ marginTop: 6, opacity: 0.7 }}>tokens</div>
5927
- <div> input: {event.usage.input.toLocaleString()}</div>
5928
- <div> output: {event.usage.output.toLocaleString()}</div>
5929
- <div> cache read: {event.usage.cacheRead.toLocaleString()}</div>
5930
- <div>
5931
- {" "}cache write: {event.usage.cacheWrite.toLocaleString()}
5932
- </div>
5933
- </>
5934
- )}
5935
- </div>
5936
- )}
5937
-
5938
- {/* Content */}
5939
- <div style={{ padding: "18px 22px" }}>
5940
- <DrawerContent event={event} row={row} />
5941
- </div>
5942
- </div>
5943
- );
5944
- }
5945
-
5946
- function drawerTitle(row: PresentationRow | null, event: SessionEvent): string {
5947
- if (row) {
5948
- switch (row.kind) {
5949
- case "user":
5950
- return "Message";
5951
- case "agent":
5952
- return "Message";
5953
- case "tool-group":
5954
- return `Tool use · ${formatToolSummary(row.toolNames)}`;
5955
- case "interrupt":
5956
- return "Interrupted";
5957
- case "model":
5958
- return "Model (zero-usage)";
5959
- case "error":
5960
- return "API error";
5961
- case "task-notification":
5962
- return `Background task · ${row.status}`;
5963
- }
5964
- }
5965
- return event.rawType;
5966
- }
5967
-
5968
- function DrawerContent({ event, row }: { event: SessionEvent; row: PresentationRow | null }) {
5969
- // User rows can override their blocks (e.g. slash commands get a cleaned
5970
- // "/implement AGE-8" block instead of the raw XML).
5971
- if (row?.kind === "user" && row.displayBlocks) {
5972
- return (
5973
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
5974
- {row.displayBlocks.map((b, i) => (
5975
- <BlockView key={i} block={b} />
5976
- ))}
5977
- </div>
5978
- );
5979
- }
5980
-
5981
- // Task notifications: parsed fields in a clean key-value layout,
5982
- // not the raw <task-notification> XML blob.
5983
- if (row?.kind === "task-notification") {
5984
- const statusColor =
5985
- row.status === "success"
5986
- ? "var(--af-success)"
5987
- : row.status === "failed"
5988
- ? "var(--af-danger)"
5989
- : "var(--af-text-secondary)";
5990
- return (
5991
- <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
5992
- <div
5993
- style={{
5994
- display: "flex",
5995
- alignItems: "center",
5996
- gap: 8,
5997
- }}
5998
- >
5999
- <span
6000
- style={{
6001
- fontSize: 11,
6002
- fontWeight: 600,
6003
- padding: "3px 10px",
6004
- borderRadius: 4,
6005
- background:
6006
- row.status === "success"
6007
- ? "var(--af-success-subtle)"
6008
- : row.status === "failed"
6009
- ? "var(--af-danger-subtle)"
6010
- : "var(--af-border-subtle)",
6011
- color: statusColor,
6012
- textTransform: "uppercase",
6013
- letterSpacing: "0.03em",
6014
- }}
6015
- >
6016
- {row.status}
6017
- </span>
6018
- </div>
6019
- <div style={{ fontSize: 13, lineHeight: 1.55, color: "var(--af-text)" }}>{row.summary}</div>
6020
- {(row.taskId || row.toolUseId || row.outputFile) && (
6021
- <div
6022
- style={{
6023
- fontSize: 11,
6024
- color: "var(--af-text-secondary)",
6025
- fontFamily: "var(--font-mono)",
6026
- display: "flex",
6027
- flexDirection: "column",
6028
- gap: 4,
6029
- paddingTop: 10,
6030
- borderTop: "1px solid var(--af-border-subtle)",
6031
- }}
6032
- >
6033
- {row.taskId && <div>task id: {row.taskId}</div>}
6034
- {row.toolUseId && <div>tool use: {row.toolUseId.slice(0, 24)}…</div>}
6035
- {row.outputFile && (
6036
- <div style={{ wordBreak: "break-all" }}>output: {row.outputFile}</div>
6037
- )}
6038
- </div>
6039
- )}
6040
- </div>
6041
- );
6042
- }
6043
-
6044
- // Tool group: list all individual tool calls with their inputs
6045
- if (row?.kind === "tool-group" && row.count > 1) {
6046
- return (
6047
- <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
6048
- {row.events.map((e, i) => (
6049
- <div key={e.index}>
6050
- <div
6051
- style={{
6052
- fontSize: 11,
6053
- color: "var(--af-text-tertiary)",
6054
- marginBottom: 4,
6055
- fontFamily: "var(--font-mono)",
6056
- }}
6057
- >
6058
- #{i + 1} · {formatOffset(e.tOffsetMs)} · {e.toolUseId?.slice(0, 14)}…
6059
- </div>
6060
- {e.blocks.map((b, bi) => (
6061
- <BlockView key={bi} block={b} />
6062
- ))}
6063
- </div>
6064
- ))}
6065
- </div>
6066
- );
6067
- }
6068
-
6069
- // Agent row: include thinking blocks from the same message.id if present
6070
- if (row?.kind === "agent" && row.groupedEvents.length > 1) {
6071
- return (
6072
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
6073
- {row.groupedEvents.flatMap((e, i) =>
6074
- e.blocks.map((b, bi) => <BlockView key={`${i}-${bi}`} block={b} />),
6075
- )}
6076
- </div>
6077
- );
6078
- }
6079
-
6080
- return (
6081
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
6082
- {event.blocks.map((b, i) => (
6083
- <BlockView key={i} block={b} />
6084
- ))}
6085
- </div>
6086
- );
6087
- }
6088
- function BlockView({ block }: { block: ContentBlock }) {
6089
- if (block.type === "text") {
6090
- return (
6091
- <div className="sl-prose">
6092
- <ReactMarkdown
6093
- remarkPlugins={[remarkGfm]}
6094
- components={{
6095
- a: (props) => (
6096
- <a
6097
- {...props}
6098
- target="_blank"
6099
- rel="noopener noreferrer"
6100
- style={{
6101
- color: "var(--af-accent)",
6102
- textDecoration: "underline",
6103
- textUnderlineOffset: 2,
6104
- }}
6105
- />
6106
- ),
6107
- }}
6108
- >
6109
- {block.text}
6110
- </ReactMarkdown>
6111
- </div>
6112
- );
6113
- }
6114
- if (block.type === "thinking") {
6115
- return (
6116
- <details>
6117
- <summary
6118
- style={{
6119
- cursor: "pointer",
6120
- fontSize: 11,
6121
- color: "var(--af-text-tertiary)",
6122
- marginBottom: 6,
6123
- }}
6124
- >
6125
- Thinking · {block.thinking.length} chars
6126
- </summary>
6127
- <div
6128
- style={{
6129
- fontSize: 12,
6130
- lineHeight: 1.6,
6131
- color: "var(--af-text-secondary)",
6132
- whiteSpace: "pre-wrap",
6133
- fontStyle: "italic",
6134
- borderLeft: "3px solid var(--af-border-subtle)",
6135
- paddingLeft: 12,
6136
- marginTop: 6,
6137
- }}
6138
- >
6139
- {block.thinking}
6140
- </div>
6141
- </details>
6142
- );
6143
- }
6144
- if (block.type === "tool_use") {
6145
- return <ToolUseCard name={block.name} input={block.input} />;
6146
- }
6147
- if (block.type === "tool_result") {
6148
- const text =
6149
- typeof block.content === "string" ? block.content : JSON.stringify(block.content, null, 2);
6150
- return (
6151
- <pre
6152
- style={{
6153
- fontSize: 12,
6154
- padding: 12,
6155
- background: "var(--background)",
6156
- border: "1px solid var(--af-border-subtle)",
6157
- borderRadius: 6,
6158
- overflow: "auto",
6159
- whiteSpace: "pre-wrap",
6160
- color: "var(--af-text)",
6161
- fontFamily: "var(--font-mono)",
6162
- maxHeight: 500,
6163
- }}
6164
- >
6165
- {text}
6166
- </pre>
6167
- );
6168
- }
6169
- return null;
6170
- }
5413
+ // DrawerContent, BlockView moved to ./session-view/drawer.tsx
6171
5414
 
6172
5415
  /* ------------------------------------------------------------------ */
6173
5416
  /* Helpers */
6174
5417
  /* ------------------------------------------------------------------ */
6175
5418
 
6176
- function formatDurationHeader(ms: number): string {
6177
- if (ms < 1000) return `${ms}ms`;
6178
- const s = Math.round(ms / 1000);
6179
- if (s < 60) return `${s}s`;
6180
- const m = Math.floor(s / 60);
6181
- if (m < 60) return `${m}m ${s % 60}s`;
6182
- // Roll up to hours — agent time can reach many hours once workflow
6183
- // execution is folded in, and "1116m" reads poorly.
6184
- const h = Math.floor(m / 60);
6185
- return `${h}h ${m % 60}m`;
6186
- }
6187
-
6188
- function EntryDayStrip({ entry }: { entry: Entry }) {
6189
- const fmtDate = new Date(`${entry.local_day}T12:00:00`).toLocaleDateString("en-US", {
6190
- weekday: "short", month: "short", day: "numeric",
6191
- });
6192
- const durMin = Math.round(entry.numbers.active_min);
6193
- const enr = entry.enrichment;
6194
- const isPending = enr.status === "pending" || enr.status === "error";
6195
- const isTrivial = enr.status === "skipped_trivial";
6196
-
6197
- return (
6198
- <div
6199
- style={{
6200
- border: "1px solid var(--af-border-subtle)",
6201
- borderRadius: 10,
6202
- padding: "12px 16px",
6203
- background: "var(--af-surface)",
6204
- fontSize: 13,
6205
- lineHeight: 1.5,
6206
- }}
6207
- >
6208
- <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
6209
- <span
6210
- style={{
6211
- fontSize: 12,
6212
- color: "var(--af-text-tertiary)",
6213
- display: "inline-flex",
6214
- alignItems: "center",
6215
- gap: 4,
6216
- }}
6217
- >
6218
- 📅 {fmtDate}
6219
- </span>
6220
- {isTrivial ? (
6221
- <OutcomePill outcome="trivial" size="md" agent={entry.agent} />
6222
- ) : isPending ? (
6223
- <OutcomePill
6224
- outcome={null}
6225
- pending
6226
- sessionId={entry.session_id}
6227
- localDay={entry.local_day}
6228
- size="md"
6229
- agent={entry.agent}
6230
- />
6231
- ) : enr.outcome ? (
6232
- <OutcomePill outcome={enr.outcome} size="md" agent={entry.agent} />
6233
- ) : null}
6234
- <span
6235
- style={{
6236
- fontFamily: "var(--font-mono)",
6237
- fontSize: 11,
6238
- color: "var(--af-text-tertiary)",
6239
- marginLeft: "auto",
6240
- }}
6241
- >
6242
- {durMin}m active
6243
- </span>
6244
- </div>
6245
-
6246
- {isTrivial && (
6247
- <div style={{ fontSize: 12, color: "var(--af-text-tertiary)", fontStyle: "italic", marginTop: 8 }}>
6248
- Warmup — no enrichment generated.
6249
- </div>
6250
- )}
5419
+ // formatDurationHeader moved to ./session-view/helpers.ts
6251
5420
 
6252
- {isPending && (
6253
- <div style={{ fontSize: 12, color: "var(--af-text-secondary)", marginTop: 8 }}>
6254
- Click the pending pill to generate this day's digest →
6255
- </div>
6256
- )}
6257
-
6258
- {enr.status === "done" && (
6259
- <>
6260
- {enr.brief_summary && (
6261
- <p style={{ margin: "10px 0 0", color: "var(--af-text)" }}>{enr.brief_summary}</p>
6262
- )}
6263
- {(enr.friction_detail || enr.user_instructions.length > 0) && (
6264
- <div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
6265
- {enr.friction_detail && (
6266
- <div style={{ fontSize: 12, color: "var(--af-text-secondary)" }}>
6267
- <span style={{ color: "#ed8936" }}>⚠ </span>
6268
- {enr.friction_detail}
6269
- </div>
6270
- )}
6271
- {enr.user_instructions.length > 0 && (
6272
- <details style={{ fontSize: 12, color: "var(--af-text-secondary)" }}>
6273
- <summary style={{ cursor: "pointer", color: "var(--af-text-tertiary)" }}>
6274
- Top user instructions ({enr.user_instructions.length})
6275
- </summary>
6276
- <ul style={{ margin: "6px 0 0", paddingLeft: 18 }}>
6277
- {enr.user_instructions.slice(0, 5).map((u, i) => (
6278
- <li key={i} style={{ marginBottom: 2 }}>{u}</li>
6279
- ))}
6280
- </ul>
6281
- </details>
6282
- )}
6283
- </div>
6284
- )}
6285
- <div style={{ marginTop: 10, fontSize: 11 }}>
6286
- <Link
6287
- href={`/digest/${entry.local_day}`}
6288
- style={{ color: "var(--af-accent)", textDecoration: "none" }}
6289
- >
6290
- Open day digest →
6291
- </Link>
6292
- </div>
6293
- </>
6294
- )}
6295
- </div>
6296
- );
6297
- }
5421
+ // EntryDayStrip moved to ./session-view/entry-day-strip.tsx