fleetlens 0.13.0 → 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 (205) hide show
  1. package/app/apps/web/.next/BUILD_ID +1 -1
  2. package/app/apps/web/.next/build-manifest.json +8 -8
  3. package/app/apps/web/.next/prerender-manifest.json +3 -3
  4. package/app/apps/web/.next/server/app/_global-error/page/build-manifest.json +5 -5
  5. package/app/apps/web/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/app/apps/web/.next/server/app/_global-error.html +1 -1
  7. package/app/apps/web/.next/server/app/_global-error.rsc +7 -7
  8. package/app/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  9. package/app/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  10. package/app/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  11. package/app/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  12. package/app/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/app/apps/web/.next/server/app/_not-found/page/build-manifest.json +5 -5
  14. package/app/apps/web/.next/server/app/_not-found/page.js +1 -1
  15. package/app/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  16. package/app/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/app/apps/web/.next/server/app/api/ask/route.js.nft.json +1 -1
  18. package/app/apps/web/.next/server/app/api/digest/day/[date]/route.js +1 -1
  19. package/app/apps/web/.next/server/app/api/digest/day/[date]/route.js.nft.json +1 -1
  20. package/app/apps/web/.next/server/app/api/digest/month/[yearMonth]/route.js +1 -1
  21. package/app/apps/web/.next/server/app/api/digest/month/[yearMonth]/route.js.nft.json +1 -1
  22. package/app/apps/web/.next/server/app/api/digest/month-index/route.js.nft.json +1 -1
  23. package/app/apps/web/.next/server/app/api/digest/week/[startDate]/route.js +1 -1
  24. package/app/apps/web/.next/server/app/api/digest/week/[startDate]/route.js.nft.json +1 -1
  25. package/app/apps/web/.next/server/app/api/digest/week-index/route.js.nft.json +1 -1
  26. package/app/apps/web/.next/server/app/api/events/route.js.nft.json +1 -1
  27. package/app/apps/web/.next/server/app/api/jobs/route.js.nft.json +1 -1
  28. package/app/apps/web/.next/server/app/api/pdf/[key]/route.js.nft.json +1 -1
  29. package/app/apps/web/.next/server/app/api/runs/[runId]/route.js.nft.json +1 -1
  30. package/app/apps/web/.next/server/app/api/runs/[runId]/stream/route.js.nft.json +1 -1
  31. package/app/apps/web/.next/server/app/api/runs/route.js.nft.json +1 -1
  32. package/app/apps/web/.next/server/app/api/settings/route.js.nft.json +1 -1
  33. package/app/apps/web/.next/server/app/api/team-config/route.js.nft.json +1 -1
  34. package/app/apps/web/.next/server/app/api/workflow-agent/route.js.nft.json +1 -1
  35. package/app/apps/web/.next/server/app/changelog/page/build-manifest.json +5 -5
  36. package/app/apps/web/.next/server/app/changelog/page.js +1 -1
  37. package/app/apps/web/.next/server/app/changelog/page.js.nft.json +1 -1
  38. package/app/apps/web/.next/server/app/changelog/page_client-reference-manifest.js +1 -1
  39. package/app/apps/web/.next/server/app/day/[date]/page/build-manifest.json +5 -5
  40. package/app/apps/web/.next/server/app/day/[date]/page.js +1 -1
  41. package/app/apps/web/.next/server/app/day/[date]/page.js.nft.json +1 -1
  42. package/app/apps/web/.next/server/app/day/[date]/page_client-reference-manifest.js +1 -1
  43. package/app/apps/web/.next/server/app/day/page/build-manifest.json +5 -5
  44. package/app/apps/web/.next/server/app/day/page.js +1 -1
  45. package/app/apps/web/.next/server/app/day/page.js.nft.json +1 -1
  46. package/app/apps/web/.next/server/app/day/page_client-reference-manifest.js +1 -1
  47. package/app/apps/web/.next/server/app/digest/[date]/page/build-manifest.json +5 -5
  48. package/app/apps/web/.next/server/app/digest/[date]/page.js +1 -1
  49. package/app/apps/web/.next/server/app/digest/[date]/page.js.nft.json +1 -1
  50. package/app/apps/web/.next/server/app/digest/[date]/page_client-reference-manifest.js +1 -1
  51. package/app/apps/web/.next/server/app/digest/page/build-manifest.json +5 -5
  52. package/app/apps/web/.next/server/app/digest/page.js +1 -1
  53. package/app/apps/web/.next/server/app/digest/page.js.nft.json +1 -1
  54. package/app/apps/web/.next/server/app/digest/page_client-reference-manifest.js +1 -1
  55. package/app/apps/web/.next/server/app/insights/[key]/page/build-manifest.json +5 -5
  56. package/app/apps/web/.next/server/app/insights/[key]/page.js +1 -1
  57. package/app/apps/web/.next/server/app/insights/[key]/page.js.nft.json +1 -1
  58. package/app/apps/web/.next/server/app/insights/[key]/page_client-reference-manifest.js +1 -1
  59. package/app/apps/web/.next/server/app/insights/page/build-manifest.json +5 -5
  60. package/app/apps/web/.next/server/app/insights/page.js +3 -2
  61. package/app/apps/web/.next/server/app/insights/page.js.nft.json +1 -1
  62. package/app/apps/web/.next/server/app/insights/page_client-reference-manifest.js +1 -1
  63. package/app/apps/web/.next/server/app/page/build-manifest.json +5 -5
  64. package/app/apps/web/.next/server/app/page.js +1 -1
  65. package/app/apps/web/.next/server/app/page.js.nft.json +1 -1
  66. package/app/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  67. package/app/apps/web/.next/server/app/parallelism/page/build-manifest.json +5 -5
  68. package/app/apps/web/.next/server/app/parallelism/page.js +1 -1
  69. package/app/apps/web/.next/server/app/parallelism/page.js.nft.json +1 -1
  70. package/app/apps/web/.next/server/app/parallelism/page_client-reference-manifest.js +1 -1
  71. package/app/apps/web/.next/server/app/print/[key]/page/build-manifest.json +5 -5
  72. package/app/apps/web/.next/server/app/print/[key]/page.js +1 -1
  73. package/app/apps/web/.next/server/app/print/[key]/page.js.nft.json +1 -1
  74. package/app/apps/web/.next/server/app/print/[key]/page_client-reference-manifest.js +1 -1
  75. package/app/apps/web/.next/server/app/projects/[slug]/page/build-manifest.json +5 -5
  76. package/app/apps/web/.next/server/app/projects/[slug]/page.js +1 -1
  77. package/app/apps/web/.next/server/app/projects/[slug]/page.js.nft.json +1 -1
  78. package/app/apps/web/.next/server/app/projects/[slug]/page_client-reference-manifest.js +1 -1
  79. package/app/apps/web/.next/server/app/projects/page/build-manifest.json +5 -5
  80. package/app/apps/web/.next/server/app/projects/page.js +1 -1
  81. package/app/apps/web/.next/server/app/projects/page.js.nft.json +1 -1
  82. package/app/apps/web/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  83. package/app/apps/web/.next/server/app/runs/page/build-manifest.json +5 -5
  84. package/app/apps/web/.next/server/app/runs/page.js +1 -1
  85. package/app/apps/web/.next/server/app/runs/page.js.nft.json +1 -1
  86. package/app/apps/web/.next/server/app/runs/page_client-reference-manifest.js +1 -1
  87. package/app/apps/web/.next/server/app/sessions/[id]/page/build-manifest.json +5 -5
  88. package/app/apps/web/.next/server/app/sessions/[id]/page.js +1 -1
  89. package/app/apps/web/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  90. package/app/apps/web/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
  91. package/app/apps/web/.next/server/app/sessions/page/build-manifest.json +5 -5
  92. package/app/apps/web/.next/server/app/sessions/page.js +1 -1
  93. package/app/apps/web/.next/server/app/sessions/page.js.nft.json +1 -1
  94. package/app/apps/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  95. package/app/apps/web/.next/server/app/settings/page/build-manifest.json +5 -5
  96. package/app/apps/web/.next/server/app/settings/page.js +1 -1
  97. package/app/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  98. package/app/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  99. package/app/apps/web/.next/server/app/team/page/build-manifest.json +5 -5
  100. package/app/apps/web/.next/server/app/team/page.js +1 -1
  101. package/app/apps/web/.next/server/app/team/page.js.nft.json +1 -1
  102. package/app/apps/web/.next/server/app/team/page_client-reference-manifest.js +1 -1
  103. package/app/apps/web/.next/server/app/usage/page/build-manifest.json +5 -5
  104. package/app/apps/web/.next/server/app/usage/page.js +1 -1
  105. package/app/apps/web/.next/server/app/usage/page.js.nft.json +1 -1
  106. package/app/apps/web/.next/server/app/usage/page_client-reference-manifest.js +1 -1
  107. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0gril0u._.js +9 -0
  108. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0n79hpm._.js +9 -0
  109. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0~utv~v._.js +9 -0
  110. package/app/apps/web/.next/server/chunks/packages_parser_dist_06d9se1._.js +1 -1
  111. package/app/apps/web/.next/server/chunks/packages_parser_dist_10ihc3~._.js +1 -1
  112. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0exckd4._.js +1 -1
  113. package/app/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__0-1_njm._.js → [root-of-the-server]__0k1x-c9._.js} +2 -2
  114. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0lhwp-k._.js +8 -0
  115. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0~.f_3z._.js +3 -3
  116. package/app/apps/web/.next/server/chunks/ssr/_0kajk31._.js +1 -1
  117. package/app/apps/web/.next/server/chunks/ssr/_0s3n5mz._.js +8 -0
  118. package/app/apps/web/.next/server/chunks/ssr/_0xu2pvc._.js +1 -1
  119. package/app/apps/web/.next/server/chunks/ssr/{_0jw2sm7._.js → _12mbgs5._.js} +2 -2
  120. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_day_[date]_day-view_tsx_0wjoe1u._.js +1 -1
  121. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_insights_page_tsx_071kwyw._.js +3 -0
  122. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_sessions_sessions-grid_tsx_0bri5x.._.js +3 -0
  123. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_0l6hsch._.js +6 -0
  124. package/app/apps/web/.next/server/chunks/ssr/apps_web_lib_changelog_ts_0n7_p1d._.js +1 -1
  125. package/app/apps/web/.next/server/chunks/ssr/apps_web_lib_changelog_ts_0nb5_-s._.js +1 -1
  126. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0cepfi8._.js +3 -0
  127. package/app/apps/web/.next/server/chunks/ssr/packages_entries_dist_node_0_271yr.js +1 -1
  128. package/app/apps/web/.next/server/chunks/ssr/packages_parser_dist_0mx_509._.js +1 -1
  129. package/app/apps/web/.next/server/middleware-build-manifest.js +8 -8
  130. package/app/apps/web/.next/server/middleware-manifest.json +5 -5
  131. package/app/apps/web/.next/server/pages/500.html +1 -1
  132. package/app/apps/web/.next/server/server-reference-manifest.js +1 -1
  133. package/app/apps/web/.next/server/server-reference-manifest.json +1 -1
  134. package/app/apps/web/.next/static/chunks/{0pzt9vemtzct8.js → 02bv_ejzhhzvp.js} +3 -3
  135. package/app/apps/web/.next/static/chunks/{07lbm-sdh9c2j.js → 0389wb_f0j78j.js} +1 -1
  136. package/app/apps/web/.next/static/chunks/03gy1evbn94qu.js +6 -0
  137. package/app/apps/web/.next/static/chunks/03rd.3cc_t~yf.js +4 -0
  138. package/app/apps/web/.next/static/chunks/{0l4e3as18eexy.js → 0g223u9amib0q.js} +1 -1
  139. package/app/apps/web/.next/static/chunks/{0h3.-_s-k0mj3.js → 0i.hc~2bj0_3d.js} +1 -1
  140. package/app/apps/web/.next/static/chunks/0m7dd-2zd9vl2.js +4 -0
  141. package/app/apps/web/.next/static/chunks/0nqklmbs5s3ax.js +6 -0
  142. package/app/apps/web/.next/static/chunks/{073081qv4uyn8.js → 0p--hzrway8~3.js} +1 -1
  143. package/app/apps/web/.next/static/chunks/0q8dg4txlzno0.js +1 -0
  144. package/app/apps/web/.next/static/chunks/{0gblp_v.kiu-..js → 0qv-gw888_5nu.js} +1 -1
  145. package/app/apps/web/.next/static/chunks/{14bjditmmrn7l.js → 0tirv_vo6q8uc.js} +1 -1
  146. package/app/apps/web/.next/static/chunks/{0qmh5da6gfm56.js → 0vz4~a~~hq399.js} +1 -1
  147. package/app/apps/web/.next/static/chunks/{0n0n.0279ot6c.js → 14vx702c32kn6.js} +1 -1
  148. package/app/apps/web/.next/static/chunks/{turbopack-0lb9w090w5ezx.js → turbopack-0mxu3~9vk.brh.js} +1 -1
  149. package/app/apps/web/app/api/digest/_shared/digest-route-helpers.ts +178 -0
  150. package/app/apps/web/app/api/digest/day/[date]/route.ts +34 -128
  151. package/app/apps/web/app/api/digest/month/[yearMonth]/route.ts +36 -121
  152. package/app/apps/web/app/api/digest/week/[startDate]/route.ts +36 -123
  153. package/app/apps/web/app/insights/page.tsx +20 -5
  154. package/app/apps/web/app/parallelism/burst-detail-modal.tsx +637 -0
  155. package/app/apps/web/app/parallelism/concurrency-info-modal.tsx +228 -0
  156. package/app/apps/web/app/parallelism/gantt-chart-utils.ts +49 -0
  157. package/app/apps/web/app/parallelism/gantt-chart.tsx +16 -902
  158. package/app/apps/web/app/sessions/[id]/session-view/colors.ts +31 -0
  159. package/app/apps/web/app/sessions/[id]/session-view/debug-list.tsx +78 -0
  160. package/app/apps/web/app/sessions/[id]/session-view/drawer.tsx +414 -0
  161. package/app/apps/web/app/sessions/[id]/session-view/entry-day-strip.tsx +124 -0
  162. package/app/apps/web/app/sessions/[id]/session-view/header-stats.tsx +217 -0
  163. package/app/apps/web/app/sessions/[id]/session-view/helpers.ts +135 -0
  164. package/app/apps/web/app/sessions/[id]/session-view/subagent-drawer.tsx +339 -0
  165. package/app/apps/web/app/sessions/[id]/session-view/token-stats.tsx +111 -0
  166. package/app/apps/web/app/sessions/[id]/session-view/tooltip.tsx +63 -0
  167. package/app/apps/web/app/sessions/[id]/session-view/types.ts +46 -0
  168. package/app/apps/web/app/sessions/[id]/session-view/workflows-shared.tsx +104 -0
  169. package/app/apps/web/app/sessions/[id]/session-view.tsx +448 -1639
  170. package/app/apps/web/app/sessions/[id]/team-tab/minimap-hover-card.tsx +201 -0
  171. package/app/apps/web/app/sessions/[id]/team-tab/minimap-idle-band.tsx +53 -0
  172. package/app/apps/web/app/sessions/[id]/team-tab/minimap-shared.ts +20 -0
  173. package/app/apps/web/app/sessions/[id]/team-tab/team-minimap.tsx +10 -265
  174. package/app/apps/web/app/sessions/sessions-grid.tsx +10 -10
  175. package/app/apps/web/components/digest-actions.tsx +103 -0
  176. package/app/apps/web/components/job-queue-widget.tsx +1 -1
  177. package/app/apps/web/components/month-digest-view.tsx +19 -64
  178. package/app/apps/web/components/searchable-select.tsx +215 -0
  179. package/app/apps/web/components/tail-mode.tsx +39 -8
  180. package/app/apps/web/components/week-digest-view.tsx +16 -71
  181. package/app/apps/web/lib/changelog-data.generated.ts +1 -1
  182. package/app/apps/web/lib/entries-index.ts +7 -8
  183. package/app/apps/web/lib/entries.ts +0 -9
  184. package/app/apps/web/lib/format.test.ts +221 -0
  185. package/app/apps/web/lib/utilization-tone.test.ts +56 -0
  186. package/app/apps/web/package.json +1 -1
  187. package/app/apps/web/tsconfig.tsbuildinfo +1 -1
  188. package/dist/daemon-worker.js +35 -43
  189. package/dist/index.js +37 -49
  190. package/package.json +1 -1
  191. package/app/apps/web/.next/server/chunks/[root-of-the-server]__04j3tng._.js +0 -9
  192. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0cnh3k4._.js +0 -9
  193. package/app/apps/web/.next/server/chunks/[root-of-the-server]__132eosx._.js +0 -9
  194. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__136-0me._.js +0 -8
  195. package/app/apps/web/.next/server/chunks/ssr/_0w.cv64._.js +0 -8
  196. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_01d-iwx._.js +0 -6
  197. package/app/apps/web/.next/server/chunks/ssr/node_modules_lucide-react_dist_esm_icons_0-lmm4p._.js +0 -3
  198. package/app/apps/web/.next/static/chunks/05k84gif3ucxm.js +0 -6
  199. package/app/apps/web/.next/static/chunks/0nnjldasalwdu.js +0 -4
  200. package/app/apps/web/.next/static/chunks/0okg13phslr1..js +0 -4
  201. package/app/apps/web/.next/static/chunks/0rnay-a.-jr8-.js +0 -1
  202. package/app/apps/web/.next/static/chunks/0snv3-2ux2xvv.js +0 -6
  203. /package/app/apps/web/.next/static/{rbp9x3iPbzrz-wxS-DAU5 → VEfemGVAQ0xCHjj15orZU}/_buildManifest.js +0 -0
  204. /package/app/apps/web/.next/static/{rbp9x3iPbzrz-wxS-DAU5 → VEfemGVAQ0xCHjj15orZU}/_clientMiddlewareManifest.js +0 -0
  205. /package/app/apps/web/.next/static/{rbp9x3iPbzrz-wxS-DAU5 → VEfemGVAQ0xCHjj15orZU}/_ssgManifest.js +0 -0
@@ -20,7 +20,6 @@ import {
20
20
  import ReactMarkdown from "react-markdown";
21
21
  import remarkGfm from "remark-gfm";
22
22
  import type {
23
- ContentBlock,
24
23
  SessionDetail,
25
24
  SessionEvent,
26
25
  SubagentRun,
@@ -33,13 +32,44 @@ import {
33
32
  buildPresentation,
34
33
  buildMegaRows,
35
34
  detectPrMarkers,
36
- rowPrimaryIndex as rowPrimaryIndexFromLib,
37
35
  type MegaRow,
38
36
  type PresentationRow,
39
37
  type PresentationRowKind,
40
38
  type TurnMegaRow,
41
39
  type TurnSummary,
42
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";
43
73
  import { estimateCost, formatCost, formatGap, formatOffset, formatRelative, formatTokens, shortId } from "@/lib/format";
44
74
  import { LiveBadge } from "@/components/live-badge";
45
75
  import { AskButton, AskDrawer } from "@/components/ask";
@@ -90,16 +120,6 @@ const FILTER_MODES: { value: FilterMode; label: string }[] = [
90
120
  { value: "error", label: "Errors only" },
91
121
  ];
92
122
 
93
- /** Render a `YYYY-MM-DD` day key as a short, locale-free label (e.g.
94
- * "Jun 16"). Formatting straight from the key — not a Date — keeps SSR and
95
- * client output identical regardless of timezone. */
96
- const DAY_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
97
- function formatDayKey(key: string): string {
98
- const [, m, d] = key.split("-").map(Number);
99
- if (!m || !d) return key;
100
- return `${DAY_MONTHS[m - 1]} ${d}`;
101
- }
102
-
103
123
  /* ------------------------------------------------------------------ */
104
124
  /* Main component */
105
125
  /* ------------------------------------------------------------------ */
@@ -182,9 +202,14 @@ export function SessionView({
182
202
  const [scrollIntent, setScrollIntent] = useState(0);
183
203
  /** Scroll-only target (no selection) — used by day navigation so jumping to
184
204
  * a day scrolls the transcript there WITHOUT popping the event drawer the
185
- * way a row/minimap selection does. `n` forces the effect to re-run even
186
- * when the same index is targeted twice. */
187
- const [dayScrollTarget, setDayScrollTarget] = useState<{ index: number; key: string; n: number } | null>(null);
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);
188
213
  const rowRefs = useRef<Record<number, HTMLDivElement | null>>({});
189
214
  // Per-day digest cards (EntryDayStrip) are interleaved into the transcript at
190
215
  // each day's first row, so day-nav scrolls to the card itself when present.
@@ -211,6 +236,12 @@ export function SessionView({
211
236
  * at mount + on resize via ResizeObserver — no magic constants. */
212
237
  const headerRef = useRef<HTMLDivElement>(null);
213
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;
214
245
  useEffect(() => {
215
246
  const el = headerRef.current;
216
247
  if (!el) return;
@@ -231,6 +262,10 @@ export function SessionView({
231
262
  // When the user explicitly clicks the toggle, suppress auto-collapse
232
263
  // for a short period so the scroll listener doesn't immediately undo it.
233
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);
234
269
  const toggleCollapsed = () => {
235
270
  setCollapsed((v) => !v);
236
271
  manualPinRef.current = Date.now();
@@ -312,11 +347,45 @@ export function SessionView({
312
347
  // selectedIndex (so no drawer opens). Same scrollIntoView contract as above.
313
348
  useEffect(() => {
314
349
  if (!dayScrollTarget) return;
350
+ const main = headerRef.current?.closest("main") as HTMLElement | null;
315
351
  // Prefer the day's digest card (it leads the day); fall back to the first
316
352
  // row when that day has no entry.
317
353
  const el =
318
354
  dayStripRefs.current[dayScrollTarget.key] ?? rowRefs.current[dayScrollTarget.index];
319
- if (el) el.scrollIntoView({ block: "start", behavior: "auto" });
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);
320
389
  }, [dayScrollTarget]);
321
390
 
322
391
  const { events, durationMs, totalUsage, model, eventCount, projectName } = session;
@@ -561,65 +630,88 @@ export function SessionView({
561
630
  return m;
562
631
  }, [entries]);
563
632
 
564
- // Selected timeline day. Defaults to the FIRST active day so the minimap
565
- // matches where the transcript opens (the top), and the scroll-spy then
566
- // advances it as the user reads downward. The lazy initializer runs
567
- // identically on server + client (sessionDays is deterministic), so there's
568
- // no hydration mismatch; the effect re-pins it when the component is reused
569
- // across /sessions/[id] navigations. "all" shows the full session.
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);
570
642
  const [selectedDayKey, setSelectedDayKey] = useState<string>(
571
- () => (sessionDays.length ? sessionDays[0]!.key : "all"),
643
+ () => (sessionDays.length ? sessionDays[sessionDays.length - 1]!.key : "all"),
572
644
  );
573
645
  useEffect(() => {
574
- setSelectedDayKey(sessionDays.length ? sessionDays[0]!.key : "all");
646
+ setSelectedDayKey(sessionDays.length ? sessionDays[sessionDays.length - 1]!.key : "all");
647
+ didInitialDayJump.current = false;
575
648
  // eslint-disable-next-line react-hooks/exhaustive-deps
576
649
  }, [session.sessionId]);
577
650
 
578
651
  const dayIndex = sessionDays.findIndex((d) => d.key === selectedDayKey);
579
- // Latest selection, read by the scroll-spy without re-subscribing its
580
- // listener on every day change. Also lets the spy leave an explicit "all"
581
- // (full-session) view untouched while the user scrolls.
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.
582
658
  const selectedDayKeyRef = useRef(selectedDayKey);
583
659
  selectedDayKeyRef.current = selectedDayKey;
584
660
 
585
- // Scroll-spy: as the transcript scrolls across a day boundary, auto-advance
586
- // the timeline's selected day to whichever day sits under the sticky header.
587
- // Anchors on the interleaved day cards. It only mirrors the reading position
588
- // (no scroll, no header pop), so the minimap + "day N/N" selector follow the
589
- // user across days in both directions. Skipped while "all" is chosen.
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.
590
676
  useEffect(() => {
591
- if (tab !== "transcript" || sessionDays.length <= 1) return;
592
- const headerEl = headerRef.current;
593
- const mainEl = headerEl?.closest("main") as HTMLElement | null;
594
- if (!headerEl || !mainEl) return;
595
- let raf = 0;
596
- const update = () => {
597
- raf = 0;
598
- if (selectedDayKeyRef.current === "all") return;
599
- const line = headerEl.getBoundingClientRect().bottom + 8;
600
- let currentKey: string | null = null;
601
- for (const d of sessionDays) {
602
- const stripEl = dayStripRefs.current[d.key];
603
- if (!stripEl) continue;
604
- if (stripEl.getBoundingClientRect().top <= line) currentKey = d.key;
605
- else break;
606
- }
607
- // Above every card (top of transcript) → we're still reading the first day.
608
- if (!currentKey) currentKey = sessionDays.find((d) => dayStripRefs.current[d.key])?.key ?? null;
609
- if (currentKey) setSelectedDayKey((prev) => (prev !== currentKey ? currentKey! : prev));
610
- };
611
- const onScroll = () => {
612
- if (raf) return;
613
- raf = requestAnimationFrame(update);
614
- };
615
- mainEl.addEventListener("scroll", onScroll, { passive: true });
616
- update(); // sync the minimap to the initial reading position (day 1 at top)
617
- return () => {
618
- mainEl.removeEventListener("scroll", onScroll);
619
- if (raf) cancelAnimationFrame(raf);
620
- };
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
621
686
  }, [tab, sessionDays]);
622
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
+
623
715
  /** Offset window for the selected day, or null when there's a single day
624
716
  * or "all" is chosen — null tells the minimap to render the full session
625
717
  * exactly as before (no regression for single-day sessions). */
@@ -709,12 +801,13 @@ export function SessionView({
709
801
  setScrollIntent((n) => n + 1);
710
802
  }
711
803
 
712
- /** Switch the minimap to a given day (or "all"). On an explicit day pick we
713
- * also scroll the transcript to that day's first row so "jump to a day"
714
- * navigates the body too — but the default selection on mount does NOT
715
- * scroll (it only flows through here on user interaction). */
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). */
716
808
  function gotoDay(key: string) {
717
809
  setSelectedDayKey(key);
810
+ tailNavLockRef.current = Date.now();
718
811
  // Paging days scrolls the transcript; that programmatic scroll (often
719
812
  // upward to an earlier day's first row) would otherwise read as a
720
813
  // user scroll-up and pop the sticky header open. Force it collapsed and
@@ -735,7 +828,38 @@ export function SessionView({
735
828
  setExpandedTurns((prev) => new Set(prev).add(turnIdx));
736
829
  }
737
830
  }
738
- setDayScrollTarget((prev) => ({ index: ev.index, key, n: (prev?.n ?? 0) + 1 }));
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
+ }));
739
863
  }
740
864
 
741
865
  /** Scroll the transcript to the row whose assistant message issued a given
@@ -1186,6 +1310,7 @@ export function SessionView({
1186
1310
  // for the same right-side real estate.
1187
1311
  if (id) setSelectedIndex(null);
1188
1312
  }}
1313
+ onPlayheadChange={followDayToScroll}
1189
1314
  />
1190
1315
  )}
1191
1316
  </div>
@@ -1230,6 +1355,7 @@ export function SessionView({
1230
1355
  )
1231
1356
  }
1232
1357
  onJumpToParent={jumpToToolUse}
1358
+ onClearFocus={() => setSelectedWorkflowId(null)}
1233
1359
  />
1234
1360
  </div>
1235
1361
  ) : tab === "debug" ? (
@@ -1281,6 +1407,7 @@ export function SessionView({
1281
1407
  sessionDays={sessionDays}
1282
1408
  entryByDay={entryByDay}
1283
1409
  dayStripRefs={dayStripRefs}
1410
+ onJumpAcrossDay={jumpAcrossDay}
1284
1411
  />
1285
1412
  </>
1286
1413
  )}
@@ -1411,7 +1538,7 @@ export function SessionView({
1411
1538
  )}
1412
1539
 
1413
1540
  {/* Tail mode FAB — auto-scroll to follow live events */}
1414
- <TailMode isLive={isSessionLive} />
1541
+ <TailMode isLive={isSessionLive} navLockRef={tailNavLockRef} />
1415
1542
  </div>
1416
1543
  );
1417
1544
  }
@@ -1420,353 +1547,19 @@ export function SessionView({
1420
1547
  /* Row helpers */
1421
1548
  /* ------------------------------------------------------------------ */
1422
1549
 
1423
- const rowPrimaryIndex = rowPrimaryIndexFromLib;
1424
-
1425
- /* ------------------------------------------------------------------ */
1426
- /* DisplayRow: one line in the transcript list */
1427
- /* */
1428
- /* Flat list item type — either a presentation row (user, agent, */
1429
- /* tool-group, error, ...), a collapsed turn summary, or an */
1430
- /* expanded-turn header with its inner rows following. */
1431
- /* ------------------------------------------------------------------ */
1432
-
1433
- type DisplayRow =
1434
- | { kind: "presentation"; row: PresentationRow; indented?: boolean }
1435
- | { kind: "turn-collapsed"; turn: TurnMegaRow }
1436
- | { kind: "turn-expanded-header"; turn: TurnMegaRow }
1437
- | { kind: "turn-expanded-footer"; turn: TurnMegaRow };
1438
-
1439
- function flattenMegaRows(megaRows: MegaRow[], expanded: Set<number>): DisplayRow[] {
1440
- const out: DisplayRow[] = [];
1441
- for (const m of megaRows) {
1442
- if (m.kind === "turn") {
1443
- if (expanded.has(m.firstPrimaryIndex)) {
1444
- out.push({ kind: "turn-expanded-header", turn: m });
1445
- for (const r of m.rows) {
1446
- out.push({ kind: "presentation", row: r, indented: true });
1447
- }
1448
- out.push({ kind: "turn-expanded-footer", turn: m });
1449
- } else {
1450
- out.push({ kind: "turn-collapsed", turn: m });
1451
- }
1452
- } else {
1453
- out.push({ kind: "presentation", row: m });
1454
- }
1455
- }
1456
- return out;
1457
- }
1458
-
1459
- /** Fallback for "All events" filter mode — wraps each raw event in a
1460
- * synthetic PresentationRow so the transcript component can render
1461
- * everything (attachments, meta, thinking, tool_result) without
1462
- * going through the meaningful-transformation logic. */
1463
- function allRowsAsRawRows(events: SessionEvent[]): PresentationRow[] {
1464
- const out: PresentationRow[] = [];
1465
- for (const e of events) {
1466
- // Coerce every raw event to the closest presentation kind.
1467
- let kind: PresentationRowKind;
1468
- switch (e.role) {
1469
- case "user":
1470
- kind = "user";
1471
- break;
1472
- case "agent":
1473
- case "agent-thinking":
1474
- kind = "agent";
1475
- break;
1476
- case "tool-call":
1477
- case "tool-result":
1478
- kind = "tool-group";
1479
- break;
1480
- case "system":
1481
- case "meta":
1482
- kind = "model";
1483
- break;
1484
- }
1485
- if (kind === "tool-group") {
1486
- out.push({
1487
- kind: "tool-group",
1488
- toolNames: [{ name: e.toolName ?? e.rawType, count: 1 }],
1489
- count: 1,
1490
- events: [e],
1491
- tOffsetMs: e.tOffsetMs,
1492
- gapMs: e.gapMs,
1493
- });
1494
- } else if (kind === "agent") {
1495
- out.push({
1496
- kind: "agent",
1497
- event: e,
1498
- groupedEvents: [e],
1499
- tOffsetMs: e.tOffsetMs,
1500
- gapMs: e.gapMs,
1501
- });
1502
- } else if (kind === "user") {
1503
- out.push({
1504
- kind: "user",
1505
- event: e,
1506
- tOffsetMs: e.tOffsetMs,
1507
- gapMs: e.gapMs,
1508
- });
1509
- } else {
1510
- out.push({
1511
- kind: "model",
1512
- event: e,
1513
- tOffsetMs: e.tOffsetMs,
1514
- gapMs: e.gapMs,
1515
- });
1516
- }
1517
- }
1518
- return out;
1519
- }
1550
+ // rowPrimaryIndex, DisplayRow, flattenMegaRows, allRowsAsRawRows moved to ./session-view/{types,helpers}.ts
1520
1551
 
1521
1552
  /* ------------------------------------------------------------------ */
1522
1553
  /* Header stats + token stat w/ tooltip */
1523
1554
  /* ------------------------------------------------------------------ */
1524
1555
 
1525
- /** Compact inline header stat — icon + value, separated by dots.
1526
- * Used in the single-line session header, modeled on Claude's Sessions page. */
1527
- function InlineStat({
1528
- icon,
1529
- value,
1530
- mono,
1531
- truncate,
1532
- }: {
1533
- icon?: React.ReactNode;
1534
- value: string;
1535
- mono?: boolean;
1536
- truncate?: boolean;
1537
- }) {
1538
- return (
1539
- <span
1540
- style={{
1541
- display: "inline-flex",
1542
- alignItems: "center",
1543
- gap: 5,
1544
- fontSize: 12,
1545
- color: "var(--af-text-secondary)",
1546
- maxWidth: truncate ? 260 : undefined,
1547
- overflow: "hidden",
1548
- textOverflow: "ellipsis",
1549
- whiteSpace: "nowrap",
1550
- }}
1551
- >
1552
- {icon && (
1553
- <span
1554
- style={{
1555
- display: "inline-flex",
1556
- color: "var(--af-text-tertiary)",
1557
- }}
1558
- >
1559
- {icon}
1560
- </span>
1561
- )}
1562
- <span
1563
- style={{
1564
- fontFamily: mono ? "var(--font-mono)" : "inherit",
1565
- overflow: "hidden",
1566
- textOverflow: "ellipsis",
1567
- whiteSpace: "nowrap",
1568
- }}
1569
- >
1570
- {value}
1571
- </span>
1572
- </span>
1573
- );
1574
- }
1575
-
1576
- function InlineStatDivider() {
1577
- return (
1578
- <span
1579
- style={{
1580
- color: "var(--af-text-tertiary)",
1581
- fontSize: 11,
1582
- opacity: 0.6,
1583
- }}
1584
- >
1585
- ·
1586
- </span>
1587
- );
1588
- }
1589
-
1590
- function EntrypointBadge({ entrypoint }: { entrypoint: string }) {
1591
- // cli + claude-desktop are human-driven; sdk-* are programmatic.
1592
- const isSdk = entrypoint.startsWith("sdk-");
1593
- const tone = isSdk
1594
- ? { bg: "rgba(245, 158, 11, 0.16)", fg: "#b45309" }
1595
- : { bg: "rgba(16, 185, 129, 0.16)", fg: "#047857" };
1596
- return (
1597
- <span
1598
- title={`entrypoint: ${entrypoint}`}
1599
- style={{
1600
- fontSize: 10.5,
1601
- padding: "2px 7px",
1602
- borderRadius: 4,
1603
- background: tone.bg,
1604
- color: tone.fg,
1605
- fontWeight: 600,
1606
- fontFamily: "var(--font-mono)",
1607
- letterSpacing: 0.2,
1608
- }}
1609
- >
1610
- {entrypoint}
1611
- </span>
1612
- );
1613
- }
1614
-
1615
- function InlineTokenStat({ usage }: { usage: SessionEvent["usage"] }) {
1616
- const { ref, anchor, open, close } = useAnchoredTooltip();
1617
- const u = usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1618
- const totalIn = u.input + u.cacheRead + u.cacheWrite;
1619
- const pctRead = totalIn > 0 ? Math.round((u.cacheRead / totalIn) * 100) : 0;
1620
- const cached = u.cacheRead + u.cacheWrite;
1621
-
1622
- return (
1623
- <span
1624
- ref={ref}
1625
- style={{
1626
- display: "inline-flex",
1627
- alignItems: "center",
1628
- gap: 6,
1629
- fontSize: 12,
1630
- color: "var(--af-text-secondary)",
1631
- cursor: "default",
1632
- fontFamily: "var(--font-mono)",
1633
- }}
1634
- onMouseEnter={open}
1635
- onMouseLeave={close}
1636
- >
1637
- <span>
1638
- {formatTokens(u.input)}
1639
- <span style={{ color: "var(--af-text-tertiary)", margin: "0 3px" }}>in</span>
1640
- {formatTokens(u.output)}
1641
- <span style={{ color: "var(--af-text-tertiary)", marginLeft: 3 }}>out</span>
1642
- </span>
1643
- {cached > 0 && (
1644
- <span
1645
- style={{
1646
- fontSize: 10.5,
1647
- color: "var(--af-text-tertiary)",
1648
- paddingLeft: 6,
1649
- borderLeft: "1px solid var(--af-border-subtle)",
1650
- }}
1651
- >
1652
- +{formatTokens(cached)} cached
1653
- </span>
1654
- )}
1655
- {anchor && (
1656
- <AnchoredTooltip anchor={anchor} width={280}>
1657
- <TooltipRow label="Input (fresh)" value={u.input.toLocaleString()} />
1658
- <TooltipRow label="Output" value={u.output.toLocaleString()} />
1659
- <TooltipRow label="Cache read" value={`${u.cacheRead.toLocaleString()} (${pctRead}%)`} />
1660
- <TooltipRow label="Cache write" value={u.cacheWrite.toLocaleString()} />
1661
- <div
1662
- style={{
1663
- marginTop: 6,
1664
- paddingTop: 6,
1665
- borderTop: "1px solid rgba(241,245,249,0.12)",
1666
- opacity: 0.65,
1667
- fontSize: 10,
1668
- whiteSpace: "normal",
1669
- lineHeight: 1.4,
1670
- }}
1671
- >
1672
- Cache reads are cumulative across all API requests and billed at ~10% of regular input.
1673
- </div>
1674
- </AnchoredTooltip>
1675
- )}
1676
- </span>
1677
- );
1678
- }
1679
-
1680
- // Used for tooltips that live inside an overflow:hidden ancestor (like the
1681
- // session-header row). getBoundingClientRect + position:fixed lets the
1682
- // tooltip escape the clipping context instead of being cropped.
1683
- function useAnchoredTooltip() {
1684
- const ref = useRef<HTMLSpanElement>(null);
1685
- const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
1686
- const open = () => {
1687
- if (!ref.current) return;
1688
- const r = ref.current.getBoundingClientRect();
1689
- setAnchor({ top: r.bottom + 6, left: r.left });
1690
- };
1691
- const close = () => setAnchor(null);
1692
- return { ref, anchor, open, close };
1693
- }
1694
-
1695
- function AnchoredTooltip({
1696
- anchor,
1697
- width,
1698
- children,
1699
- }: {
1700
- anchor: { top: number; left: number };
1701
- width: number;
1702
- children: React.ReactNode;
1703
- }) {
1704
- return (
1705
- <div
1706
- style={{
1707
- position: "fixed",
1708
- top: anchor.top,
1709
- left: anchor.left,
1710
- zIndex: 1000,
1711
- background: "#1A1A1A",
1712
- color: "#F5F1EC",
1713
- padding: "8px 12px",
1714
- borderRadius: 6,
1715
- fontSize: 11,
1716
- fontFamily: "var(--font-mono)",
1717
- lineHeight: 1.5,
1718
- pointerEvents: "none",
1719
- boxShadow: "0 4px 16px rgba(15,23,42,0.24)",
1720
- width,
1721
- }}
1722
- >
1723
- {children}
1724
- </div>
1725
- );
1726
- }
1556
+ // InlineStat, InlineStatDivider, EntrypointBadge, InlineTokenStat, useAnchoredTooltip, AnchoredTooltip moved to ./session-view/header-stats.tsx
1727
1557
 
1728
1558
  /* ------------------------------------------------------------------ */
1729
1559
  /* Generic Tooltip */
1730
1560
  /* ------------------------------------------------------------------ */
1731
1561
 
1732
- function Tooltip({ children, style }: { children: React.ReactNode; style?: CSSProperties }) {
1733
- return (
1734
- <div
1735
- style={{
1736
- position: "absolute",
1737
- zIndex: 100,
1738
- background: "#1A1A1A",
1739
- color: "#F5F1EC",
1740
- borderRadius: 8,
1741
- padding: "10px 12px",
1742
- fontSize: 11,
1743
- fontFamily: "var(--font-mono)",
1744
- lineHeight: 1.5,
1745
- boxShadow: "0 4px 20px rgba(0,0,0,0.18)",
1746
- pointerEvents: "none",
1747
- whiteSpace: "nowrap",
1748
- ...style,
1749
- }}
1750
- >
1751
- {children}
1752
- </div>
1753
- );
1754
- }
1755
-
1756
- function TooltipRow({ label, value }: { label: string; value: string }) {
1757
- return (
1758
- <div
1759
- style={{
1760
- display: "flex",
1761
- justifyContent: "space-between",
1762
- gap: 12,
1763
- }}
1764
- >
1765
- <span style={{ opacity: 0.7 }}>{label}</span>
1766
- <span>{value}</span>
1767
- </div>
1768
- );
1769
- }
1562
+ // Tooltip, TooltipRow moved to ./session-view/tooltip.tsx
1770
1563
 
1771
1564
  /* ------------------------------------------------------------------ */
1772
1565
  /* Mini-map */
@@ -1787,22 +1580,7 @@ function TooltipRow({ label, value }: { label: string; value: string }) {
1787
1580
  /* - Rich hover card (role pill + preview) instead of bare SVG title. */
1788
1581
  /* ------------------------------------------------------------------ */
1789
1582
 
1790
- type MinimapSeg =
1791
- | {
1792
- kind: "row";
1793
- row: PresentationRow;
1794
- primaryIndex: number;
1795
- start: number;
1796
- end: number;
1797
- }
1798
- | {
1799
- kind: "turn";
1800
- turn: TurnMegaRow;
1801
- primaryIndex: number;
1802
- start: number;
1803
- end: number;
1804
- }
1805
- | { kind: "idle"; start: number; end: number; durationMs: number };
1583
+ // MinimapSeg moved to ./session-view/types.ts
1806
1584
 
1807
1585
  /** All rows render at full bar height — distinction comes from color/pattern
1808
1586
  * alone, matching Claude Managed Agents' unified-height timeline. */
@@ -1833,6 +1611,7 @@ function Minimap({
1833
1611
  coldResumeMarkers,
1834
1612
  rawIdleBands,
1835
1613
  model,
1614
+ onPlayheadChange,
1836
1615
  }: {
1837
1616
  displayRows: DisplayRow[];
1838
1617
  durationMs: number;
@@ -1859,6 +1638,10 @@ function Minimap({
1859
1638
  * surface their dead-air on the minimap. */
1860
1639
  rawIdleBands?: { start: number; end: number; durationMs: number }[];
1861
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;
1862
1645
  }) {
1863
1646
  const WIDTH = 1400;
1864
1647
  /** Main timeline height. Sub-agent lanes stack below this. */
@@ -1897,6 +1680,10 @@ function Minimap({
1897
1680
  };
1898
1681
  } | null>(null);
1899
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;
1900
1687
  const containerRef = useRef<HTMLDivElement>(null);
1901
1688
 
1902
1689
  /* Playhead — track which transcript row is at the top of the viewport.
@@ -1905,60 +1692,54 @@ function Minimap({
1905
1692
  * on every scroll event. For a session with ~2000 rows that's thousands
1906
1693
  * of DOM queries per second during scroll — enough to pin a CPU core
1907
1694
  * and make Chrome ask to kill the tab. IntersectionObserver is event-
1908
- * driven: the browser only notifies us when rows cross a narrow band
1909
- * near the top of the viewport (the "playhead line"), so the cost
1910
- * 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.
1911
1698
  */
1912
1699
  useEffect(() => {
1913
1700
  const main = containerRef.current?.closest("main") as HTMLElement | null;
1914
1701
  if (!main) return;
1915
1702
 
1916
- // A thin horizontal band just below the sticky header. A row is
1917
- // "at the playhead" when it enters this band from the bottom.
1918
- // rootMargin: top crop = headerOffset (so the band starts where the
1919
- // transcript actually starts), bottom crop = everything except the
1920
- // band, so only rows inside the band trigger callbacks.
1921
- const BAND_HEIGHT = 12;
1922
- const rootMargin = `-${headerOffset}px 0px -${Math.max(
1923
- 0,
1924
- main.clientHeight - headerOffset - BAND_HEIGHT,
1925
- )}px 0px`;
1926
-
1927
- // Track which rows are currently inside the band. The topmost is
1928
- // the playhead. Using a Set keeps updates O(1).
1929
- 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>();
1930
1715
 
1931
1716
  const publishTopmost = () => {
1932
- if (inBand.size === 0) {
1933
- // Fall back to the row just above the band by scanning the
1934
- // few immediately-adjacent rows — cheap because the band is
1935
- // narrow.
1936
- setPlayheadMs(null);
1937
- return;
1938
- }
1939
- let topmost: HTMLElement | null = null;
1940
- let topmostOff = Infinity;
1941
- for (const el of inBand) {
1942
- const t = el.offsetTop;
1943
- if (t < topmostOff) {
1944
- topmostOff = t;
1945
- 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;
1946
1731
  }
1947
1732
  }
1948
- if (!topmost) {
1949
- setPlayheadMs(null);
1950
- return;
1951
- }
1952
- const tOff = Number(topmost.getAttribute("data-sl-toffset"));
1953
- setPlayheadMs(Number.isNaN(tOff) ? null : tOff);
1733
+ setPlayheadMs(value);
1734
+ onPlayheadChangeRef.current?.(value);
1954
1735
  };
1955
1736
 
1956
1737
  const observer = new IntersectionObserver(
1957
1738
  (entries) => {
1958
1739
  for (const e of entries) {
1959
1740
  const el = e.target as HTMLElement;
1960
- if (e.isIntersecting) inBand.add(el);
1961
- else inBand.delete(el);
1741
+ if (e.isIntersecting) visibleRows.add(el);
1742
+ else visibleRows.delete(el);
1962
1743
  }
1963
1744
  publishTopmost();
1964
1745
  },
@@ -2607,12 +2388,17 @@ function Minimap({
2607
2388
  strokeWidth="1.5"
2608
2389
  strokeDasharray="4 3"
2609
2390
  opacity="0.75"
2391
+ pointerEvents="none"
2610
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. */}
2611
2397
  <rect
2612
- x={x - 8}
2613
- y={0}
2614
- width={16}
2615
- height={TOTAL_H}
2398
+ x={x - 6}
2399
+ y={MAIN_H / 2 - 8}
2400
+ width={12}
2401
+ height={16}
2616
2402
  fill="transparent"
2617
2403
  />
2618
2404
  <polygon
@@ -2620,6 +2406,7 @@ function Minimap({
2620
2406
  fill="#D97706"
2621
2407
  stroke="var(--af-surface)"
2622
2408
  strokeWidth="1.5"
2409
+ pointerEvents="none"
2623
2410
  />
2624
2411
  </g>
2625
2412
  );
@@ -2717,33 +2504,7 @@ function Minimap({
2717
2504
  );
2718
2505
  }
2719
2506
 
2720
- /** Color a sub-agent bar by its type. Background runs use a brighter
2721
- * variant of their color so true parallelism is visually distinct from
2722
- * blocking subagent calls (where the parent waits and there's no
2723
- * meaningful overlap with main session work). */
2724
- function subagentColor(agentType: string, background?: boolean): string {
2725
- const palette: Record<string, [string, string]> = {
2726
- "general-purpose": ["#5C84C3", "#7BA3DC"],
2727
- Explore: ["#A855F7", "#C57BFF"],
2728
- Plan: ["#F59E0B", "#FFBD3D"],
2729
- "code-reviewer": ["#34D399", "#5EE5B0"],
2730
- "playwright-qa-verifier": ["#22D3EE", "#67E8F9"],
2731
- "claude-code-guide": ["#EC4899", "#F472B6"],
2732
- };
2733
- const [base, bright] = palette[agentType] ?? ["#8A8580", "#A8A19A"];
2734
- return background ? bright : base;
2735
- }
2736
-
2737
- /** Workflow signature color, modulated by run status. Deliberately a warm
2738
- * orange — distinct from the blue/purple subagent palette so workflow lanes
2739
- * read as a different tier on the minimap. Failed runs go red, in-flight
2740
- * runs go green. */
2741
- function workflowColor(status: string): string {
2742
- const s = status.toLowerCase();
2743
- if (s === "failed" || s === "aborted" || s === "error") return "#EF4444";
2744
- if (s === "running" || s === "in_progress" || s === "active") return "#10B981";
2745
- return "#EA580C";
2746
- }
2507
+ // subagentColor, workflowColor moved to ./session-view/colors.ts
2747
2508
 
2748
2509
  function MinimapHoverCard({
2749
2510
  containerRef,
@@ -3344,6 +3105,7 @@ function TranscriptList({
3344
3105
  sessionDays,
3345
3106
  entryByDay,
3346
3107
  dayStripRefs,
3108
+ onJumpAcrossDay,
3347
3109
  }: {
3348
3110
  displayRows: DisplayRow[];
3349
3111
  rowRefs: React.MutableRefObject<Record<number, HTMLDivElement | null>>;
@@ -3364,6 +3126,14 @@ function TranscriptList({
3364
3126
  sessionDays: { key: string; startMs: number; endMs: number; count: number }[];
3365
3127
  entryByDay: Map<string, Entry>;
3366
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;
3367
3137
  }) {
3368
3138
  // Find the last collapsed turn index so we can mark it as in-progress
3369
3139
  // when the session is live.
@@ -3396,6 +3166,10 @@ function TranscriptList({
3396
3166
  if (d.kind === "turn-expanded-footer") return undefined;
3397
3167
  return d.row.tOffsetMs;
3398
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;
3399
3173
  /** Which local day a row falls in: the last day bucket whose start is at or
3400
3174
  * before the row's offset. sessionDays is pre-sorted by startMs. */
3401
3175
  const dayKeyForOffset = (tOff: number | undefined): string | null => {
@@ -3435,10 +3209,27 @@ function TranscriptList({
3435
3209
  }
3436
3210
  // Day boundary: drop this day's digest card at its first row (after any
3437
3211
  // overnight idle divider that led into the day) so per-day perception
3438
- // sits inline with that day's work, not stacked at the top.
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.
3439
3216
  const dk = dayKeyForOffset(rowTOffset(d));
3440
3217
  if (dk && dk !== currentDayKey) {
3218
+ const endedKey = currentDayKey; // day that just ended; null on the first day
3441
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
+ }
3442
3233
  const dayEntry = entryByDay.get(dk);
3443
3234
  if (dayEntry) {
3444
3235
  out.push(
@@ -3453,6 +3244,16 @@ function TranscriptList({
3453
3244
  </div>,
3454
3245
  );
3455
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
+ }
3456
3257
  }
3457
3258
  }
3458
3259
 
@@ -3550,30 +3351,75 @@ function IdleDivider({ gapMs }: { gapMs: number }) {
3550
3351
  );
3551
3352
  }
3552
3353
 
3553
- function findColdResumeInDisplayRow(
3554
- d: DisplayRow,
3555
- ): SessionEvent["coldResume"] | null {
3556
- if (d.kind === "presentation") return findColdResumeInRow(d.row);
3557
- if (d.kind === "turn-collapsed" || d.kind === "turn-expanded-header") {
3558
- for (const r of d.turn.rows) {
3559
- const hit = findColdResumeInRow(r);
3560
- if (hit) return hit;
3561
- }
3562
- }
3563
- return null;
3564
- }
3565
-
3566
- function findColdResumeInRow(
3567
- r: PresentationRow,
3568
- ): SessionEvent["coldResume"] | null {
3569
- if (r.kind === "agent") {
3570
- if (r.event.coldResume) return r.event.coldResume;
3571
- for (const ge of r.groupedEvents) {
3572
- if (ge.coldResume) return ge.coldResume;
3573
- }
3574
- } else if (r.kind === "tool-group") {
3575
- for (const e of r.events) if (e.coldResume) return e.coldResume;
3576
- }
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
+
3399
+ function findColdResumeInDisplayRow(
3400
+ d: DisplayRow,
3401
+ ): SessionEvent["coldResume"] | null {
3402
+ if (d.kind === "presentation") return findColdResumeInRow(d.row);
3403
+ if (d.kind === "turn-collapsed" || d.kind === "turn-expanded-header") {
3404
+ for (const r of d.turn.rows) {
3405
+ const hit = findColdResumeInRow(r);
3406
+ if (hit) return hit;
3407
+ }
3408
+ }
3409
+ return null;
3410
+ }
3411
+
3412
+ function findColdResumeInRow(
3413
+ r: PresentationRow,
3414
+ ): SessionEvent["coldResume"] | null {
3415
+ if (r.kind === "agent") {
3416
+ if (r.event.coldResume) return r.event.coldResume;
3417
+ for (const ge of r.groupedEvents) {
3418
+ if (ge.coldResume) return ge.coldResume;
3419
+ }
3420
+ } else if (r.kind === "tool-group") {
3421
+ for (const e of r.events) if (e.coldResume) return e.coldResume;
3422
+ }
3577
3423
  return null;
3578
3424
  }
3579
3425
 
@@ -4643,170 +4489,13 @@ function ToolGroupLabel({
4643
4489
  );
4644
4490
  }
4645
4491
 
4646
- function TokenChip({ usage }: { usage: NonNullable<SessionEvent["usage"]> }) {
4647
- const [hover, setHover] = useState(false);
4648
- const totalIn = usage.input + usage.cacheRead + usage.cacheWrite;
4649
- const pctRead = totalIn > 0 ? Math.round((usage.cacheRead / totalIn) * 100) : 0;
4650
- return (
4651
- <span
4652
- style={{
4653
- position: "relative",
4654
- fontFamily: "var(--font-mono)",
4655
- fontSize: 11,
4656
- color: "var(--af-text-secondary)",
4657
- display: "inline-flex",
4658
- alignItems: "center",
4659
- gap: 4,
4660
- whiteSpace: "nowrap",
4661
- }}
4662
- onMouseEnter={() => setHover(true)}
4663
- onMouseLeave={() => setHover(false)}
4664
- >
4665
- <span style={{ opacity: 0.6 }}>▤</span>
4666
- {formatTokens(totalIn)} / {formatTokens(usage.output)}
4667
- {hover && (
4668
- <Tooltip style={{ right: 0, top: "calc(100% + 6px)", minWidth: 180 }}>
4669
- <TooltipRow label="Input" value={usage.input.toLocaleString()} />
4670
- <TooltipRow
4671
- label="Cache read"
4672
- value={`${usage.cacheRead.toLocaleString()} (${pctRead}%)`}
4673
- />
4674
- <TooltipRow label="Cache write" value={usage.cacheWrite.toLocaleString()} />
4675
- <TooltipRow label="Output" value={usage.output.toLocaleString()} />
4676
- </Tooltip>
4677
- )}
4678
- </span>
4679
- );
4680
- }
4681
-
4682
- /** Like TokenChip but for per-turn aggregates. Primary display is
4683
- * fresh input / output (not the cache-inflated sum), consistent with
4684
- * the session header. Tooltip shows the full breakdown plus a footnote
4685
- * reminding that cache reads are cumulative. */
4686
- function TurnTokenChip({
4687
- usage,
4688
- }: {
4689
- usage: { input: number; output: number; cacheRead: number; cacheWrite: number };
4690
- }) {
4691
- const [hover, setHover] = useState(false);
4692
- const totalIn = usage.input + usage.cacheRead + usage.cacheWrite;
4693
- const pctRead = totalIn > 0 ? Math.round((usage.cacheRead / totalIn) * 100) : 0;
4694
- return (
4695
- <span
4696
- style={{
4697
- position: "relative",
4698
- fontFamily: "var(--font-mono)",
4699
- fontSize: 11,
4700
- color: "var(--af-text-tertiary)",
4701
- display: "inline-flex",
4702
- alignItems: "center",
4703
- gap: 4,
4704
- whiteSpace: "nowrap",
4705
- cursor: "default",
4706
- }}
4707
- onMouseEnter={() => setHover(true)}
4708
- onMouseLeave={() => setHover(false)}
4709
- >
4710
- <span style={{ opacity: 0.6 }}>▤</span>
4711
- {formatTokens(usage.input)} / {formatTokens(usage.output)}
4712
- {hover && (
4713
- <Tooltip style={{ right: 0, top: "calc(100% + 6px)", minWidth: 220 }}>
4714
- <TooltipRow label="Input (fresh)" value={usage.input.toLocaleString()} />
4715
- <TooltipRow label="Output" value={usage.output.toLocaleString()} />
4716
- <TooltipRow
4717
- label="Cache read"
4718
- value={`${usage.cacheRead.toLocaleString()} (${pctRead}%)`}
4719
- />
4720
- <TooltipRow label="Cache write" value={usage.cacheWrite.toLocaleString()} />
4721
- <div
4722
- style={{
4723
- marginTop: 6,
4724
- paddingTop: 6,
4725
- borderTop: "1px solid rgba(241,245,249,0.12)",
4726
- opacity: 0.65,
4727
- fontSize: 10,
4728
- whiteSpace: "normal",
4729
- lineHeight: 1.4,
4730
- }}
4731
- >
4732
- Cumulative across all requests in this turn.
4733
- </div>
4734
- </Tooltip>
4735
- )}
4736
- </span>
4737
- );
4738
- }
4492
+ // TokenChip, TurnTokenChip moved to ./session-view/token-stats.tsx
4739
4493
 
4740
4494
  /* ------------------------------------------------------------------ */
4741
4495
  /* Debug list */
4742
4496
  /* ------------------------------------------------------------------ */
4743
4497
 
4744
- function DebugList({ events }: { events: SessionEvent[] }) {
4745
- // Rebuild a JSON view from the structured fields on the event. The
4746
- // original `raw` field is stripped server-side before serialization
4747
- // (see app/sessions/[id]/page.tsx) because including the full JSONL
4748
- // line per event doubles the RSC payload on large sessions. All the
4749
- // useful data is already on the structured event; this view surfaces
4750
- // it in the same shape you'd get from the raw JSONL.
4751
- const shapeForDebug = (e: SessionEvent) => ({
4752
- type: e.rawType,
4753
- index: e.index,
4754
- uuid: e.uuid,
4755
- parentUuid: e.parentUuid,
4756
- timestamp: e.timestamp,
4757
- role: e.role,
4758
- messageId: e.messageId,
4759
- stopReason: e.stopReason,
4760
- model: e.model,
4761
- requestId: e.requestId,
4762
- toolName: e.toolName,
4763
- toolUseId: e.toolUseId,
4764
- attachmentType: e.attachmentType,
4765
- usage: e.usage,
4766
- blocks: e.blocks,
4767
- });
4768
-
4769
- return (
4770
- <div style={{ padding: "8px 0" }}>
4771
- {events.map((e) => (
4772
- <details
4773
- key={e.index}
4774
- style={{
4775
- borderBottom: "1px solid var(--af-border-subtle)",
4776
- padding: "8px 12px",
4777
- }}
4778
- >
4779
- <summary
4780
- style={{
4781
- cursor: "pointer",
4782
- fontFamily: "var(--font-mono)",
4783
- fontSize: 12,
4784
- color: "var(--af-text-secondary)",
4785
- }}
4786
- >
4787
- #{e.index} · {e.rawType}
4788
- {e.attachmentType ? `/${e.attachmentType}` : ""} · {e.timestamp ?? "(no ts)"}
4789
- </summary>
4790
- <pre
4791
- style={{
4792
- marginTop: 8,
4793
- padding: 12,
4794
- background: "var(--background)",
4795
- border: "1px solid var(--af-border-subtle)",
4796
- borderRadius: 6,
4797
- fontSize: 11,
4798
- overflow: "auto",
4799
- maxHeight: 400,
4800
- color: "var(--af-text-secondary)",
4801
- }}
4802
- >
4803
- {JSON.stringify(shapeForDebug(e), null, 2)}
4804
- </pre>
4805
- </details>
4806
- ))}
4807
- </div>
4808
- );
4809
- }
4498
+ // DebugList moved to ./session-view/debug-list.tsx
4810
4499
 
4811
4500
  /* ------------------------------------------------------------------ */
4812
4501
  /* SubagentDrawer */
@@ -4819,412 +4508,98 @@ function DebugList({ events }: { events: SessionEvent[] }) {
4819
4508
  /* this subagent off. */
4820
4509
  /* ------------------------------------------------------------------ */
4821
4510
 
4822
- function SubagentDrawer({
4823
- subagent,
4824
- 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,
4825
4528
  onJumpToParent,
4529
+ onClearFocus,
4826
4530
  }: {
4827
- subagent: SubagentRun;
4828
- onClose: () => void;
4829
- 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;
4830
4538
  }) {
4831
- const s = subagent;
4832
- const fill = subagentColor(s.agentType, s.runInBackground);
4833
- const startOff = formatOffset(s.startTOffsetMs);
4834
- const endOff = formatOffset(s.endTOffsetMs);
4835
- const dur = s.durationMs !== undefined ? formatGap(s.durationMs) : "—";
4836
- const totalIn = s.totalUsage.input + s.totalUsage.cacheRead + s.totalUsage.cacheWrite;
4837
- const pctRead = totalIn > 0 ? Math.round((s.totalUsage.cacheRead / totalIn) * 100) : 0;
4838
-
4539
+ const totalTokens = workflows.reduce((n, w) => n + w.totalTokens, 0);
4540
+ const totalTools = workflows.reduce((n, w) => n + w.toolCallCount, 0);
4839
4541
  return (
4840
- <div>
4841
- {/* 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
+ >
4842
4551
  <div
4843
4552
  style={{
4844
- padding: "14px 20px",
4845
- borderBottom: "1px solid var(--af-border-subtle)",
4846
4553
  display: "flex",
4847
4554
  alignItems: "center",
4848
4555
  gap: 10,
4849
- background: "var(--af-surface)",
4850
- position: "sticky",
4851
- top: 0,
4852
- zIndex: 1,
4853
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))",
4854
4560
  }}
4855
4561
  >
4856
4562
  <span
4857
4563
  style={{
4858
- fontSize: 11,
4859
- fontWeight: 600,
4860
- padding: "3px 10px",
4861
- borderRadius: 4,
4862
- background: fill,
4863
- color: "#fff",
4864
- }}
4865
- >
4866
- {s.agentType}
4867
- </span>
4868
- {s.runInBackground && (
4869
- <span
4870
- style={{
4871
- fontSize: 9,
4872
- fontWeight: 600,
4873
- padding: "2px 8px",
4874
- borderRadius: 4,
4875
- background: "var(--af-warning-subtle)",
4876
- color: "var(--af-warning)",
4877
- textTransform: "uppercase",
4878
- letterSpacing: "0.04em",
4879
- }}
4880
- >
4881
- background
4882
- </span>
4883
- )}
4884
- <span
4885
- style={{
4886
- fontSize: 14,
4564
+ display: "inline-flex",
4565
+ alignItems: "center",
4566
+ gap: 7,
4567
+ fontSize: 13,
4887
4568
  fontWeight: 600,
4888
4569
  color: "var(--af-text)",
4889
- flex: 1,
4890
- minWidth: 0,
4891
- overflow: "hidden",
4892
- textOverflow: "ellipsis",
4893
- whiteSpace: "nowrap",
4894
4570
  }}
4895
- title={s.description}
4896
4571
  >
4897
- {s.description}
4572
+ <WorkflowIcon size={14} style={{ color: "#EA580C" }} />
4573
+ Workflows
4898
4574
  </span>
4899
- <button
4900
- onClick={onClose}
4575
+ <span
4901
4576
  style={{
4902
- background: "transparent",
4903
- border: "none",
4904
- cursor: "pointer",
4577
+ fontSize: 11.5,
4905
4578
  color: "var(--af-text-tertiary)",
4906
- padding: 4,
4907
- borderRadius: 4,
4579
+ fontFamily: "var(--font-mono)",
4908
4580
  }}
4909
- aria-label="Close"
4910
4581
  >
4911
- <X size={16} />
4912
- </button>
4913
- </div>
4914
-
4915
- {/* Meta strip */}
4916
- <div
4917
- style={{
4918
- padding: "10px 20px",
4919
- fontFamily: "var(--font-mono)",
4920
- fontSize: 11,
4921
- color: "var(--af-text-tertiary)",
4922
- borderBottom: "1px solid var(--af-border-subtle)",
4923
- display: "grid",
4924
- gridTemplateColumns: "auto 1fr",
4925
- columnGap: 10,
4926
- rowGap: 3,
4927
- }}
4928
- >
4929
- <span style={{ opacity: 0.7 }}>range</span>
4930
- <span style={{ color: "var(--af-text-secondary)" }}>
4931
- {startOff} → {endOff}
4582
+ {workflows.length} run{workflows.length === 1 ? "" : "s"} ·{" "}
4583
+ {spawnedAgentCount.toLocaleString()} agents · {totalTools.toLocaleString()} tool calls ·{" "}
4584
+ {formatTokens(totalTokens)} tok orchestrated
4932
4585
  </span>
4933
- <span style={{ opacity: 0.7 }}>duration</span>
4934
- <span style={{ color: "var(--af-text-secondary)" }}>{dur}</span>
4935
- {s.model && (
4936
- <>
4937
- <span style={{ opacity: 0.7 }}>model</span>
4938
- <span style={{ color: "var(--af-text-secondary)" }}>{s.model}</span>
4939
- </>
4940
- )}
4941
- {s.parentToolUseId && (
4942
- <>
4943
- <span style={{ opacity: 0.7 }}>parent</span>
4944
- <span style={{ color: "var(--af-text-secondary)" }}>
4945
- {s.parentToolUseId.slice(0, 24)}…
4946
- </span>
4947
- </>
4948
- )}
4949
4586
  </div>
4950
-
4951
- {/* Activity stats */}
4952
- <div
4953
- style={{
4954
- padding: "14px 20px",
4955
- borderBottom: "1px solid var(--af-border-subtle)",
4956
- display: "grid",
4957
- gridTemplateColumns: "1fr 1fr 1fr",
4958
- gap: 10,
4959
- }}
4960
- >
4961
- <StatCell label="Events" value={String(s.eventCount)} />
4962
- <StatCell label="Messages" value={String(s.assistantMessageCount ?? 0)} />
4963
- <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
+ ))}
4964
4599
  </div>
4965
-
4966
- {/* Token breakdown */}
4967
- <div
4968
- style={{
4969
- padding: "12px 20px",
4970
- borderBottom: "1px solid var(--af-border-subtle)",
4971
- fontSize: 11,
4972
- fontFamily: "var(--font-mono)",
4973
- color: "var(--af-text-secondary)",
4974
- display: "flex",
4975
- flexDirection: "column",
4976
- gap: 3,
4977
- }}
4978
- >
4979
- <TokenLine label="Input (fresh)" value={s.totalUsage.input} />
4980
- <TokenLine label="Output" value={s.totalUsage.output} />
4981
- <TokenLine
4982
- label="Cache read"
4983
- value={s.totalUsage.cacheRead}
4984
- suffix={` (${pctRead}%)`}
4985
- />
4986
- <TokenLine label="Cache write" value={s.totalUsage.cacheWrite} />
4987
- </div>
4988
-
4989
- {/* Tool breakdown */}
4990
- {s.toolCalls && s.toolCalls.length > 0 && (
4991
- <div
4992
- style={{
4993
- padding: "14px 20px",
4994
- borderBottom: "1px solid var(--af-border-subtle)",
4995
- }}
4996
- >
4997
- <div
4998
- style={{
4999
- fontSize: 10,
5000
- fontWeight: 600,
5001
- textTransform: "uppercase",
5002
- letterSpacing: "0.04em",
5003
- color: "var(--af-text-tertiary)",
5004
- marginBottom: 8,
5005
- }}
5006
- >
5007
- Tools used
5008
- </div>
5009
- <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
5010
- {s.toolCalls.map((t) => (
5011
- <span
5012
- key={t.name}
5013
- style={{
5014
- display: "inline-flex",
5015
- alignItems: "center",
5016
- gap: 4,
5017
- fontSize: 11,
5018
- padding: "2px 8px",
5019
- borderRadius: 4,
5020
- background: "var(--af-border-subtle)",
5021
- color: "var(--af-text)",
5022
- fontFamily: "var(--font-mono)",
5023
- }}
5024
- >
5025
- <b style={{ fontWeight: 600 }}>{shortenToolName(t.name)}</b>
5026
- <span style={{ color: "var(--af-text-tertiary)" }}>×{t.count}</span>
5027
- </span>
5028
- ))}
5029
- </div>
5030
- </div>
5031
- )}
5032
-
5033
- {/* Parent prompt (what the parent asked the subagent to do) */}
5034
- {s.prompt && (
5035
- <div
5036
- style={{
5037
- padding: "14px 20px",
5038
- borderBottom: "1px solid var(--af-border-subtle)",
5039
- }}
5040
- >
5041
- <div
5042
- style={{
5043
- display: "flex",
5044
- alignItems: "center",
5045
- justifyContent: "space-between",
5046
- marginBottom: 8,
5047
- }}
5048
- >
5049
- <div
5050
- style={{
5051
- fontSize: 10,
5052
- fontWeight: 600,
5053
- textTransform: "uppercase",
5054
- letterSpacing: "0.04em",
5055
- color: "var(--af-text-tertiary)",
5056
- }}
5057
- >
5058
- Prompt
5059
- </div>
5060
- {s.parentToolUseId && (
5061
- <button
5062
- type="button"
5063
- onClick={onJumpToParent}
5064
- style={{
5065
- fontSize: 10,
5066
- color: "var(--af-accent)",
5067
- background: "transparent",
5068
- border: "none",
5069
- cursor: "pointer",
5070
- padding: 0,
5071
- }}
5072
- >
5073
- Jump to parent →
5074
- </button>
5075
- )}
5076
- </div>
5077
- <div className="sl-prose" style={{ fontSize: 12.5 }}>
5078
- <ReactMarkdown
5079
- remarkPlugins={[remarkGfm]}
5080
- components={{
5081
- a: (props) => (
5082
- <a
5083
- {...props}
5084
- target="_blank"
5085
- rel="noopener noreferrer"
5086
- style={{
5087
- color: "var(--af-accent)",
5088
- textDecoration: "underline",
5089
- }}
5090
- />
5091
- ),
5092
- }}
5093
- >
5094
- {s.prompt}
5095
- </ReactMarkdown>
5096
- </div>
5097
- </div>
5098
- )}
5099
-
5100
- {/* Final text */}
5101
- {s.finalText && (
5102
- <div style={{ padding: "14px 20px" }}>
5103
- <div
5104
- style={{
5105
- fontSize: 10,
5106
- fontWeight: 600,
5107
- textTransform: "uppercase",
5108
- letterSpacing: "0.04em",
5109
- color: "var(--af-text-tertiary)",
5110
- marginBottom: 8,
5111
- }}
5112
- >
5113
- Final result
5114
- </div>
5115
- <div className="sl-prose" style={{ fontSize: 13 }}>
5116
- <ReactMarkdown
5117
- remarkPlugins={[remarkGfm]}
5118
- components={{
5119
- a: (props) => (
5120
- <a
5121
- {...props}
5122
- target="_blank"
5123
- rel="noopener noreferrer"
5124
- style={{
5125
- color: "var(--af-accent)",
5126
- textDecoration: "underline",
5127
- }}
5128
- />
5129
- ),
5130
- }}
5131
- >
5132
- {s.finalText}
5133
- </ReactMarkdown>
5134
- </div>
5135
- </div>
5136
- )}
5137
- </div>
5138
- );
5139
- }
5140
-
5141
- /* ------------------------------------------------------------------ */
5142
- /* Workflows panel */
5143
- /* */
5144
- /* A single `Workflow` tool call collapses a whole dynamic-workflow */
5145
- /* fan-out into one transcript row. This panel surfaces what that row */
5146
- /* actually did: each run's spawned-agent count, tool calls, tokens, */
5147
- /* phases, and progress log — the fleet work the transcript hides. */
5148
- /* ------------------------------------------------------------------ */
5149
-
5150
- function WorkflowsPanel({
5151
- workflows,
5152
- spawnedAgentCount,
5153
- focusRunId,
5154
- selectedAgentKey,
5155
- onOpenAgent,
5156
- onJumpToParent,
5157
- }: {
5158
- workflows: WorkflowRun[];
5159
- spawnedAgentCount: number;
5160
- focusRunId?: string | null;
5161
- selectedAgentKey?: string | null;
5162
- onOpenAgent: (runId: string, agent: WorkflowRun["agents"][number]) => void;
5163
- onJumpToParent: (toolUseId?: string) => void;
5164
- }) {
5165
- const totalTokens = workflows.reduce((n, w) => n + w.totalTokens, 0);
5166
- const totalTools = workflows.reduce((n, w) => n + w.toolCallCount, 0);
5167
- return (
5168
- <section
5169
- style={{
5170
- marginBottom: 16,
5171
- border: "1px solid var(--af-border-subtle)",
5172
- borderRadius: 10,
5173
- background: "var(--af-surface)",
5174
- overflow: "hidden",
5175
- }}
5176
- >
5177
- <div
5178
- style={{
5179
- display: "flex",
5180
- alignItems: "center",
5181
- gap: 10,
5182
- flexWrap: "wrap",
5183
- padding: "10px 14px",
5184
- borderBottom: "1px solid var(--af-border-subtle)",
5185
- background: "var(--af-surface-subtle, rgba(234,88,12,0.04))",
5186
- }}
5187
- >
5188
- <span
5189
- style={{
5190
- display: "inline-flex",
5191
- alignItems: "center",
5192
- gap: 7,
5193
- fontSize: 13,
5194
- fontWeight: 600,
5195
- color: "var(--af-text)",
5196
- }}
5197
- >
5198
- <WorkflowIcon size={14} style={{ color: "#EA580C" }} />
5199
- Workflows
5200
- </span>
5201
- <span
5202
- style={{
5203
- fontSize: 11.5,
5204
- color: "var(--af-text-tertiary)",
5205
- fontFamily: "var(--font-mono)",
5206
- }}
5207
- >
5208
- {workflows.length} run{workflows.length === 1 ? "" : "s"} ·{" "}
5209
- {spawnedAgentCount.toLocaleString()} agents · {totalTools.toLocaleString()} tool calls ·{" "}
5210
- {formatTokens(totalTokens)} tok orchestrated
5211
- </span>
5212
- </div>
5213
- <div style={{ display: "flex", flexDirection: "column" }}>
5214
- {workflows.map((w) => (
5215
- <WorkflowCard
5216
- key={w.runId}
5217
- w={w}
5218
- focused={focusRunId === w.runId}
5219
- selectedAgentKey={selectedAgentKey}
5220
- onOpenAgent={onOpenAgent}
5221
- onJumpToParent={onJumpToParent}
5222
- />
5223
- ))}
5224
- </div>
5225
- </section>
5226
- );
5227
- }
4600
+ </section>
4601
+ );
4602
+ }
5228
4603
 
5229
4604
  function WorkflowCard({
5230
4605
  w,
@@ -5232,12 +4607,14 @@ function WorkflowCard({
5232
4607
  selectedAgentKey,
5233
4608
  onOpenAgent,
5234
4609
  onJumpToParent,
4610
+ onClearFocus,
5235
4611
  }: {
5236
4612
  w: WorkflowRun;
5237
4613
  focused?: boolean;
5238
4614
  selectedAgentKey?: string | null;
5239
4615
  onOpenAgent: (runId: string, agent: WorkflowRun["agents"][number]) => void;
5240
4616
  onJumpToParent: (toolUseId?: string) => void;
4617
+ onClearFocus?: () => void;
5241
4618
  }) {
5242
4619
  const [open, setOpen] = useState(focused);
5243
4620
  const cardRef = useRef<HTMLDivElement>(null);
@@ -5257,14 +4634,17 @@ function WorkflowCard({
5257
4634
  ref={cardRef}
5258
4635
  style={{
5259
4636
  borderTop: "1px solid var(--af-border-subtle)",
5260
- background: focused ? "rgba(234,88,12,0.07)" : "transparent",
5261
- transition: "background 0.5s ease",
5262
4637
  scrollMarginTop: 120,
5263
4638
  }}
5264
4639
  >
5265
4640
  {/* Collapsed header — click to expand */}
5266
4641
  <button
5267
- 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
+ }}
5268
4648
  style={{
5269
4649
  width: "100%",
5270
4650
  display: "flex",
@@ -5287,7 +4667,11 @@ function WorkflowCard({
5287
4667
  fontFamily: "var(--font-mono)",
5288
4668
  fontSize: 13,
5289
4669
  fontWeight: 600,
5290
- 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",
5291
4675
  minWidth: 0,
5292
4676
  overflow: "hidden",
5293
4677
  textOverflow: "ellipsis",
@@ -5369,7 +4753,7 @@ function WorkflowCard({
5369
4753
  fontFamily: "var(--font-mono)",
5370
4754
  }}
5371
4755
  >
5372
- Jump to Workflow call →
4756
+ Jump to timeline →
5373
4757
  </button>
5374
4758
  )}
5375
4759
  </div>
@@ -6020,593 +5404,18 @@ function WorkflowRunLog({ logs }: { logs: string[] }) {
6020
5404
  );
6021
5405
  }
6022
5406
 
6023
- function WfMiniStat({ icon, value }: { icon?: React.ReactNode; value: string }) {
6024
- return (
6025
- <span
6026
- style={{
6027
- display: "inline-flex",
6028
- alignItems: "center",
6029
- gap: 4,
6030
- fontSize: 11.5,
6031
- color: "var(--af-text-secondary)",
6032
- fontFamily: "var(--font-mono)",
6033
- whiteSpace: "nowrap",
6034
- }}
6035
- >
6036
- {icon && <span style={{ color: "var(--af-text-tertiary)", display: "inline-flex" }}>{icon}</span>}
6037
- {value}
6038
- </span>
6039
- );
6040
- }
6041
-
6042
- function SectionLabel({ children }: { children: React.ReactNode }) {
6043
- return (
6044
- <div
6045
- style={{
6046
- fontSize: 10,
6047
- fontWeight: 600,
6048
- textTransform: "uppercase",
6049
- letterSpacing: "0.04em",
6050
- color: "var(--af-text-tertiary)",
6051
- marginBottom: 8,
6052
- }}
6053
- >
6054
- {children}
6055
- </div>
6056
- );
6057
- }
6058
-
6059
- function StatCell({ label, value }: { label: string; value: string }) {
6060
- return (
6061
- <div>
6062
- <div
6063
- style={{
6064
- fontSize: 9,
6065
- color: "var(--af-text-tertiary)",
6066
- textTransform: "uppercase",
6067
- letterSpacing: "0.04em",
6068
- fontWeight: 600,
6069
- }}
6070
- >
6071
- {label}
6072
- </div>
6073
- <div
6074
- style={{
6075
- fontSize: 18,
6076
- fontWeight: 700,
6077
- fontFamily: "var(--font-mono)",
6078
- color: "var(--af-text)",
6079
- marginTop: 2,
6080
- }}
6081
- >
6082
- {value}
6083
- </div>
6084
- </div>
6085
- );
6086
- }
6087
-
6088
- function TokenLine({
6089
- label,
6090
- value,
6091
- suffix,
6092
- }: {
6093
- label: string;
6094
- value: number;
6095
- suffix?: string;
6096
- }) {
6097
- return (
6098
- <div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
6099
- <span style={{ opacity: 0.7 }}>{label}</span>
6100
- <span>
6101
- {value.toLocaleString()}
6102
- {suffix && <span style={{ opacity: 0.65 }}>{suffix}</span>}
6103
- </span>
6104
- </div>
6105
- );
6106
- }
5407
+ // WfMiniStat, SectionLabel, StatCell, TokenLine moved to ./session-view/workflows-shared.tsx
6107
5408
 
6108
5409
  /* ------------------------------------------------------------------ */
6109
5410
  /* Drawer */
6110
5411
  /* ------------------------------------------------------------------ */
6111
5412
 
6112
- function Drawer({
6113
- event,
6114
- row,
6115
- onClose,
6116
- }: {
6117
- event: SessionEvent;
6118
- row: PresentationRow | null;
6119
- onClose: () => void;
6120
- }) {
6121
- const [showDev, setShowDev] = useState(false);
6122
- const kind: PresentationRowKind = row?.kind ?? "agent";
6123
- const theme = ROLE_THEMES[kind];
6124
- const title = drawerTitle(row, event);
6125
- const hasUsage = !!event.usage && (event.usage.input > 0 || event.usage.output > 0);
6126
-
6127
- return (
6128
- <div>
6129
- {/* Sticky title bar */}
6130
- <div
6131
- style={{
6132
- padding: "14px 20px",
6133
- borderBottom: "1px solid var(--af-border-subtle)",
6134
- display: "flex",
6135
- alignItems: "center",
6136
- gap: 10,
6137
- background: "var(--af-surface)",
6138
- position: "sticky",
6139
- top: 0,
6140
- zIndex: 1,
6141
- }}
6142
- >
6143
- <span
6144
- style={{
6145
- fontSize: 11,
6146
- fontWeight: 600,
6147
- padding: "3px 10px",
6148
- borderRadius: 4,
6149
- background: theme.bg,
6150
- color: theme.fg,
6151
- }}
6152
- >
6153
- {theme.label}
6154
- </span>
6155
- <span
6156
- style={{
6157
- fontSize: 14,
6158
- fontWeight: 600,
6159
- color: "var(--af-text)",
6160
- }}
6161
- >
6162
- {title}
6163
- </span>
6164
- <button
6165
- onClick={onClose}
6166
- style={{
6167
- marginLeft: "auto",
6168
- background: "transparent",
6169
- border: "none",
6170
- cursor: "pointer",
6171
- color: "var(--af-text-tertiary)",
6172
- padding: 4,
6173
- borderRadius: 4,
6174
- }}
6175
- >
6176
- <X size={16} />
6177
- </button>
6178
- </div>
6179
-
6180
- {/* Meta line — compact */}
6181
- <div
6182
- style={{
6183
- padding: "8px 20px",
6184
- fontFamily: "var(--font-mono)",
6185
- fontSize: 11,
6186
- color: "var(--af-text-tertiary)",
6187
- display: "flex",
6188
- gap: 10,
6189
- alignItems: "center",
6190
- flexWrap: "wrap",
6191
- borderBottom: "1px solid var(--af-border-subtle)",
6192
- }}
6193
- >
6194
- <span>{formatOffset(event.tOffsetMs)}</span>
6195
- {event.gapMs !== undefined && event.gapMs > 0 && <span>· {formatGap(event.gapMs)}</span>}
6196
- {hasUsage && event.usage && (
6197
- <span style={{ color: "var(--af-text-secondary)" }}>
6198
- · {formatTokens(event.usage.input + event.usage.cacheRead + event.usage.cacheWrite)}/
6199
- {formatTokens(event.usage.output)} tokens
6200
- </span>
6201
- )}
6202
- <button
6203
- onClick={() => setShowDev((s) => !s)}
6204
- style={{
6205
- marginLeft: "auto",
6206
- background: "transparent",
6207
- border: "none",
6208
- fontSize: 10,
6209
- color: "var(--af-text-tertiary)",
6210
- cursor: "pointer",
6211
- padding: 0,
6212
- fontFamily: "inherit",
6213
- textDecoration: "underline",
6214
- textUnderlineOffset: 2,
6215
- }}
6216
- >
6217
- {showDev ? "hide details" : "details"}
6218
- </button>
6219
- </div>
6220
-
6221
- {/* Developer panel — collapsed by default */}
6222
- {showDev && (
6223
- <div
6224
- style={{
6225
- padding: "10px 20px 14px",
6226
- borderBottom: "1px solid var(--af-border-subtle)",
6227
- fontSize: 11,
6228
- color: "var(--af-text-secondary)",
6229
- fontFamily: "var(--font-mono)",
6230
- display: "flex",
6231
- flexDirection: "column",
6232
- gap: 4,
6233
- }}
6234
- >
6235
- {event.model && <div>model: {event.model}</div>}
6236
- {event.requestId && <div>request: {event.requestId}</div>}
6237
- {event.messageId && <div>message: {event.messageId}</div>}
6238
- {event.stopReason && <div>stop_reason: {event.stopReason}</div>}
6239
- {event.usage && (
6240
- <>
6241
- <div style={{ marginTop: 6, opacity: 0.7 }}>tokens</div>
6242
- <div> input: {event.usage.input.toLocaleString()}</div>
6243
- <div> output: {event.usage.output.toLocaleString()}</div>
6244
- <div> cache read: {event.usage.cacheRead.toLocaleString()}</div>
6245
- <div>
6246
- {" "}cache write: {event.usage.cacheWrite.toLocaleString()}
6247
- </div>
6248
- </>
6249
- )}
6250
- </div>
6251
- )}
6252
-
6253
- {/* Content */}
6254
- <div style={{ padding: "18px 22px" }}>
6255
- <DrawerContent event={event} row={row} />
6256
- </div>
6257
- </div>
6258
- );
6259
- }
6260
-
6261
- function drawerTitle(row: PresentationRow | null, event: SessionEvent): string {
6262
- if (row) {
6263
- switch (row.kind) {
6264
- case "user":
6265
- return "Message";
6266
- case "agent":
6267
- return "Message";
6268
- case "tool-group":
6269
- return `Tool use · ${formatToolSummary(row.toolNames)}`;
6270
- case "interrupt":
6271
- return "Interrupted";
6272
- case "model":
6273
- return "Model (zero-usage)";
6274
- case "error":
6275
- return "API error";
6276
- case "task-notification":
6277
- return `Background task · ${row.status}`;
6278
- }
6279
- }
6280
- return event.rawType;
6281
- }
6282
-
6283
- function DrawerContent({ event, row }: { event: SessionEvent; row: PresentationRow | null }) {
6284
- // User rows can override their blocks (e.g. slash commands get a cleaned
6285
- // "/implement AGE-8" block instead of the raw XML).
6286
- if (row?.kind === "user" && row.displayBlocks) {
6287
- return (
6288
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
6289
- {row.displayBlocks.map((b, i) => (
6290
- <BlockView key={i} block={b} />
6291
- ))}
6292
- </div>
6293
- );
6294
- }
6295
-
6296
- // Task notifications: parsed fields in a clean key-value layout,
6297
- // not the raw <task-notification> XML blob.
6298
- if (row?.kind === "task-notification") {
6299
- const statusColor =
6300
- row.status === "success"
6301
- ? "var(--af-success)"
6302
- : row.status === "failed"
6303
- ? "var(--af-danger)"
6304
- : "var(--af-text-secondary)";
6305
- return (
6306
- <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
6307
- <div
6308
- style={{
6309
- display: "flex",
6310
- alignItems: "center",
6311
- gap: 8,
6312
- }}
6313
- >
6314
- <span
6315
- style={{
6316
- fontSize: 11,
6317
- fontWeight: 600,
6318
- padding: "3px 10px",
6319
- borderRadius: 4,
6320
- background:
6321
- row.status === "success"
6322
- ? "var(--af-success-subtle)"
6323
- : row.status === "failed"
6324
- ? "var(--af-danger-subtle)"
6325
- : "var(--af-border-subtle)",
6326
- color: statusColor,
6327
- textTransform: "uppercase",
6328
- letterSpacing: "0.03em",
6329
- }}
6330
- >
6331
- {row.status}
6332
- </span>
6333
- </div>
6334
- <div style={{ fontSize: 13, lineHeight: 1.55, color: "var(--af-text)" }}>{row.summary}</div>
6335
- {(row.taskId || row.toolUseId || row.outputFile) && (
6336
- <div
6337
- style={{
6338
- fontSize: 11,
6339
- color: "var(--af-text-secondary)",
6340
- fontFamily: "var(--font-mono)",
6341
- display: "flex",
6342
- flexDirection: "column",
6343
- gap: 4,
6344
- paddingTop: 10,
6345
- borderTop: "1px solid var(--af-border-subtle)",
6346
- }}
6347
- >
6348
- {row.taskId && <div>task id: {row.taskId}</div>}
6349
- {row.toolUseId && <div>tool use: {row.toolUseId.slice(0, 24)}…</div>}
6350
- {row.outputFile && (
6351
- <div style={{ wordBreak: "break-all" }}>output: {row.outputFile}</div>
6352
- )}
6353
- </div>
6354
- )}
6355
- </div>
6356
- );
6357
- }
6358
-
6359
- // Tool group: list all individual tool calls with their inputs
6360
- if (row?.kind === "tool-group" && row.count > 1) {
6361
- return (
6362
- <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
6363
- {row.events.map((e, i) => (
6364
- <div key={e.index}>
6365
- <div
6366
- style={{
6367
- fontSize: 11,
6368
- color: "var(--af-text-tertiary)",
6369
- marginBottom: 4,
6370
- fontFamily: "var(--font-mono)",
6371
- }}
6372
- >
6373
- #{i + 1} · {formatOffset(e.tOffsetMs)} · {e.toolUseId?.slice(0, 14)}…
6374
- </div>
6375
- {e.blocks.map((b, bi) => (
6376
- <BlockView key={bi} block={b} />
6377
- ))}
6378
- </div>
6379
- ))}
6380
- </div>
6381
- );
6382
- }
6383
-
6384
- // Agent row: include thinking blocks from the same message.id if present
6385
- if (row?.kind === "agent" && row.groupedEvents.length > 1) {
6386
- return (
6387
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
6388
- {row.groupedEvents.flatMap((e, i) =>
6389
- e.blocks.map((b, bi) => <BlockView key={`${i}-${bi}`} block={b} />),
6390
- )}
6391
- </div>
6392
- );
6393
- }
6394
-
6395
- return (
6396
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
6397
- {event.blocks.map((b, i) => (
6398
- <BlockView key={i} block={b} />
6399
- ))}
6400
- </div>
6401
- );
6402
- }
6403
- function BlockView({ block }: { block: ContentBlock }) {
6404
- if (block.type === "text") {
6405
- return (
6406
- <div className="sl-prose">
6407
- <ReactMarkdown
6408
- remarkPlugins={[remarkGfm]}
6409
- components={{
6410
- a: (props) => (
6411
- <a
6412
- {...props}
6413
- target="_blank"
6414
- rel="noopener noreferrer"
6415
- style={{
6416
- color: "var(--af-accent)",
6417
- textDecoration: "underline",
6418
- textUnderlineOffset: 2,
6419
- }}
6420
- />
6421
- ),
6422
- }}
6423
- >
6424
- {block.text}
6425
- </ReactMarkdown>
6426
- </div>
6427
- );
6428
- }
6429
- if (block.type === "thinking") {
6430
- return (
6431
- <details>
6432
- <summary
6433
- style={{
6434
- cursor: "pointer",
6435
- fontSize: 11,
6436
- color: "var(--af-text-tertiary)",
6437
- marginBottom: 6,
6438
- }}
6439
- >
6440
- Thinking · {block.thinking.length} chars
6441
- </summary>
6442
- <div
6443
- style={{
6444
- fontSize: 12,
6445
- lineHeight: 1.6,
6446
- color: "var(--af-text-secondary)",
6447
- whiteSpace: "pre-wrap",
6448
- fontStyle: "italic",
6449
- borderLeft: "3px solid var(--af-border-subtle)",
6450
- paddingLeft: 12,
6451
- marginTop: 6,
6452
- }}
6453
- >
6454
- {block.thinking}
6455
- </div>
6456
- </details>
6457
- );
6458
- }
6459
- if (block.type === "tool_use") {
6460
- return <ToolUseCard name={block.name} input={block.input} />;
6461
- }
6462
- if (block.type === "tool_result") {
6463
- const text =
6464
- typeof block.content === "string" ? block.content : JSON.stringify(block.content, null, 2);
6465
- return (
6466
- <pre
6467
- style={{
6468
- fontSize: 12,
6469
- padding: 12,
6470
- background: "var(--background)",
6471
- border: "1px solid var(--af-border-subtle)",
6472
- borderRadius: 6,
6473
- overflow: "auto",
6474
- whiteSpace: "pre-wrap",
6475
- color: "var(--af-text)",
6476
- fontFamily: "var(--font-mono)",
6477
- maxHeight: 500,
6478
- }}
6479
- >
6480
- {text}
6481
- </pre>
6482
- );
6483
- }
6484
- return null;
6485
- }
5413
+ // DrawerContent, BlockView moved to ./session-view/drawer.tsx
6486
5414
 
6487
5415
  /* ------------------------------------------------------------------ */
6488
5416
  /* Helpers */
6489
5417
  /* ------------------------------------------------------------------ */
6490
5418
 
6491
- function formatDurationHeader(ms: number): string {
6492
- if (ms < 1000) return `${ms}ms`;
6493
- const s = Math.round(ms / 1000);
6494
- if (s < 60) return `${s}s`;
6495
- const m = Math.floor(s / 60);
6496
- if (m < 60) return `${m}m ${s % 60}s`;
6497
- // Roll up to hours — agent time can reach many hours once workflow
6498
- // execution is folded in, and "1116m" reads poorly.
6499
- const h = Math.floor(m / 60);
6500
- return `${h}h ${m % 60}m`;
6501
- }
6502
-
6503
- function EntryDayStrip({ entry }: { entry: Entry }) {
6504
- const fmtDate = new Date(`${entry.local_day}T12:00:00`).toLocaleDateString("en-US", {
6505
- weekday: "short", month: "short", day: "numeric",
6506
- });
6507
- const durMin = Math.round(entry.numbers.active_min);
6508
- const enr = entry.enrichment;
6509
- const isPending = enr.status === "pending" || enr.status === "error";
6510
- const isTrivial = enr.status === "skipped_trivial";
5419
+ // formatDurationHeader moved to ./session-view/helpers.ts
6511
5420
 
6512
- return (
6513
- <div
6514
- style={{
6515
- border: "1px solid var(--af-border-subtle)",
6516
- borderRadius: 10,
6517
- padding: "12px 16px",
6518
- background: "var(--af-surface)",
6519
- fontSize: 13,
6520
- lineHeight: 1.5,
6521
- }}
6522
- >
6523
- <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
6524
- <span
6525
- style={{
6526
- fontSize: 12,
6527
- color: "var(--af-text-tertiary)",
6528
- display: "inline-flex",
6529
- alignItems: "center",
6530
- gap: 4,
6531
- }}
6532
- >
6533
- 📅 {fmtDate}
6534
- </span>
6535
- {isTrivial ? (
6536
- <OutcomePill outcome="trivial" size="md" agent={entry.agent} />
6537
- ) : isPending ? (
6538
- <OutcomePill
6539
- outcome={null}
6540
- pending
6541
- sessionId={entry.session_id}
6542
- localDay={entry.local_day}
6543
- size="md"
6544
- agent={entry.agent}
6545
- />
6546
- ) : enr.outcome ? (
6547
- <OutcomePill outcome={enr.outcome} size="md" agent={entry.agent} />
6548
- ) : null}
6549
- <span
6550
- style={{
6551
- fontFamily: "var(--font-mono)",
6552
- fontSize: 11,
6553
- color: "var(--af-text-tertiary)",
6554
- marginLeft: "auto",
6555
- }}
6556
- >
6557
- {durMin}m active
6558
- </span>
6559
- </div>
6560
-
6561
- {isTrivial && (
6562
- <div style={{ fontSize: 12, color: "var(--af-text-tertiary)", fontStyle: "italic", marginTop: 8 }}>
6563
- Warmup — no enrichment generated.
6564
- </div>
6565
- )}
6566
-
6567
- {isPending && (
6568
- <div style={{ fontSize: 12, color: "var(--af-text-secondary)", marginTop: 8 }}>
6569
- Click the pending pill to generate this day's digest →
6570
- </div>
6571
- )}
6572
-
6573
- {enr.status === "done" && (
6574
- <>
6575
- {enr.brief_summary && (
6576
- <p style={{ margin: "10px 0 0", color: "var(--af-text)" }}>{enr.brief_summary}</p>
6577
- )}
6578
- {(enr.friction_detail || enr.user_instructions.length > 0) && (
6579
- <div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
6580
- {enr.friction_detail && (
6581
- <div style={{ fontSize: 12, color: "var(--af-text-secondary)" }}>
6582
- <span style={{ color: "#ed8936" }}>⚠ </span>
6583
- {enr.friction_detail}
6584
- </div>
6585
- )}
6586
- {enr.user_instructions.length > 0 && (
6587
- <details style={{ fontSize: 12, color: "var(--af-text-secondary)" }}>
6588
- <summary style={{ cursor: "pointer", color: "var(--af-text-tertiary)" }}>
6589
- Top user instructions ({enr.user_instructions.length})
6590
- </summary>
6591
- <ul style={{ margin: "6px 0 0", paddingLeft: 18 }}>
6592
- {enr.user_instructions.slice(0, 5).map((u, i) => (
6593
- <li key={i} style={{ marginBottom: 2 }}>{u}</li>
6594
- ))}
6595
- </ul>
6596
- </details>
6597
- )}
6598
- </div>
6599
- )}
6600
- <div style={{ marginTop: 10, fontSize: 11 }}>
6601
- <Link
6602
- href={`/digest/${entry.local_day}`}
6603
- style={{ color: "var(--af-accent)", textDecoration: "none" }}
6604
- >
6605
- Open day digest →
6606
- </Link>
6607
- </div>
6608
- </>
6609
- )}
6610
- </div>
6611
- );
6612
- }
5421
+ // EntryDayStrip moved to ./session-view/entry-day-strip.tsx