fleetlens 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/app/apps/web/.next/BUILD_ID +1 -1
  2. package/app/apps/web/.next/build-manifest.json +3 -3
  3. package/app/apps/web/.next/prerender-manifest.json +3 -3
  4. package/app/apps/web/.next/server/app/_global-error.html +1 -1
  5. package/app/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/app/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/app/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/app/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/app/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/app/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/app/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  12. package/app/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/app/apps/web/.next/server/app/api/ask/route.js.nft.json +1 -1
  14. package/app/apps/web/.next/server/app/api/digest/day/[date]/route.js.nft.json +1 -1
  15. package/app/apps/web/.next/server/app/api/digest/month/[yearMonth]/route.js.nft.json +1 -1
  16. package/app/apps/web/.next/server/app/api/digest/month-index/route.js.nft.json +1 -1
  17. package/app/apps/web/.next/server/app/api/digest/week/[startDate]/route.js.nft.json +1 -1
  18. package/app/apps/web/.next/server/app/api/digest/week-index/route.js.nft.json +1 -1
  19. package/app/apps/web/.next/server/app/api/events/route.js.nft.json +1 -1
  20. package/app/apps/web/.next/server/app/api/jobs/route.js.nft.json +1 -1
  21. package/app/apps/web/.next/server/app/api/pdf/[key]/route.js.nft.json +1 -1
  22. package/app/apps/web/.next/server/app/api/runs/[runId]/route.js.nft.json +1 -1
  23. package/app/apps/web/.next/server/app/api/runs/[runId]/stream/route.js.nft.json +1 -1
  24. package/app/apps/web/.next/server/app/api/runs/route.js.nft.json +1 -1
  25. package/app/apps/web/.next/server/app/api/settings/route.js.nft.json +1 -1
  26. package/app/apps/web/.next/server/app/api/team-config/route.js.nft.json +1 -1
  27. package/app/apps/web/.next/server/app/api/workflow-agent/route.js.nft.json +1 -1
  28. package/app/apps/web/.next/server/app/changelog/page.js.nft.json +1 -1
  29. package/app/apps/web/.next/server/app/changelog/page_client-reference-manifest.js +1 -1
  30. package/app/apps/web/.next/server/app/day/[date]/page.js.nft.json +1 -1
  31. package/app/apps/web/.next/server/app/day/[date]/page_client-reference-manifest.js +1 -1
  32. package/app/apps/web/.next/server/app/day/page.js.nft.json +1 -1
  33. package/app/apps/web/.next/server/app/day/page_client-reference-manifest.js +1 -1
  34. package/app/apps/web/.next/server/app/digest/[date]/page.js.nft.json +1 -1
  35. package/app/apps/web/.next/server/app/digest/[date]/page_client-reference-manifest.js +1 -1
  36. package/app/apps/web/.next/server/app/digest/page.js.nft.json +1 -1
  37. package/app/apps/web/.next/server/app/digest/page_client-reference-manifest.js +1 -1
  38. package/app/apps/web/.next/server/app/insights/[key]/page.js.nft.json +1 -1
  39. package/app/apps/web/.next/server/app/insights/[key]/page_client-reference-manifest.js +1 -1
  40. package/app/apps/web/.next/server/app/insights/page.js.nft.json +1 -1
  41. package/app/apps/web/.next/server/app/insights/page_client-reference-manifest.js +1 -1
  42. package/app/apps/web/.next/server/app/page.js.nft.json +1 -1
  43. package/app/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  44. package/app/apps/web/.next/server/app/parallelism/page.js.nft.json +1 -1
  45. package/app/apps/web/.next/server/app/parallelism/page_client-reference-manifest.js +1 -1
  46. package/app/apps/web/.next/server/app/print/[key]/page.js.nft.json +1 -1
  47. package/app/apps/web/.next/server/app/print/[key]/page_client-reference-manifest.js +1 -1
  48. package/app/apps/web/.next/server/app/projects/[slug]/page.js.nft.json +1 -1
  49. package/app/apps/web/.next/server/app/projects/[slug]/page_client-reference-manifest.js +1 -1
  50. package/app/apps/web/.next/server/app/projects/page.js.nft.json +1 -1
  51. package/app/apps/web/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  52. package/app/apps/web/.next/server/app/runs/page.js.nft.json +1 -1
  53. package/app/apps/web/.next/server/app/runs/page_client-reference-manifest.js +1 -1
  54. package/app/apps/web/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  55. package/app/apps/web/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
  56. package/app/apps/web/.next/server/app/sessions/page.js.nft.json +1 -1
  57. package/app/apps/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  58. package/app/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
  59. package/app/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  60. package/app/apps/web/.next/server/app/team/page.js.nft.json +1 -1
  61. package/app/apps/web/.next/server/app/team/page_client-reference-manifest.js +1 -1
  62. package/app/apps/web/.next/server/app/usage/page.js.nft.json +1 -1
  63. package/app/apps/web/.next/server/app/usage/page_client-reference-manifest.js +1 -1
  64. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0n-ax-5._.js +1 -1
  65. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0~.f_3z._.js +3 -3
  66. package/app/apps/web/.next/server/chunks/ssr/_0xu2pvc._.js +1 -1
  67. package/app/apps/web/.next/server/chunks/ssr/apps_web_app_day_[date]_day-view_tsx_0wjoe1u._.js +1 -1
  68. package/app/apps/web/.next/server/chunks/ssr/apps_web_lib_changelog_ts_0n7_p1d._.js +1 -1
  69. package/app/apps/web/.next/server/chunks/ssr/apps_web_lib_changelog_ts_0nb5_-s._.js +1 -1
  70. package/app/apps/web/.next/server/middleware-build-manifest.js +3 -3
  71. package/app/apps/web/.next/server/middleware-manifest.json +5 -5
  72. package/app/apps/web/.next/server/pages/500.html +1 -1
  73. package/app/apps/web/.next/server/server-reference-manifest.js +1 -1
  74. package/app/apps/web/.next/server/server-reference-manifest.json +1 -1
  75. package/app/apps/web/.next/static/chunks/{0vz4~a~~hq399.js → 02xxpexzk3rf-.js} +1 -1
  76. package/app/apps/web/.next/static/chunks/0uyctl-m8iydb.js +1 -0
  77. package/app/apps/web/.next/static/chunks/0~qdw1b2uz._5.js +10 -0
  78. package/app/apps/web/app/parallelism/gantt-chart.tsx +8 -1
  79. package/app/apps/web/app/sessions/[id]/session-view/minimap.tsx +1587 -0
  80. package/app/apps/web/app/sessions/[id]/session-view/workflows-panel.tsx +944 -0
  81. package/app/apps/web/app/sessions/[id]/session-view.tsx +909 -3223
  82. package/app/apps/web/components/tail-mode.tsx +7 -1
  83. package/app/apps/web/lib/changelog-data.generated.ts +1 -1
  84. package/app/apps/web/lib/format.ts +17 -0
  85. package/app/apps/web/package.json +1 -1
  86. package/app/apps/web/tsconfig.tsbuildinfo +1 -1
  87. package/dist/daemon-worker.js +4 -4
  88. package/dist/index.js +11 -11
  89. package/package.json +1 -1
  90. package/app/apps/web/.next/static/chunks/02bv_ejzhhzvp.js +0 -10
  91. package/app/apps/web/.next/static/chunks/0q8dg4txlzno0.js +0 -1
  92. /package/app/apps/web/.next/static/{VEfemGVAQ0xCHjj15orZU → bZs5UyzsYBqC6MFY5n-M_}/_buildManifest.js +0 -0
  93. /package/app/apps/web/.next/static/{VEfemGVAQ0xCHjj15orZU → bZs5UyzsYBqC6MFY5n-M_}/_clientMiddlewareManifest.js +0 -0
  94. /package/app/apps/web/.next/static/{VEfemGVAQ0xCHjj15orZU → bZs5UyzsYBqC6MFY5n-M_}/_ssgManifest.js +0 -0
@@ -15,16 +15,13 @@ import {
15
15
  GitPullRequest,
16
16
  Workflow as WorkflowIcon,
17
17
  Wrench,
18
- X,
19
18
  } from "lucide-react";
20
19
  import ReactMarkdown from "react-markdown";
21
20
  import remarkGfm from "remark-gfm";
22
21
  import type {
23
22
  SessionDetail,
24
23
  SessionEvent,
25
- SubagentRun,
26
24
  WorkflowRun,
27
- PrMarker,
28
25
  } from "@claude-lens/parser";
29
26
  import type { Entry } from "@claude-lens/entries";
30
27
  import { OutcomePill } from "@/components/outcome-pill";
@@ -41,9 +38,8 @@ import {
41
38
  import {
42
39
  rowPrimaryIndex,
43
40
  type DisplayRow,
44
- type MinimapSeg,
45
41
  } from "./session-view/types";
46
- import { subagentColor, workflowColor } from "./session-view/colors";
42
+ import { Minimap } from "./session-view/minimap";
47
43
  import {
48
44
  formatDayKey,
49
45
  flattenMegaRows,
@@ -64,13 +60,11 @@ import {
64
60
  } from "./session-view/header-stats";
65
61
  import { Drawer } from "./session-view/drawer";
66
62
  import {
67
- WfMiniStat,
68
- SectionLabel,
69
- StatCell,
70
- TokenLine,
71
- } from "./session-view/workflows-shared";
63
+ WorkflowsPanel,
64
+ WorkflowAgentDrawer,
65
+ } from "./session-view/workflows-panel";
72
66
  import { SubagentDrawer } from "./session-view/subagent-drawer";
73
- import { estimateCost, formatCost, formatGap, formatOffset, formatRelative, formatTokens, shortId } from "@/lib/format";
67
+ import { estimateCost, formatCost, formatGap, formatOffset, formatRelative, formatResumeStamp, formatTokens, shortId } from "@/lib/format";
74
68
  import { LiveBadge } from "@/components/live-badge";
75
69
  import { AskButton, AskDrawer } from "@/components/ask";
76
70
  import { TailMode } from "@/components/tail-mode";
@@ -447,17 +441,24 @@ export function SessionView({
447
441
  * between-turn idle because no agent activity occurred between.)
448
442
  *
449
443
  * Side effect: the leading warm-up gap before the first agent response
450
- * drops out unless it crosses MIN_GAP_MS — which the first 6s in a
444
+ * drops out unless it crosses the idle threshold — which the first 6s in a
451
445
  * typical Codex session does not. */
452
446
  const rawIdleBands = useMemo(() => {
453
447
  // Anchor on ANY timestamped event so agent-thinking, meta, and other
454
448
  // intra-turn signals prove the parent is doing work. Only what we
455
449
  // need to distinguish is "user" (turn boundary) vs everything else.
456
450
  const isUserRole = (role: string) => role === "user";
457
- // 30s threshold: keeps ordinary tool latency (Read big file, slow Bash,
458
- // model thinking between anchors) out of the idle stripes while still
459
- // catching anything that reads as a real pause.
460
- const MIN_GAP_MS = 30_000;
451
+ // Two thresholds, because an in-turn pause and a between-turn pause mean
452
+ // different things. MID-TURN the agent is actively working — a long Bash,
453
+ // a build, model thinking between tool calls — so a 30s–2min gap is NOT
454
+ // idle; only a genuine multi-minute stall is. We match the parser's
455
+ // active-segment gap (3 min) there. BETWEEN turns the user has actually
456
+ // stepped away, so a lower bar is right. The old single 30s threshold drew
457
+ // every tool-call pause as a "Session idle" sliver, shredding a long turn
458
+ // into dozens of fragments and clipping the turn's minimap block at the
459
+ // first sliver (so the block stopped short of a workflow that ran inside it).
460
+ const IN_TURN_IDLE_MS = 180_000;
461
+ const BETWEEN_TURN_IDLE_MS = 120_000;
461
462
  const anchors: { ms: number; role: string }[] = [];
462
463
  for (const e of events) {
463
464
  if (!e.timestamp) continue;
@@ -467,7 +468,14 @@ export function SessionView({
467
468
  }
468
469
  if (anchors.length < 2) return [];
469
470
  anchors.sort((a, b) => a.ms - b.ms);
470
- const sessionStartMs = anchors[0]!.ms - (events.find((e) => e.timestamp)?.tOffsetMs ?? 0);
471
+ // anchors[0].ms (sorted ascending) is the global-min timestamp — exactly the
472
+ // origin the parser measures tOffsetMs from (tOffsetMs = ms - min(all ts)), so
473
+ // band offsets (anchor.ms - sessionStartMs) land in the SAME space as the day
474
+ // window. The old `- firstInFile.tOffsetMs` term subtracted a DIFFERENT event's
475
+ // offset when the JSONL starts out of chronological order, skewing every band
476
+ // by their gap and leaking the between-day idle past the minimap's strict
477
+ // day-window edge filter (a ~40h "Session idle" band dominating the day view).
478
+ const sessionStartMs = anchors[0]!.ms;
471
479
 
472
480
  // "Busy" spans = delegated agent work that means the parent isn't idle:
473
481
  // subagent runs AND dynamic-workflow execution. Both are carved out of the
@@ -528,7 +536,7 @@ export function SessionView({
528
536
  // "Awaiting first response" — true after a user message, false once an
529
537
  // agent or tool-call anchor proves the model started replying.
530
538
  // Thinking and meta events don't clear it, so a user→thinking→agent
531
- // sequence where thinking→agent crosses MIN_GAP_MS still reads as
539
+ // sequence where thinking→agent crosses the idle threshold still reads as
532
540
  // response latency rather than idle.
533
541
  let awaitingFirstResponse = false;
534
542
  if (anchors[0]) {
@@ -545,20 +553,19 @@ export function SessionView({
545
553
  if (isUserRole(curRole)) awaitingFirstResponse = true;
546
554
  else if (curRole === "agent" || curRole === "tool-call") awaitingFirstResponse = false;
547
555
 
548
- if (rawGap <= MIN_GAP_MS) continue;
556
+ const betweenTurn = isUserRole(curRole);
557
+ if (rawGap <= (betweenTurn ? BETWEEN_TURN_IDLE_MS : IN_TURN_IDLE_MS)) continue;
549
558
  if (skipAsLatency) continue;
550
559
 
551
560
  const gStart = anchors[i - 1]!.ms - sessionStartMs;
552
561
  const gEnd = anchors[i]!.ms - sessionStartMs;
553
- const betweenTurn = isUserRole(curRole);
554
562
 
555
563
  if (!betweenTurn) {
556
564
  // In-turn gap: the parent is mid-turn. If any subagent run overlaps,
557
565
  // the parent is waiting on delegated work — not idle. Drop the
558
566
  // whole gap rather than carving out startup/teardown slivers that
559
567
  // would clutter the minimap with thin stripes. Gaps with no
560
- // subagent overlap (long Bash runs, model thinking between tool
561
- // calls) still emit as a single band.
568
+ // subagent overlap (a genuine multi-minute stall) emit as one band.
562
569
  if (overlapsSubagent(gStart, gEnd)) continue;
563
570
  bands.push({ start: gStart, end: gEnd, durationMs: gEnd - gStart });
564
571
  continue;
@@ -569,11 +576,13 @@ export function SessionView({
569
576
  // Each surviving slice becomes its own band.
570
577
  for (const slice of carveOutSubagents(gStart, gEnd)) {
571
578
  const dur = slice.end - slice.start;
572
- if (dur > MIN_GAP_MS) {
579
+ if (dur > BETWEEN_TURN_IDLE_MS) {
573
580
  bands.push({ start: slice.start, end: slice.end, durationMs: dur });
574
581
  }
575
582
  }
576
583
  }
584
+
585
+ bands.sort((a, b) => a.start - b.start);
577
586
  return bands;
578
587
  }, [events, session.subagents, session.workflows]);
579
588
 
@@ -639,11 +648,23 @@ export function SessionView({
639
648
  // mismatch; the effect re-pins it when the component is reused across
640
649
  // /sessions/[id] navigations. "all" shows the full session.
641
650
  const didInitialDayJump = useRef(false);
651
+ // A `?day=YYYY-MM-DD` query param (set by links from the Day / Concurrency
652
+ // views) pins the opening day to that day instead of the most recent one.
653
+ // Validated against the session's real day buckets and ignored if it doesn't
654
+ // match. Read in effects (not the lazy initializer) so SSR and the first
655
+ // client render agree — the param-driven selection happens client-side.
656
+ const wantedDayFromUrl = (days: typeof sessionDays): string | null => {
657
+ if (typeof window === "undefined") return null;
658
+ const w = new URLSearchParams(window.location.search).get("day");
659
+ return w && days.some((d) => d.key === w) ? w : null;
660
+ };
661
+ const lastOrAll = (days: typeof sessionDays) =>
662
+ days.length ? days[days.length - 1]!.key : "all";
642
663
  const [selectedDayKey, setSelectedDayKey] = useState<string>(
643
- () => (sessionDays.length ? sessionDays[sessionDays.length - 1]!.key : "all"),
664
+ () => lastOrAll(sessionDays),
644
665
  );
645
666
  useEffect(() => {
646
- setSelectedDayKey(sessionDays.length ? sessionDays[sessionDays.length - 1]!.key : "all");
667
+ setSelectedDayKey(wantedDayFromUrl(sessionDays) ?? lastOrAll(sessionDays));
647
668
  didInitialDayJump.current = false;
648
669
  // eslint-disable-next-line react-hooks/exhaustive-deps
649
670
  }, [session.sessionId]);
@@ -659,8 +680,10 @@ export function SessionView({
659
680
  selectedDayKeyRef.current = selectedDayKey;
660
681
 
661
682
  // 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
683
+ // requested day (?day=) or, by default, the most recent one (the lazy
684
+ // selectedDayKey is the last day; the re-pin effect above re-points it to the
685
+ // requested day client-side, so the minimap matches). Routed through gotoDay
686
+ // rather than scrolling to the day's
664
687
  // strip directly: in "turns" mode the most recent day can be absorbed into a
665
688
  // turn anchored on an earlier day and so have NO inline strip — gotoDay finds
666
689
  // the day's first event, expands its turn, and falls back to the row ref, so
@@ -676,12 +699,21 @@ export function SessionView({
676
699
  useEffect(() => {
677
700
  if (tab !== "transcript" || didInitialDayJump.current) return;
678
701
  const days = sessionDaysRef.current;
679
- if (days.length <= 1 || isSessionLive) {
702
+ if (days.length <= 1) {
703
+ didInitialDayJump.current = true;
704
+ return;
705
+ }
706
+ const wanted = wantedDayFromUrl(days);
707
+ // Live tail: let TailMode land on the live tail unless an explicit EARLIER
708
+ // day was requested (?day=). A request for the live/last day needs no jump
709
+ // — the tail is already in it — and jumping would fight TailMode's mount
710
+ // scroll and flip live-follow off.
711
+ if (isSessionLive && (!wanted || wanted === days[days.length - 1]!.key)) {
680
712
  didInitialDayJump.current = true;
681
713
  return;
682
714
  }
683
715
  didInitialDayJump.current = true;
684
- gotoDay(days[days.length - 1]!.key);
716
+ gotoDay(wanted ?? days[days.length - 1]!.key);
685
717
  // eslint-disable-next-line react-hooks/exhaustive-deps
686
718
  }, [tab, sessionDays]);
687
719
 
@@ -818,17 +850,40 @@ export function SessionView({
818
850
  if (key === "all") return;
819
851
  const d = sessionDays.find((x) => x.key === key);
820
852
  if (!d) return;
821
- const ev = events.find((e) => e.tOffsetMs !== undefined && e.tOffsetMs >= d.startMs);
822
- if (!ev) return;
823
- // In turns mode the day's first row may be inside a collapsed turn — expand
824
- // it so the row exists in the DOM before the scroll effect runs.
853
+ // Target the day's first PRESENTATION ROW, not its first raw event. A day can
854
+ // open on a meta/system/sidechain event that has no row, whose index then
855
+ // lands in neither turnByRowIndex nor rowRefs — so the jump silently no-ops
856
+ // (the bug where a multi-day session opened stuck at the very top). A row's
857
+ // primary index is the coordinate both that map and the scroll refs use.
858
+ let firstRow = allRows.find(
859
+ (r) => r.tOffsetMs !== undefined && r.tOffsetMs >= d.startMs,
860
+ );
861
+ // A day can consist entirely of background-agent / workflow events, which
862
+ // render in the Workflows tab — not the transcript — so NO presentation row
863
+ // falls inside it (sessionDays is built from all events, allRows only from
864
+ // the visible transcript). Fall back to the last rendered row so the jump
865
+ // lands on the closest content instead of silently no-oping (the bug where
866
+ // the view stayed pinned at the very top).
867
+ if (!firstRow) {
868
+ for (let i = allRows.length - 1; i >= 0; i--) {
869
+ if (allRows[i]!.tOffsetMs !== undefined) {
870
+ firstRow = allRows[i];
871
+ break;
872
+ }
873
+ }
874
+ }
875
+ if (!firstRow) return;
876
+ const targetIndex = rowPrimaryIndex(firstRow);
877
+ // In turns mode that row may be inside a collapsed turn (often one anchored
878
+ // on the PREVIOUS day) — expand it so the row exists in the DOM before the
879
+ // scroll effect runs.
825
880
  if (filter === "turns") {
826
- const turnIdx = turnByRowIndex.get(ev.index);
881
+ const turnIdx = turnByRowIndex.get(targetIndex);
827
882
  if (turnIdx !== undefined && !expandedTurns.has(turnIdx)) {
828
883
  setExpandedTurns((prev) => new Set(prev).add(turnIdx));
829
884
  }
830
885
  }
831
- setDayScrollTarget((prev) => ({ index: ev.index, key, block: "start", n: (prev?.n ?? 0) + 1 }));
886
+ setDayScrollTarget((prev) => ({ index: targetIndex, key, block: "start", n: (prev?.n ?? 0) + 1 }));
832
887
  }
833
888
 
834
889
  /** Cross-day hop used by the inline day-boundary buttons. Selecting a day can
@@ -1298,7 +1353,6 @@ export function SessionView({
1298
1353
  setSelectedWorkflowId(runId);
1299
1354
  setTab("workflows");
1300
1355
  }}
1301
- collapsed={collapsed}
1302
1356
  prMarkers={prMarkers}
1303
1357
  coldResumeMarkers={coldResumeMarkers}
1304
1358
  rawIdleBands={rawIdleBands}
@@ -1565,2157 +1619,675 @@ export function SessionView({
1565
1619
  /* Mini-map */
1566
1620
  /* ------------------------------------------------------------------ */
1567
1621
 
1622
+ // ROW_IMPORTANCE, Minimap, MinimapHoverCard moved to ./session-view/minimap.tsx
1623
+ // MinimapSeg moved to ./session-view/types.ts
1624
+ // subagentColor, workflowColor moved to ./session-view/colors.ts
1625
+
1568
1626
  /* ------------------------------------------------------------------ */
1569
- /* Mini-map */
1570
- /* */
1571
- /* Design: */
1572
- /* - Renders ONE segment per presentation row (not per raw event) */
1573
- /* so density matches what the user sees in the transcript. */
1574
- /* - Variable heights by importance: User/Agent/Error/Interrupt */
1575
- /* full height; Tool/Model reduced; idle = full w/ stripes. */
1576
- /* - Minimum block width 5px so single events never disappear. */
1577
- /* - Scroll playhead: thin vertical line tracks the current transcript*/
1578
- /* scroll position inside the <main> container. */
1579
- /* - Time axis ticks below the bar, auto-scaled by total duration. */
1580
- /* - Rich hover card (role pill + preview) instead of bare SVG title. */
1627
+ /* Transcript list + rows */
1581
1628
  /* ------------------------------------------------------------------ */
1582
1629
 
1583
- // MinimapSeg moved to ./session-view/types.ts
1584
-
1585
- /** All rows render at full bar height — distinction comes from color/pattern
1586
- * alone, matching Claude Managed Agents' unified-height timeline. */
1587
- const ROW_IMPORTANCE: Record<PresentationRowKind, number> = {
1588
- user: 1.0,
1589
- agent: 1.0,
1590
- interrupt: 1.0,
1591
- error: 1.0,
1592
- "tool-group": 1.0,
1593
- model: 1.0,
1594
- "task-notification": 1.0,
1595
- };
1596
-
1597
- function Minimap({
1630
+ function TranscriptList({
1598
1631
  displayRows,
1599
- durationMs,
1600
- dayWindow,
1632
+ rowRefs,
1601
1633
  selectedIndex,
1602
1634
  onSelect,
1603
- headerOffset,
1604
- subagents,
1605
- workflows,
1606
- onWorkflowClick,
1607
- collapsed,
1608
- selectedSubagentId,
1609
- onSelectSubagent,
1610
- prMarkers,
1611
- coldResumeMarkers,
1612
- rawIdleBands,
1635
+ onToggleTurn,
1636
+ stickyOffset,
1637
+ isSessionLive,
1638
+ team,
1613
1639
  model,
1614
- onPlayheadChange,
1640
+ rawIdleBands,
1641
+ sessionDays,
1642
+ entryByDay,
1643
+ dayStripRefs,
1644
+ onJumpAcrossDay,
1615
1645
  }: {
1616
1646
  displayRows: DisplayRow[];
1617
- durationMs: number;
1618
- /** When set, scope the timeline to this offset window (one local day).
1619
- * null renders the full session. */
1620
- dayWindow?: { startMs: number; endMs: number } | null;
1647
+ rowRefs: React.MutableRefObject<Record<number, HTMLDivElement | null>>;
1621
1648
  selectedIndex: number | null;
1622
- onSelect: (i: number) => void;
1623
- headerOffset: number;
1624
- subagents?: SubagentRun[];
1625
- workflows?: WorkflowRun[];
1626
- onWorkflowClick?: (runId: string) => void;
1627
- collapsed?: boolean;
1628
- selectedSubagentId?: string | null;
1629
- onSelectSubagent?: (id: string | null) => void;
1630
- prMarkers?: PrMarker[];
1631
- coldResumeMarkers?: {
1632
- tOffsetMs: number;
1633
- info: NonNullable<SessionEvent["coldResume"]>;
1634
- }[];
1635
- /** Idle bands derived directly from raw event timestamps. Used in place
1636
- * of the legacy "gap before user row" heuristic so Codex sessions
1637
- * (which spend most of their time in agent reasoning, not user input)
1638
- * surface their dead-air on the minimap. */
1639
- rawIdleBands?: { start: number; end: number; durationMs: number }[];
1649
+ onSelect: (i: number | null) => void;
1650
+ onToggleTurn: (firstPrimaryIndex: number) => void;
1651
+ stickyOffset: number;
1652
+ isSessionLive?: boolean;
1653
+ team?: (TimelineData & { teamName: string }) | null;
1640
1654
  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;
1655
+ /** Anchor-to-anchor idle bands. The body emits IdleDivider before any
1656
+ * row whose start matches a band's end, so Codex sessions get the same
1657
+ * "Session idle X minutes" markers Claude has always had. */
1658
+ rawIdleBands?: { start: number; end: number; durationMs: number }[];
1659
+ /** Day buckets (sorted by startMs) + per-day digest cards. The body drops a
1660
+ * card at each day's first row so per-day perception sits inline with that
1661
+ * day's work instead of stacked at the top. */
1662
+ sessionDays: { key: string; startMs: number; endMs: number; count: number }[];
1663
+ entryByDay: Map<string, Entry>;
1664
+ dayStripRefs: React.MutableRefObject<Record<string, HTMLDivElement | null>>;
1665
+ /** Hop to an adjacent day from an inline day-boundary button.
1666
+ * (selectKey, scrollKey, block, fallbackRowIndex). */
1667
+ onJumpAcrossDay: (
1668
+ selectKey: string,
1669
+ scrollKey: string,
1670
+ block: "start" | "end",
1671
+ fallbackIndex: number,
1672
+ ) => void;
1645
1673
  }) {
1646
- const WIDTH = 1400;
1647
- /** Main timeline height. Sub-agent lanes stack below this. */
1648
- const MAIN_H = 28;
1649
- /** Per-subagent lane height including the inner gap. */
1650
- const SUB_LANE_H = 11;
1651
- /** Gap between the main timeline and the sub-agent lanes (when present). */
1652
- const SUB_LANE_GAP = 6;
1653
- /** Per-workflow lane height — a touch taller than subagent lanes so the
1654
- * workflow tier reads as distinct. */
1655
- const WF_LANE_H = 13;
1656
- const BAR_TOP = 3;
1657
- const BAR_BOT = MAIN_H - 3;
1658
- const BAR_H = BAR_BOT - BAR_TOP;
1659
- /** Gap subtracted from each segment's width so blocks never touch. */
1660
- const GAP = 3;
1661
- const MIN_BLOCK = 6;
1662
- /** Minimum displayed segment width in SVG units. Every segment gets at
1663
- * least this much space regardless of time duration, so tool-group
1664
- * blocks inside a 24-minute session are still visible + clickable. */
1665
- const MIN_DISPLAY_WIDTH = 12;
1666
- /** Error/interrupt rendered as thin vertical bars capped at this width. */
1667
- const THIN_BAR_MAX = 5;
1668
-
1669
- const [hover, setHover] = useState<{
1670
- clientX: number;
1671
- row?: PresentationRow;
1672
- turn?: TurnMegaRow;
1673
- idleMs?: number;
1674
- subagent?: SubagentRun;
1675
- workflow?: WorkflowRun;
1676
- pr?: PrMarker;
1677
- cold?: {
1678
- tOffsetMs: number;
1679
- info: NonNullable<SessionEvent["coldResume"]>;
1680
- };
1681
- } | null>(null);
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;
1687
- const containerRef = useRef<HTMLDivElement>(null);
1688
-
1689
- /* Playhead — track which transcript row is at the top of the viewport.
1690
- *
1691
- * The old implementation ran `querySelectorAll("[data-sl-row-index]")`
1692
- * on every scroll event. For a session with ~2000 rows that's thousands
1693
- * of DOM queries per second during scroll — enough to pin a CPU core
1694
- * and make Chrome ask to kill the tab. IntersectionObserver is event-
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.
1698
- */
1699
- useEffect(() => {
1700
- const main = containerRef.current?.closest("main") as HTMLElement | null;
1701
- if (!main) return;
1702
-
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>();
1715
-
1716
- const publishTopmost = () => {
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;
1731
- }
1732
- }
1733
- setPlayheadMs(value);
1734
- onPlayheadChangeRef.current?.(value);
1735
- };
1736
-
1737
- const observer = new IntersectionObserver(
1738
- (entries) => {
1739
- for (const e of entries) {
1740
- const el = e.target as HTMLElement;
1741
- if (e.isIntersecting) visibleRows.add(el);
1742
- else visibleRows.delete(el);
1743
- }
1744
- publishTopmost();
1745
- },
1746
- { root: main, rootMargin, threshold: 0 },
1747
- );
1748
-
1749
- // Observe all current rows. Re-observes whenever the row count
1750
- // changes (i.e. filter switch, turn expand/collapse).
1751
- const rows = main.querySelectorAll<HTMLDivElement>("[data-sl-row-index]");
1752
- for (const r of rows) observer.observe(r);
1753
-
1754
- return () => observer.disconnect();
1755
- }, [displayRows.length, headerOffset]);
1756
-
1757
- // Offset window for the rendered timeline. `winEnd` is the absolute offset
1758
- // of the right edge (session/day end); `safeDur` is the window LENGTH used
1759
- // as the proportional-width divisor. For a single-day session (dayWindow
1760
- // null) these collapse to [0, durationMs] — identical to the old behavior.
1761
- const winStart = dayWindow?.startMs ?? 0;
1762
- const winEnd = dayWindow?.endMs ?? durationMs;
1763
- const safeDur = Math.max(winEnd - winStart, 1);
1764
-
1765
- // Subagent/workflow lanes are scoped to the same window as the main
1766
- // timeline so a day view doesn't reserve empty lanes for runs that happened
1767
- // on other days. Overlap test, not containment — a run straddling the
1768
- // window edge still shows.
1769
- const winSubagents = useMemo(
1770
- () =>
1771
- (subagents ?? []).filter(
1772
- (s) =>
1773
- s.startTOffsetMs !== undefined &&
1774
- s.endTOffsetMs !== undefined &&
1775
- s.endTOffsetMs >= winStart &&
1776
- s.startTOffsetMs <= winEnd,
1777
- ),
1778
- [subagents, winStart, winEnd],
1779
- );
1780
- const winWorkflows = useMemo(
1781
- () =>
1782
- (workflows ?? []).filter(
1783
- (w) =>
1784
- w.startTOffsetMs !== undefined &&
1785
- w.startTOffsetMs <= winEnd &&
1786
- (w.endTOffsetMs ?? winEnd) >= winStart,
1787
- ),
1788
- [workflows, winStart, winEnd],
1789
- );
1790
-
1791
- /* Build segments from the same display-row stream the transcript
1792
- uses. Collapsed turns become one wide segment; expanded-turn
1793
- headers are skipped; presentation rows (whether standalone or
1794
- indented children of an expanded turn) become atomic segments. */
1795
- const segs: MinimapSeg[] = useMemo(() => {
1796
- const out: MinimapSeg[] = [];
1797
-
1798
- // Flatten to (item, start) pairs with the expanded-turn headers removed
1799
- // and collapsed turns preserved as single-item entries.
1800
- type WithTime =
1801
- | { kind: "row"; row: PresentationRow; start: number; gapMs: number }
1802
- | {
1803
- kind: "turn";
1804
- turn: TurnMegaRow;
1805
- start: number;
1806
- gapMs: number;
1807
- };
1808
-
1809
- const withTime: WithTime[] = [];
1810
- for (const d of displayRows) {
1811
- if (d.kind === "turn-expanded-header") continue;
1812
- if (d.kind === "turn-expanded-footer") continue;
1813
- if (d.kind === "turn-collapsed") {
1814
- if (d.turn.tOffsetMs === undefined) continue;
1815
- if (d.turn.tOffsetMs < winStart || d.turn.tOffsetMs > winEnd) continue;
1816
- withTime.push({
1817
- kind: "turn",
1818
- turn: d.turn,
1819
- start: d.turn.tOffsetMs,
1820
- gapMs: d.turn.rows[0]?.gapMs ?? 0,
1821
- });
1822
- } else {
1823
- // presentation row
1824
- if (d.row.tOffsetMs === undefined) continue;
1825
- if (d.row.tOffsetMs < winStart || d.row.tOffsetMs > winEnd) continue;
1826
- withTime.push({
1827
- kind: "row",
1828
- row: d.row,
1829
- start: d.row.tOffsetMs,
1830
- gapMs: d.row.gapMs ?? 0,
1831
- });
1832
- }
1833
- }
1834
-
1835
- // Build a sorted copy of idle bands so we can clip row/turn ends to
1836
- // the start of the next idle band (or the next row, whichever comes
1837
- // first). This stops a row's rectangle from spanning across an idle
1838
- // gap and visually swallowing it.
1839
- const idleBands = (rawIdleBands ?? [])
1840
- .filter((b) => b.end > winStart && b.start < winEnd)
1841
- .sort((a, b) => a.start - b.start);
1842
- const nextIdleStartFrom = (t: number): number | undefined => {
1843
- for (const b of idleBands) {
1844
- if (b.start >= t) return b.start;
1845
- }
1846
- return undefined;
1847
- };
1848
-
1849
- for (let i = 0; i < withTime.length; i++) {
1850
- const item = withTime[i];
1851
- const next = withTime[i + 1];
1852
- const start = item.start;
1853
- const nextRowStart = next?.start;
1854
- const nextIdleStart = nextIdleStartFrom(start + 1);
1855
- // Cap end to whichever comes first: next visible row, next idle band,
1856
- // or the window's right edge (so a day's last segment doesn't bleed
1857
- // past the scoped window into the next day).
1858
- const cap = (raw: number) =>
1859
- Math.min(
1860
- raw,
1861
- nextRowStart ?? Number.POSITIVE_INFINITY,
1862
- nextIdleStart ?? Number.POSITIVE_INFINITY,
1863
- winEnd,
1864
- );
1865
-
1866
- if (item.kind === "turn") {
1867
- const turnEnd = start + (item.turn.durationMs ?? 0);
1868
- out.push({
1869
- kind: "turn",
1870
- turn: item.turn,
1871
- primaryIndex: item.turn.firstPrimaryIndex,
1872
- start,
1873
- end: Math.max(cap(turnEnd), start + 1),
1874
- });
1875
- } else {
1876
- const end = cap(nextRowStart ?? start + 800);
1877
- out.push({
1878
- kind: "row",
1879
- row: item.row,
1880
- primaryIndex: rowPrimaryIndex(item.row),
1881
- start,
1882
- end: Math.max(end, start + 1),
1883
- });
1674
+ // Find the last collapsed turn index so we can mark it as in-progress
1675
+ // when the session is live.
1676
+ let lastCollapsedTurnIdx = -1;
1677
+ if (isSessionLive) {
1678
+ for (let i = displayRows.length - 1; i >= 0; i--) {
1679
+ if (displayRows[i].kind === "turn-collapsed") {
1680
+ lastCollapsedTurnIdx = i;
1681
+ break;
1884
1682
  }
1885
1683
  }
1684
+ }
1886
1685
 
1887
- // Idle bands are computed from raw event timestamps (5s threshold) up
1888
- // in the parent — append them and let the sort below interleave by
1889
- // start time so they slot between rows correctly.
1890
- for (const band of idleBands) {
1891
- out.push({
1892
- kind: "idle",
1893
- start: band.start,
1894
- end: band.end,
1895
- durationMs: band.durationMs,
1896
- });
1897
- }
1898
- out.sort((a, b) => a.start - b.start);
1899
-
1900
- // Cap the number of rendered segments. Each <rect> carries two
1901
- // event handlers (onMouseEnter + onClick), so 2000 raw-events in
1902
- // "All events" mode becomes 4000 DOM event listeners — enough to
1903
- // stall Chrome during paint. Sampling to MAX contiguous buckets
1904
- // loses per-item hover precision but keeps the overall shape and
1905
- // click-to-navigate behavior (clicks map to the first row inside
1906
- // the bucket, which is the right anchor for "jump here" UX).
1907
- const MAX_SEGMENTS = 600;
1908
- if (out.length <= MAX_SEGMENTS) return out;
1909
-
1910
- const step = Math.ceil(out.length / MAX_SEGMENTS);
1911
- const bucketed: MinimapSeg[] = [];
1912
- for (let i = 0; i < out.length; i += step) {
1913
- const first = out[i]!;
1914
- const last = out[Math.min(i + step - 1, out.length - 1)]!;
1915
- // Merge bucket: keep the first seg's kind + primaryIndex (so clicks
1916
- // jump to the first item of the bucket), but extend the end range
1917
- // to cover the whole bucket's time span.
1918
- if (first.kind === "idle") {
1919
- bucketed.push({
1920
- kind: "idle",
1921
- start: first.start,
1922
- end: last.end,
1923
- durationMs: last.end - first.start,
1924
- });
1925
- } else if (first.kind === "turn") {
1926
- bucketed.push({
1927
- kind: "turn",
1928
- turn: first.turn,
1929
- primaryIndex: first.primaryIndex,
1930
- start: first.start,
1931
- end: last.end,
1932
- });
1933
- } else {
1934
- bucketed.push({
1935
- kind: "row",
1936
- row: first.row,
1937
- primaryIndex: first.primaryIndex,
1938
- start: first.start,
1939
- end: last.end,
1940
- });
1941
- }
1942
- }
1943
- return bucketed;
1944
- }, [displayRows, safeDur, rawIdleBands, winStart, winEnd]);
1945
-
1946
- /* Sequential layout with a minimum displayed width per segment.
1947
- - Raw proportional width = (seg.end - seg.start) / safeDur * WIDTH
1948
- - Enforced width = max(raw, MIN_DISPLAY_WIDTH)
1949
- - x positions are cumulative (not time-proportional), so tiny events
1950
- stay visible next to a 24-minute turn.
1951
- - If the cumulative width exceeds WIDTH, proportionally shrink so it
1952
- all fits. Time ordering is preserved; exact time-to-pixel mapping
1953
- is sacrificed. */
1954
- const positions: { x: number; w: number }[] = useMemo(() => {
1955
- if (segs.length === 0) return [];
1956
- const raws = segs.map((s) => ((s.end - s.start) / safeDur) * WIDTH);
1957
- const enforced = raws.map((w) => Math.max(w, MIN_DISPLAY_WIDTH));
1958
- const total = enforced.reduce((a, b) => a + b, 0);
1959
- const scale = WIDTH / total;
1960
- const out: { x: number; w: number }[] = [];
1961
- let cursor = 0;
1962
- for (const w of enforced) {
1963
- const scaled = w * scale;
1964
- out.push({ x: cursor, w: scaled });
1965
- cursor += scaled;
1686
+ const idleBandList = (rawIdleBands ?? []).slice().sort((a, b) => a.start - b.start);
1687
+ /** Returns the first matching idle band (or null) whose end falls within
1688
+ * 1s of the given row's tOffset. Tolerance absorbs Date.parse rounding
1689
+ * between anchor.ms and the presentation row's tOffsetMs. */
1690
+ const idleBandBeforeOffset = (
1691
+ tOff: number | undefined,
1692
+ ): { start: number; durationMs: number } | null => {
1693
+ if (tOff === undefined) return null;
1694
+ for (const b of idleBandList) {
1695
+ if (Math.abs(b.end - tOff) < 1000) return b;
1966
1696
  }
1967
- return out;
1968
- }, [segs, safeDur]);
1969
-
1970
- /** Map a time offset (ms) to an x coordinate in the relaxed layout.
1971
- * Walks segments sequentially and interpolates within whichever one
1972
- * contains the target ms. Used for the playhead indicator. */
1973
- const msToX = (ms: number): number => {
1974
- if (segs.length === 0) return 0;
1975
- if (ms <= segs[0].start) return positions[0]?.x ?? 0;
1976
- for (let i = 0; i < segs.length; i++) {
1977
- const s = segs[i];
1978
- const p = positions[i];
1979
- if (!p) continue;
1980
- if (ms < s.start) return p.x;
1981
- if (ms <= s.end) {
1982
- const segDur = s.end - s.start;
1983
- const frac = segDur > 0 ? (ms - s.start) / segDur : 0;
1984
- return p.x + frac * p.w;
1985
- }
1697
+ return null;
1698
+ };
1699
+ /** Pull a t-offset off whichever display-row variant we have. */
1700
+ const rowTOffset = (d: DisplayRow): number | undefined => {
1701
+ if (d.kind === "turn-collapsed" || d.kind === "turn-expanded-header") return d.turn.tOffsetMs;
1702
+ if (d.kind === "turn-expanded-footer") return undefined;
1703
+ return d.row.tOffsetMs;
1704
+ };
1705
+ /** Absolute ISO timestamp of a row's primary event — used to date-stamp an
1706
+ * idle gap that resumes on a later day. */
1707
+ const rowTimestamp = (d: DisplayRow): string | undefined => {
1708
+ const r =
1709
+ d.kind === "presentation"
1710
+ ? d.row
1711
+ : d.kind === "turn-collapsed" || d.kind === "turn-expanded-header"
1712
+ ? d.turn.rows[0]
1713
+ : undefined;
1714
+ if (!r) return undefined;
1715
+ return r.kind === "tool-group" ? r.events[0]?.timestamp : r.event?.timestamp;
1716
+ };
1717
+ // A display row → its event PRIMARY index (what jumpAcrossDay's turn/row
1718
+ // lookups are keyed by — NOT the displayRows loop position).
1719
+ const rowPrimaryIndexOf = (d: DisplayRow): number =>
1720
+ d.kind === "presentation" ? rowPrimaryIndex(d.row) : d.turn.firstPrimaryIndex;
1721
+ /** Which local day a row falls in: the last day bucket whose start is at or
1722
+ * before the row's offset. sessionDays is pre-sorted by startMs. */
1723
+ const dayKeyForOffset = (tOff: number | undefined): string | null => {
1724
+ if (tOff === undefined) return null;
1725
+ let key: string | null = null;
1726
+ for (const dd of sessionDays) {
1727
+ if (dd.startMs <= tOff) key = dd.key;
1728
+ else break;
1986
1729
  }
1987
- const last = positions[positions.length - 1];
1988
- return last ? last.x + last.w : WIDTH;
1730
+ return key;
1989
1731
  };
1990
1732
 
1991
- // ---- Sub-agent lane assignment -------------------------------------
1992
- // Place each subagent in the lowest lane index where it doesn't
1993
- // collide with the most recent bar in that lane. Greedy left→right
1994
- // sweep — O(N×L) where L is the number of lanes (small in practice).
1995
- // Returns a Map agentId → laneIndex plus the total lane count.
1996
- const { laneOf, laneCount } = useMemo(() => {
1997
- if (winSubagents.length === 0) {
1998
- return { laneOf: new Map<string, number>(), laneCount: 0 };
1999
- }
2000
- const sorted = [...winSubagents].sort(
2001
- (a, b) => (a.startTOffsetMs ?? 0) - (b.startTOffsetMs ?? 0),
2002
- );
2003
- const laneEnds: number[] = [];
2004
- const laneOf = new Map<string, number>();
2005
- for (const s of sorted) {
2006
- const start = s.startTOffsetMs ?? 0;
2007
- const end = s.endTOffsetMs ?? start + 1;
2008
- let assigned = -1;
2009
- for (let i = 0; i < laneEnds.length; i++) {
2010
- if (laneEnds[i]! <= start) {
2011
- assigned = i;
2012
- break;
2013
- }
2014
- }
2015
- if (assigned === -1) {
2016
- assigned = laneEnds.length;
2017
- laneEnds.push(0);
2018
- }
2019
- laneEnds[assigned] = end;
2020
- laneOf.set(s.agentId, assigned);
2021
- }
2022
- return { laneOf, laneCount: laneEnds.length };
2023
- }, [winSubagents]);
2024
-
2025
- const SUB_BLOCK_TOP = MAIN_H + SUB_LANE_GAP;
2026
- const SUB_LANES_H = laneCount > 0 ? laneCount * SUB_LANE_H : 0;
2027
-
2028
- // ---- Workflow lane assignment --------------------------------------
2029
- // Same greedy sweep as subagents. Workflow runs are mostly sequential
2030
- // milestones, so this usually collapses to one lane — but overlap is
2031
- // handled if two runs ever ran concurrently.
2032
- const { wfLaneOf, wfLaneCount } = useMemo(() => {
2033
- // Only place workflows we can actually draw (startTOffsetMs known). A
2034
- // running run has no end yet — it extends to the session end (safeDur),
2035
- // matching the open-ended bar the render draws. Counting only placeable
2036
- // runs keeps wfLaneCount in lockstep with what renders, so no empty
2037
- // reserved band appears.
2038
- const placeable = winWorkflows;
2039
- if (placeable.length === 0) {
2040
- return { wfLaneOf: new Map<string, number>(), wfLaneCount: 0 };
2041
- }
2042
- const sorted = [...placeable].sort(
2043
- (a, b) => (a.startTOffsetMs ?? 0) - (b.startTOffsetMs ?? 0),
2044
- );
2045
- const laneEnds: number[] = [];
2046
- const wfLaneOf = new Map<string, number>();
2047
- for (const w of sorted) {
2048
- const start = w.startTOffsetMs ?? 0;
2049
- const end = w.endTOffsetMs ?? winEnd;
2050
- let assigned = -1;
2051
- for (let i = 0; i < laneEnds.length; i++) {
2052
- if (laneEnds[i]! <= start) {
2053
- assigned = i;
2054
- break;
2055
- }
2056
- }
2057
- if (assigned === -1) {
2058
- assigned = laneEnds.length;
2059
- laneEnds.push(0);
1733
+ const out: React.ReactNode[] = [];
1734
+ // Tracks the day of the last emitted row so a day's digest card is dropped
1735
+ // once, before that day's first non-indented row.
1736
+ let currentDayKey: string | null = null;
1737
+ // A user row and the turn-collapsed row that follows it share a
1738
+ // tOffsetMs (turn anchors at the user message — see buildMegaRows), so
1739
+ // both would match the same between-turn band without this guard. Track
1740
+ // emitted band starts and skip duplicates.
1741
+ const emittedBandStarts = new Set<number>();
1742
+ for (let i = 0; i < displayRows.length; i++) {
1743
+ const d = displayRows[i];
1744
+ // Emit a "Session idle" divider before any row that starts right after
1745
+ // an anchor-to-anchor idle band. Skips indented (child) rows so an
1746
+ // expanded turn doesn't render dividers for its inner steps.
1747
+ const indented = d.kind === "presentation" ? d.indented : false;
1748
+ if (!indented) {
1749
+ const band = idleBandBeforeOffset(rowTOffset(d));
1750
+ // Set when this boundary's idle divider already states the new day + time,
1751
+ // so the "Start of <new day>" marker below can be dropped as a redundant
1752
+ // restatement.
1753
+ let resumeStamped = false;
1754
+ if (
1755
+ band !== null &&
1756
+ band.durationMs > IDLE_THRESHOLD_MS &&
1757
+ !emittedBandStarts.has(band.start)
1758
+ ) {
1759
+ emittedBandStarts.add(band.start);
1760
+ // When the gap resumes on a LATER day, "1d" is vaguer than the actual
1761
+ // resume moment — show that instead. Same day-key trigger as the day
1762
+ // boundary marker emitted just below, so the two agree. Sub-day gaps
1763
+ // keep the duration (the pause length is the useful signal there).
1764
+ const resumeDayKey = dayKeyForOffset(rowTOffset(d));
1765
+ const resumeTs =
1766
+ currentDayKey !== null && resumeDayKey !== null && resumeDayKey !== currentDayKey
1767
+ ? rowTimestamp(d)
1768
+ : undefined;
1769
+ resumeStamped = resumeTs !== undefined;
1770
+ out.push(
1771
+ <IdleDivider
1772
+ key={`idle-before-${i}`}
1773
+ gapMs={band.durationMs}
1774
+ resumeStamp={resumeTs ? formatResumeStamp(resumeTs) : undefined}
1775
+ />,
1776
+ );
2060
1777
  }
2061
- laneEnds[assigned] = Math.max(end, start + 1);
2062
- wfLaneOf.set(w.runId, assigned);
2063
- }
2064
- return { wfLaneOf, wfLaneCount: laneEnds.length };
2065
- }, [winWorkflows, winEnd]);
2066
-
2067
- const SUB_BLOCK_BOTTOM = MAIN_H + (laneCount > 0 ? SUB_LANE_GAP + SUB_LANES_H : 0);
2068
- const WF_BLOCK_TOP = SUB_BLOCK_BOTTOM + (wfLaneCount > 0 ? SUB_LANE_GAP : 0);
2069
- const WF_LANES_H = wfLaneCount > 0 ? wfLaneCount * WF_LANE_H : 0;
2070
- const TOTAL_H = SUB_BLOCK_BOTTOM + (wfLaneCount > 0 ? SUB_LANE_GAP + WF_LANES_H : 0);
2071
-
2072
- return (
2073
- <div
2074
- ref={containerRef}
2075
- style={{
2076
- padding: "8px 10px",
2077
- position: "relative",
2078
- background: "var(--af-surface)",
2079
- border: "1px solid var(--af-border-subtle)",
2080
- borderRadius: 8,
2081
- marginTop: 2,
2082
- }}
2083
- onMouseLeave={() => setHover(null)}
2084
- >
2085
- <svg
2086
- viewBox={`0 0 ${WIDTH} ${TOTAL_H}`}
2087
- preserveAspectRatio="none"
2088
- style={{
2089
- width: "100%",
2090
- height: TOTAL_H,
2091
- display: "block",
2092
- overflow: "visible",
2093
- }}
2094
- >
2095
- <defs>
2096
- <pattern
2097
- id="stripes"
2098
- patternUnits="userSpaceOnUse"
2099
- width="6"
2100
- height="6"
2101
- patternTransform="rotate(45)"
2102
- >
2103
- <rect width="6" height="6" fill="rgba(120, 115, 108, 0.04)" />
2104
- <line x1="0" y1="0" x2="0" y2="6" stroke="rgba(120, 115, 108, 0.28)" strokeWidth="2" />
2105
- </pattern>
2106
- </defs>
2107
-
2108
- {/* Segments — x/w come from the relaxed sequential layout, NOT
2109
- from raw time proportions. See the positions memo above. */}
2110
- {segs.map((seg, i) => {
2111
- const pos = positions[i];
2112
- if (!pos) return null;
2113
- const xRaw = pos.x;
2114
- const wRaw = pos.w;
2115
-
2116
- if (seg.kind === "idle") {
2117
- // Idle block: fill its span minus a gap on each side, with a
2118
- // subtle border so the diagonal stripes read as a distinct block.
2119
- const w = Math.max(wRaw - GAP, MIN_BLOCK);
2120
- return (
2121
- <rect
2122
- key={`idle-${i}`}
2123
- x={xRaw + GAP / 2}
2124
- y={BAR_TOP + 1}
2125
- width={w}
2126
- height={BAR_H - 2}
2127
- fill="url(#stripes)"
2128
- stroke="rgba(120, 115, 108, 0.35)"
2129
- strokeWidth="0.6"
2130
- rx="3"
2131
- onMouseEnter={(e) =>
2132
- setHover({
2133
- clientX: e.clientX,
2134
- idleMs: seg.durationMs,
2135
- })
2136
- }
2137
- />
2138
- );
2139
- }
2140
-
2141
- // Resolve the theme + selection state. Turn segments use the
2142
- // "agent" theme (a turn is the agent's work wrapped as one unit).
2143
- const rowKind: PresentationRowKind = seg.kind === "turn" ? "agent" : seg.row.kind;
2144
- const theme = ROLE_THEMES[rowKind];
2145
- const importance = ROW_IMPORTANCE[rowKind];
2146
- const h = BAR_H * importance;
2147
- const y = BAR_TOP + (BAR_H - h) / 2;
2148
- const isSelected = selectedIndex === seg.primaryIndex;
2149
-
2150
- // Error/interrupt: render as a THIN vertical bar regardless of
2151
- // actual span. Consecutive errors become a visible comb of
2152
- // narrow strokes separated by gaps, matching Claude's UI.
2153
- const isThin =
2154
- seg.kind === "row" && (seg.row.kind === "error" || seg.row.kind === "interrupt");
2155
- let w: number;
2156
- let x: number;
2157
- if (isThin) {
2158
- w = Math.min(Math.max(wRaw - GAP, 2), THIN_BAR_MAX);
2159
- x = xRaw + Math.max((wRaw - w) / 2, GAP / 2);
2160
- } else {
2161
- w = Math.max(wRaw - GAP, MIN_BLOCK);
2162
- x = xRaw + GAP / 2;
2163
- }
2164
-
2165
- const ringPad = 2.5;
2166
- const onHover = (e: React.MouseEvent) =>
2167
- seg.kind === "turn"
2168
- ? setHover({ clientX: e.clientX, turn: seg.turn })
2169
- : setHover({ clientX: e.clientX, row: seg.row });
2170
- return (
2171
- <g key={`seg-${seg.primaryIndex}-${i}`}>
2172
- <rect
2173
- x={x}
2174
- y={y}
2175
- width={w}
2176
- height={h}
2177
- fill={theme.mini}
2178
- rx="3"
2179
- style={{ cursor: "pointer" }}
2180
- onClick={() => onSelect(seg.primaryIndex)}
2181
- onMouseEnter={onHover}
2182
- />
2183
- {isSelected && (
2184
- <rect
2185
- x={x - ringPad}
2186
- y={y - ringPad}
2187
- width={w + ringPad * 2}
2188
- height={h + ringPad * 2}
2189
- fill="none"
2190
- stroke="#5C84C3"
2191
- strokeWidth="2"
2192
- rx="5"
2193
- pointerEvents="none"
2194
- />
2195
- )}
2196
- </g>
1778
+ // Day boundary: drop this day's digest card at its first row (after any
1779
+ // overnight idle divider that led into the day) so per-day perception
1780
+ // sits inline with that day's work, not stacked at the top. The boundary
1781
+ // also gets inline jump controls so you can page across days while
1782
+ // reading — "↓ start of next day" sits at the bottom of the day that just
1783
+ // ended, "↑ end of previous day" at the top of the new one.
1784
+ const dk = dayKeyForOffset(rowTOffset(d));
1785
+ if (dk && dk !== currentDayKey) {
1786
+ const endedKey = currentDayKey; // day that just ended; null on the first day
1787
+ currentDayKey = dk;
1788
+ // Fallback scroll target for days with no digest card: the day's first
1789
+ // row by PRIMARY index (i is the displayRows position — wrong key space).
1790
+ const firstIdx = rowPrimaryIndexOf(d);
1791
+ // Skip the "Start of <new day>" marker when the resume divider above
1792
+ // already names this day + time — it would just restate it. With no
1793
+ // resume divider (continuous or sub-threshold crossing) the marker is
1794
+ // the only thing labeling the boundary, so it stays.
1795
+ if (endedKey && !resumeStamped) {
1796
+ out.push(
1797
+ <DayJumpRow
1798
+ key={`daydown-${dk}`}
1799
+ dir="down"
1800
+ label={formatDayKey(dk)}
1801
+ onClick={() => onJumpAcrossDay(dk, dk, "start", firstIdx)}
1802
+ />,
2197
1803
  );
2198
- })}
2199
-
2200
- {/* Sub-agent lanes — one row per lane, colored by agentType.
2201
- Background-mode runs are highlighted with a brighter fill +
2202
- extra dashed outline so you can spot true parallelism at a
2203
- glance. Bars use the same msToX mapping as the main timeline
2204
- so the subagent's start/end aligns vertically with whatever
2205
- was happening in the main session at that time. */}
2206
- {laneCount > 0 && winSubagents.length > 0 && (
2207
- <g>
2208
- {/* Faint divider line above the subagent lane block */}
2209
- <line
2210
- x1={0}
2211
- x2={WIDTH}
2212
- y1={MAIN_H + SUB_LANE_GAP / 2}
2213
- y2={MAIN_H + SUB_LANE_GAP / 2}
2214
- stroke="var(--af-border-subtle)"
2215
- strokeWidth="0.6"
2216
- strokeDasharray="2 4"
2217
- />
2218
- {winSubagents.map((s) => {
2219
- const lane = laneOf.get(s.agentId) ?? 0;
2220
- if (s.startTOffsetMs === undefined || s.endTOffsetMs === undefined) return null;
2221
- const startX = msToX(s.startTOffsetMs);
2222
- const endX = msToX(s.endTOffsetMs);
2223
- // Minimum 8px so a 78-second subagent in a 16-hour session is
2224
- // still wide enough to read + hit-test. Long subagents render
2225
- // at their actual proportional width.
2226
- const w = Math.max(endX - startX, 8);
2227
- const y = SUB_BLOCK_TOP + lane * SUB_LANE_H;
2228
- const h = SUB_LANE_H - 2;
2229
- const fill = subagentColor(s.agentType, s.runInBackground);
2230
- const isSelected = selectedSubagentId === s.agentId;
2231
- return (
2232
- <g key={s.agentId}>
2233
- <rect
2234
- x={startX}
2235
- y={y}
2236
- width={w}
2237
- height={h}
2238
- fill={fill}
2239
- stroke={s.runInBackground ? "#fff" : "transparent"}
2240
- strokeWidth={s.runInBackground ? 0.5 : 0}
2241
- strokeDasharray={s.runInBackground ? "1.5 1.5" : undefined}
2242
- rx="2"
2243
- style={{ cursor: "pointer" }}
2244
- onMouseEnter={(e) => setHover({ clientX: e.clientX, subagent: s })}
2245
- onClick={() => onSelectSubagent?.(isSelected ? null : s.agentId)}
2246
- />
2247
- {isSelected && (
2248
- <rect
2249
- x={startX - 2}
2250
- y={y - 2}
2251
- width={w + 4}
2252
- height={h + 4}
2253
- fill="none"
2254
- stroke="var(--af-accent)"
2255
- strokeWidth="1.5"
2256
- rx="4"
2257
- pointerEvents="none"
2258
- />
2259
- )}
2260
- </g>
2261
- );
2262
- })}
2263
- </g>
2264
- )}
2265
-
2266
- {/* Workflow lanes — one bar per dynamic-workflow run, on the same
2267
- time axis as the main timeline, colored by status (orange tier,
2268
- distinct from the blue/purple subagents). The agent count is the
2269
- headline — a single Workflow tool call hides a whole fleet. */}
2270
- {wfLaneCount > 0 && winWorkflows.length > 0 && (
2271
- <g>
2272
- <line
2273
- x1={0}
2274
- x2={WIDTH}
2275
- y1={WF_BLOCK_TOP - SUB_LANE_GAP / 2}
2276
- y2={WF_BLOCK_TOP - SUB_LANE_GAP / 2}
2277
- stroke="var(--af-border-subtle)"
2278
- strokeWidth="0.6"
2279
- strokeDasharray="2 4"
2280
- />
2281
- {winWorkflows.map((w) => {
2282
- if (w.startTOffsetMs === undefined) return null;
2283
- const lane = wfLaneOf.get(w.runId) ?? 0;
2284
- const startX = msToX(w.startTOffsetMs);
2285
- // A still-running run has no endTOffsetMs — draw it open-ended to
2286
- // the session end so it's visible (and dashed, to read as ongoing)
2287
- // rather than vanishing while still occupying a reserved lane.
2288
- const inProgress = w.endTOffsetMs === undefined;
2289
- const endX = msToX(w.endTOffsetMs ?? winEnd);
2290
- const wWidth = Math.max(endX - startX, 8);
2291
- const y = WF_BLOCK_TOP + lane * WF_LANE_H;
2292
- const h = WF_LANE_H - 2;
2293
- const fill = workflowColor(w.status);
2294
- // Center the agent count label inside the bar when there's room.
2295
- const label = `${w.agentCount}`;
2296
- const showLabel = wWidth > 22;
2297
- return (
2298
- <g key={w.runId} style={{ cursor: "pointer" }}>
2299
- <rect
2300
- x={startX}
2301
- y={y}
2302
- width={wWidth}
2303
- height={h}
2304
- fill={fill}
2305
- fillOpacity={inProgress ? 0.55 : 1}
2306
- stroke={inProgress ? fill : "transparent"}
2307
- strokeWidth={inProgress ? 0.8 : 0}
2308
- strokeDasharray={inProgress ? "2 2" : undefined}
2309
- rx="2"
2310
- onMouseEnter={(e) => setHover({ clientX: e.clientX, workflow: w })}
2311
- onClick={() => onWorkflowClick?.(w.runId)}
2312
- />
2313
- {showLabel && (
2314
- <text
2315
- x={startX + wWidth / 2}
2316
- y={y + h / 2 + 3}
2317
- textAnchor="middle"
2318
- fontSize="8.5"
2319
- fontWeight="700"
2320
- fill="#fff"
2321
- pointerEvents="none"
2322
- style={{ fontFamily: "var(--font-mono)" }}
2323
- >
2324
- {label}
2325
- </text>
2326
- )}
2327
- </g>
2328
- );
2329
- })}
2330
- </g>
2331
- )}
2332
-
2333
- {/* PR markers — diamond + vertical line at each `gh pr create` */}
2334
- {prMarkers?.map((pr, i) => {
2335
- if (pr.tOffsetMs === undefined) return null;
2336
- if (pr.tOffsetMs < winStart || pr.tOffsetMs > winEnd) return null;
2337
- const x = msToX(pr.tOffsetMs);
2338
- return (
2339
- <g
2340
- key={`pr-${i}`}
2341
- style={{ cursor: "pointer" }}
2342
- onMouseEnter={(e) => setHover({ clientX: e.clientX, pr })}
1804
+ }
1805
+ const dayEntry = entryByDay.get(dk);
1806
+ if (dayEntry) {
1807
+ out.push(
1808
+ <div
1809
+ key={`daystrip-${dk}`}
1810
+ ref={(el) => {
1811
+ dayStripRefs.current[dk] = el;
1812
+ }}
1813
+ style={{ marginBottom: 12, scrollMarginTop: stickyOffset }}
2343
1814
  >
2344
- <line
2345
- x1={x}
2346
- x2={x}
2347
- y1={0}
2348
- y2={TOTAL_H}
2349
- stroke="#8b5cf6"
2350
- strokeWidth="1.5"
2351
- strokeDasharray="4 3"
2352
- opacity="0.7"
2353
- />
2354
- {/* Invisible wider hit area for easier hovering */}
2355
- <rect
2356
- x={x - 8}
2357
- y={0}
2358
- width={16}
2359
- height={TOTAL_H}
2360
- fill="transparent"
2361
- />
2362
- {/* Diamond marker at mid-height */}
2363
- <polygon
2364
- points={`${x},${MAIN_H / 2 - 5} ${x + 5},${MAIN_H / 2} ${x},${MAIN_H / 2 + 5} ${x - 5},${MAIN_H / 2}`}
2365
- fill="#8b5cf6"
2366
- stroke="var(--af-surface)"
2367
- strokeWidth="1.5"
2368
- />
2369
- </g>
1815
+ <EntryDayStrip entry={dayEntry} />
1816
+ </div>,
2370
1817
  );
2371
- })}
2372
-
2373
- {coldResumeMarkers?.map((cr, i) => {
2374
- if (cr.tOffsetMs < winStart || cr.tOffsetMs > winEnd) return null;
2375
- const x = msToX(cr.tOffsetMs);
2376
- return (
2377
- <g
2378
- key={`cold-${i}`}
2379
- style={{ cursor: "pointer" }}
2380
- onMouseEnter={(e) => setHover({ clientX: e.clientX, cold: cr })}
2381
- >
2382
- <line
2383
- x1={x}
2384
- x2={x}
2385
- y1={0}
2386
- y2={TOTAL_H}
2387
- stroke="#D97706"
2388
- strokeWidth="1.5"
2389
- strokeDasharray="4 3"
2390
- opacity="0.75"
2391
- pointerEvents="none"
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. */}
2397
- <rect
2398
- x={x - 6}
2399
- y={MAIN_H / 2 - 8}
2400
- width={12}
2401
- height={16}
2402
- fill="transparent"
2403
- />
2404
- <polygon
2405
- points={`${x},${MAIN_H / 2 - 5} ${x + 5},${MAIN_H / 2} ${x},${MAIN_H / 2 + 5} ${x - 5},${MAIN_H / 2}`}
2406
- fill="#D97706"
2407
- stroke="var(--af-surface)"
2408
- strokeWidth="1.5"
2409
- pointerEvents="none"
2410
- />
2411
- </g>
1818
+ }
1819
+ if (endedKey) {
1820
+ out.push(
1821
+ <DayJumpRow
1822
+ key={`dayup-${dk}`}
1823
+ dir="up"
1824
+ label={formatDayKey(endedKey)}
1825
+ onClick={() => onJumpAcrossDay(endedKey, dk, "end", firstIdx)}
1826
+ />,
2412
1827
  );
2413
- })}
2414
-
2415
- {/* Playhead — positioned against the relaxed layout, not raw time.
2416
- Spans the full SVG height (main + subagent lanes) so you can see
2417
- which subagents were running at the current scroll position. */}
2418
- {playheadMs !== null && (
2419
- <line
2420
- x1={msToX(playheadMs)}
2421
- x2={msToX(playheadMs)}
2422
- y1={0}
2423
- y2={TOTAL_H}
2424
- stroke="#0F172A"
2425
- strokeWidth="1.25"
2426
- strokeDasharray="2 2"
2427
- opacity="0.65"
2428
- />
2429
- )}
2430
- </svg>
2431
-
2432
- {/* Hover card */}
2433
- {hover && (
2434
- <MinimapHoverCard
2435
- containerRef={containerRef}
2436
- clientX={hover.clientX}
2437
- row={hover.row}
2438
- turn={hover.turn}
2439
- idleMs={hover.idleMs}
2440
- subagent={hover.subagent}
2441
- workflow={hover.workflow}
2442
- pr={hover.pr}
2443
- cold={hover.cold}
2444
- model={model}
2445
- />
2446
- )}
1828
+ }
1829
+ }
1830
+ }
2447
1831
 
2448
- {/* Sub-agent legend strip — only when there are subagent lanes. */}
2449
- {laneCount > 0 && subagents && (
2450
- <div
2451
- style={{
2452
- display: "flex",
2453
- alignItems: "center",
2454
- gap: 10,
2455
- marginTop: 6,
2456
- fontSize: 10,
2457
- color: "var(--af-text-tertiary)",
1832
+ // Collapsed turn summary row
1833
+ if (d.kind === "turn-collapsed") {
1834
+ const idx = d.turn.firstPrimaryIndex;
1835
+ const turnCold = findColdResumeInDisplayRow(d);
1836
+ out.push(
1837
+ <CollapsedTurnRow
1838
+ key={`turn-${idx}`}
1839
+ turn={d.turn}
1840
+ stickyOffset={stickyOffset}
1841
+ onClick={() => onToggleTurn(idx)}
1842
+ inProgress={i === lastCollapsedTurnIdx}
1843
+ team={team}
1844
+ coldResume={turnCold ?? undefined}
1845
+ model={model}
1846
+ refCb={(el) => {
1847
+ rowRefs.current[idx] = el;
2458
1848
  }}
2459
- >
2460
- <span style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>
2461
- Sub-agents
2462
- </span>
2463
- <span>·</span>
2464
- <span>
2465
- {subagents.length} run{subagents.length === 1 ? "" : "s"}
2466
- </span>
2467
- <span style={{ marginLeft: 8, display: "flex", gap: 8, flexWrap: "wrap" }}>
2468
- {Array.from(new Set(subagents.map((s) => s.agentType))).map((type) => (
2469
- <span
2470
- key={type}
2471
- style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
2472
- >
2473
- <span
2474
- style={{
2475
- display: "inline-block",
2476
- width: 8,
2477
- height: 8,
2478
- borderRadius: 2,
2479
- background: subagentColor(type, false),
2480
- }}
2481
- />
2482
- {type}
2483
- </span>
2484
- ))}
2485
- {subagents.some((s) => s.runInBackground) && (
2486
- <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
2487
- <span
2488
- style={{
2489
- display: "inline-block",
2490
- width: 8,
2491
- height: 8,
2492
- borderRadius: 2,
2493
- background: "transparent",
2494
- border: "1px dashed var(--af-text-secondary)",
2495
- }}
2496
- />
2497
- background
2498
- </span>
2499
- )}
2500
- </span>
2501
- </div>
2502
- )}
1849
+ />,
1850
+ );
1851
+ continue;
1852
+ }
1853
+
1854
+ // Expanded turn header
1855
+ if (d.kind === "turn-expanded-header") {
1856
+ const turnCold = findColdResumeInDisplayRow(d);
1857
+ out.push(
1858
+ <ExpandedTurnHeader
1859
+ key={`turnhead-${d.turn.firstPrimaryIndex}`}
1860
+ turn={d.turn}
1861
+ onClick={() => onToggleTurn(d.turn.firstPrimaryIndex)}
1862
+ coldResume={turnCold ?? undefined}
1863
+ model={model}
1864
+ />,
1865
+ );
1866
+ continue;
1867
+ }
1868
+
1869
+ // Expanded turn footer — matching collapse control at the bottom of
1870
+ // the expanded rows so the user can collapse right where their eye
1871
+ // lands after reading through the content.
1872
+ if (d.kind === "turn-expanded-footer") {
1873
+ out.push(
1874
+ <ExpandedTurnFooter
1875
+ key={`turnfoot-${d.turn.firstPrimaryIndex}`}
1876
+ turn={d.turn}
1877
+ onClick={() => onToggleTurn(d.turn.firstPrimaryIndex)}
1878
+ />,
1879
+ );
1880
+ continue;
1881
+ }
1882
+
1883
+ // Normal presentation row (possibly indented as a child of an expanded
1884
+ // turn). Idle dividers are now emitted at the top of the loop from the
1885
+ // shared rawIdleBands list — no separate gap-before-user check needed.
1886
+ const r = d.row;
1887
+ const idx = rowPrimaryIndex(r);
1888
+ out.push(
1889
+ <TranscriptRow
1890
+ key={`row-${idx}`}
1891
+ row={r}
1892
+ selected={selectedIndex === idx}
1893
+ onSelect={() => onSelect(idx)}
1894
+ stickyOffset={stickyOffset}
1895
+ indented={d.indented}
1896
+ refCb={(el) => {
1897
+ rowRefs.current[idx] = el;
1898
+ }}
1899
+ />,
1900
+ );
1901
+ }
1902
+ return <div>{out}</div>;
1903
+ }
1904
+
1905
+ function IdleDivider({ gapMs, resumeStamp }: { gapMs: number; resumeStamp?: string }) {
1906
+ return (
1907
+ <div
1908
+ style={{
1909
+ margin: "8px 0",
1910
+ padding: "6px 12px",
1911
+ textAlign: "center",
1912
+ background:
1913
+ "repeating-linear-gradient(135deg, rgba(107,101,96,0.04) 0px, rgba(107,101,96,0.04) 6px, rgba(107,101,96,0.12) 6px, rgba(107,101,96,0.12) 12px)",
1914
+ border: "1px solid var(--af-border-subtle)",
1915
+ borderRadius: 6,
1916
+ fontSize: 11,
1917
+ color: "var(--af-text-tertiary)",
1918
+ letterSpacing: "0.02em",
1919
+ }}
1920
+ >
1921
+ {resumeStamp ? `Session resumed ${resumeStamp}` : `Session idle · ${formatGap(gapMs)}`}
2503
1922
  </div>
2504
1923
  );
2505
1924
  }
2506
1925
 
2507
- // subagentColor, workflowColor moved to ./session-view/colors.ts
2508
-
2509
- function MinimapHoverCard({
2510
- containerRef,
2511
- clientX,
2512
- row,
2513
- turn,
2514
- idleMs,
2515
- subagent,
2516
- workflow,
2517
- pr,
2518
- cold,
2519
- model,
1926
+ /** Inline cross-day jump control rendered at each day boundary. "down" sits at
1927
+ * the bottom of the day that just ended and jumps to the start of the next day;
1928
+ * "up" sits at the top of the new day and jumps to the end of the previous one.
1929
+ * The day always follows the scroll position (see followDayToScroll); these are
1930
+ * a one-click shortcut across the long idle gap between days so you don't have
1931
+ * to hand-scroll the whole span. "down" (start of next day) lands that day's
1932
+ * first row at the header line so the day-follow agrees; "up" (end of previous
1933
+ * day) scrolls the new day's strip to the viewport bottom, leaving the previous
1934
+ * day's tail above the header line. */
1935
+ function DayJumpRow({
1936
+ dir,
1937
+ label,
1938
+ onClick,
2520
1939
  }: {
2521
- containerRef: React.RefObject<HTMLDivElement | null>;
2522
- clientX: number;
2523
- row?: PresentationRow;
2524
- turn?: TurnMegaRow;
2525
- idleMs?: number;
2526
- subagent?: SubagentRun;
2527
- workflow?: WorkflowRun;
2528
- pr?: PrMarker;
2529
- cold?: { tOffsetMs: number; info: NonNullable<SessionEvent["coldResume"]> };
2530
- model?: string;
1940
+ dir: "up" | "down";
1941
+ label: string;
1942
+ onClick: () => void;
2531
1943
  }) {
2532
- const rect = containerRef.current?.getBoundingClientRect();
2533
- const localX = rect ? clientX - rect.left : 0;
2534
- const left = Math.min(Math.max(localX - 140, 8), (rect?.width ?? 1400) - 300);
2535
- // Always open below the minimap. The minimap lives inside the sticky
2536
- // header, so there's never reliable space above it for a tooltip.
2537
- const posStyle = { top: "calc(100% + 8px)", left };
2538
-
2539
- if (workflow) {
2540
- const w = workflow;
2541
- const startOff = formatOffset(w.startTOffsetMs);
2542
- const endOff = formatOffset(w.endTOffsetMs);
2543
- const dur = w.durationMs !== undefined ? formatGap(w.durationMs) : "";
2544
- return (
2545
- <div
1944
+ return (
1945
+ <div style={{ display: "flex", justifyContent: "center", margin: "6px 0" }}>
1946
+ <button
1947
+ type="button"
1948
+ onClick={onClick}
1949
+ title={dir === "up" ? `Go to the end of ${label}` : `Go to the start of ${label}`}
2546
1950
  style={{
2547
- position: "absolute",
2548
- ...posStyle,
2549
- zIndex: 100,
2550
- background: "#0F172A",
2551
- color: "#F1F5F9",
2552
- borderRadius: 10,
2553
- padding: "12px 14px",
1951
+ display: "inline-flex",
1952
+ alignItems: "center",
1953
+ gap: 6,
2554
1954
  fontSize: 11,
2555
- pointerEvents: "none",
2556
- boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
2557
- maxWidth: 440,
2558
- minWidth: 280,
2559
- lineHeight: 1.45,
1955
+ fontFamily: "var(--font-mono)",
1956
+ color: "var(--af-text-secondary)",
1957
+ background: "var(--af-surface)",
1958
+ border: "1px solid var(--af-border-subtle)",
1959
+ borderRadius: 100,
1960
+ padding: "3px 12px",
1961
+ cursor: "pointer",
2560
1962
  }}
2561
1963
  >
2562
- <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
2563
- <span
2564
- style={{
2565
- fontSize: 10,
2566
- fontWeight: 600,
2567
- padding: "2px 8px",
2568
- borderRadius: 4,
2569
- background: workflowColor(w.status),
2570
- color: "#fff",
2571
- textTransform: "uppercase",
2572
- letterSpacing: "0.04em",
2573
- }}
2574
- >
2575
- workflow · {w.status}
2576
- </span>
2577
- <span
2578
- style={{
2579
- fontFamily: "var(--font-mono)",
2580
- fontSize: 10,
2581
- opacity: 0.7,
2582
- marginLeft: "auto",
2583
- }}
2584
- >
2585
- {startOff} → {endOff} · {dur}
2586
- </span>
2587
- </div>
2588
- <div style={{ fontWeight: 600, marginBottom: 4, fontFamily: "var(--font-mono)" }}>
2589
- {w.name}
2590
- </div>
2591
- {w.description && (
2592
- <div style={{ opacity: 0.8, marginBottom: 6 }}>{w.description}</div>
2593
- )}
2594
- <div
2595
- style={{
2596
- fontSize: 10,
2597
- opacity: 0.75,
2598
- display: "flex",
2599
- gap: 8,
2600
- flexWrap: "wrap",
2601
- paddingTop: 6,
2602
- borderTop: "1px solid rgba(241,245,249,0.08)",
2603
- fontFamily: "var(--font-mono)",
2604
- }}
2605
- >
2606
- <span style={{ color: "#FB923C", fontWeight: 600 }}>{w.agentCount} agents</span>
2607
- <span>·</span>
2608
- <span>{w.toolCallCount.toLocaleString()} tools</span>
2609
- <span>·</span>
2610
- <span>{formatTokens(w.totalTokens)} tok</span>
2611
- {w.phases.length > 0 && (
2612
- <>
2613
- <span>·</span>
2614
- <span>{w.phases.length} phases</span>
2615
- </>
2616
- )}
2617
- </div>
2618
- <div style={{ marginTop: 6, fontSize: 10, opacity: 0.55, fontStyle: "italic" }}>
2619
- Click to open this run in the Workflows tab.
2620
- </div>
2621
- </div>
2622
- );
1964
+ {dir === "up" ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
1965
+ {dir === "up" ? `End of ${label}` : `Start of ${label}`}
1966
+ </button>
1967
+ </div>
1968
+ );
1969
+ }
1970
+
1971
+ function findColdResumeInDisplayRow(
1972
+ d: DisplayRow,
1973
+ ): SessionEvent["coldResume"] | null {
1974
+ if (d.kind === "presentation") return findColdResumeInRow(d.row);
1975
+ if (d.kind === "turn-collapsed" || d.kind === "turn-expanded-header") {
1976
+ for (const r of d.turn.rows) {
1977
+ const hit = findColdResumeInRow(r);
1978
+ if (hit) return hit;
1979
+ }
1980
+ }
1981
+ return null;
1982
+ }
1983
+
1984
+ function findColdResumeInRow(
1985
+ r: PresentationRow,
1986
+ ): SessionEvent["coldResume"] | null {
1987
+ if (r.kind === "agent") {
1988
+ if (r.event.coldResume) return r.event.coldResume;
1989
+ for (const ge of r.groupedEvents) {
1990
+ if (ge.coldResume) return ge.coldResume;
1991
+ }
1992
+ } else if (r.kind === "tool-group") {
1993
+ for (const e of r.events) if (e.coldResume) return e.coldResume;
2623
1994
  }
1995
+ return null;
1996
+ }
2624
1997
 
2625
- if (subagent) {
2626
- const startOff = formatOffset(subagent.startTOffsetMs);
2627
- const endOff = formatOffset(subagent.endTOffsetMs);
2628
- const dur = subagent.durationMs !== undefined ? formatGap(subagent.durationMs) : "";
2629
- const totalIn =
2630
- subagent.totalUsage.input + subagent.totalUsage.cacheRead + subagent.totalUsage.cacheWrite;
2631
- return (
1998
+ function ColdResumeNotice({
1999
+ info,
2000
+ model,
2001
+ }: {
2002
+ info: NonNullable<SessionEvent["coldResume"]>;
2003
+ model?: string;
2004
+ }) {
2005
+ const { trigger, gapMs, writeTokens, writeRatio, compact } = info;
2006
+ const estUsd = estimateCost(
2007
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: writeTokens },
2008
+ model,
2009
+ );
2010
+ const isCompact = trigger === "compact";
2011
+ const fullyCold = writeRatio >= 0.9;
2012
+ let title: string;
2013
+ if (!isCompact) title = `Session resumed cold · idle ${formatGap(gapMs)}`;
2014
+ else if (compact?.trigger === "auto") title = "Auto-compact rebuilt the cache";
2015
+ else title = "Conversation compacted · cache rebuilt";
2016
+ const hint = isCompact
2017
+ ? "Compaction summarizes the conversation, so the prefix must be rewritten into a fresh cache. Any /compact or auto-compact will cost this rewrite."
2018
+ : "Prompt cache expired during idle. Resuming within 5 min keeps the cache warm and avoids the rewrite tax.";
2019
+ return (
2020
+ <div
2021
+ style={{
2022
+ padding: "8px 12px",
2023
+ background: "rgba(217, 119, 6, 0.08)",
2024
+ border: "1px solid rgba(217, 119, 6, 0.35)",
2025
+ borderLeft: "3px solid rgba(217, 119, 6, 0.9)",
2026
+ borderRadius: 6,
2027
+ fontSize: 11.5,
2028
+ color: "var(--af-text-secondary)",
2029
+ lineHeight: 1.5,
2030
+ }}
2031
+ >
2632
2032
  <div
2633
2033
  style={{
2634
- position: "absolute",
2635
- ...posStyle,
2636
- zIndex: 100,
2637
- background: "#0F172A",
2638
- color: "#F1F5F9",
2639
- borderRadius: 10,
2640
- padding: "12px 14px",
2641
- fontSize: 11,
2642
- pointerEvents: "none",
2643
- boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
2644
- maxWidth: 440,
2645
- minWidth: 280,
2646
- lineHeight: 1.45,
2647
- }}
2648
- >
2649
- <div
2650
- style={{
2651
- display: "flex",
2652
- alignItems: "center",
2653
- gap: 8,
2654
- marginBottom: 8,
2655
- }}
2656
- >
2657
- <span
2658
- style={{
2659
- fontSize: 10,
2660
- fontWeight: 600,
2661
- padding: "2px 8px",
2662
- borderRadius: 4,
2663
- background: subagentColor(subagent.agentType, subagent.runInBackground),
2664
- color: "#fff",
2665
- }}
2666
- >
2667
- {subagent.agentType}
2668
- </span>
2669
- {subagent.runInBackground && (
2670
- <span
2671
- style={{
2672
- fontSize: 9,
2673
- fontWeight: 600,
2674
- padding: "1px 6px",
2675
- borderRadius: 3,
2676
- background: "rgba(251, 191, 36, 0.18)",
2677
- color: "#FBBF24",
2678
- textTransform: "uppercase",
2679
- letterSpacing: "0.04em",
2680
- }}
2681
- >
2682
- background
2683
- </span>
2684
- )}
2685
- <span
2686
- style={{
2687
- fontFamily: "var(--font-mono)",
2688
- fontSize: 10,
2689
- opacity: 0.7,
2690
- marginLeft: "auto",
2691
- }}
2692
- >
2693
- {startOff} → {endOff} · {dur}
2694
- </span>
2695
- </div>
2696
- <div style={{ fontWeight: 500, marginBottom: 6 }}>{subagent.description}</div>
2697
- <div
2698
- style={{
2699
- fontSize: 10,
2700
- opacity: 0.65,
2701
- display: "flex",
2702
- gap: 8,
2703
- paddingTop: 6,
2704
- borderTop: "1px solid rgba(241,245,249,0.08)",
2705
- }}
2706
- >
2707
- <span>{subagent.eventCount} events</span>
2708
- <span>·</span>
2709
- <span style={{ fontFamily: "var(--font-mono)" }}>
2710
- {formatTokens(totalIn)}/{formatTokens(subagent.totalUsage.output)} tok
2711
- </span>
2712
- </div>
2713
- {subagent.finalPreview && (
2714
- <div
2715
- style={{
2716
- marginTop: 6,
2717
- opacity: 0.78,
2718
- fontStyle: "italic",
2719
- display: "-webkit-box",
2720
- WebkitLineClamp: 2,
2721
- WebkitBoxOrient: "vertical",
2722
- overflow: "hidden",
2723
- }}
2724
- >
2725
- → {subagent.finalPreview}
2726
- </div>
2727
- )}
2728
- </div>
2729
- );
2730
- }
2731
-
2732
- if (cold) {
2733
- const { trigger, gapMs, writeTokens, compact } = cold.info;
2734
- const isCompact = trigger === "compact";
2735
- const estUsd = estimateCost(
2736
- { input: 0, output: 0, cacheRead: 0, cacheWrite: writeTokens },
2737
- model,
2738
- );
2739
- let label: string;
2740
- if (!isCompact) label = "⚡ CACHE REBUILD";
2741
- else if (compact?.trigger === "auto") label = "⚡ AUTO-COMPACT";
2742
- else label = "⚡ /COMPACT";
2743
- const detailLine = isCompact
2744
- ? `${formatTokens(writeTokens)} rewritten · pre-compact ${formatTokens(compact?.preTokens ?? 0)}`
2745
- : `${formatTokens(writeTokens)} rewritten · idle ${formatGap(gapMs)}`;
2746
- const hint = isCompact
2747
- ? "Conversation was summarized; prefix had to be rewritten into a fresh cache."
2748
- : "Prompt cache expired during idle; resuming within 5 min keeps it warm.";
2749
- return (
2750
- <div
2751
- style={{
2752
- position: "absolute",
2753
- ...posStyle,
2754
- zIndex: 100,
2755
- background: "#0F172A",
2756
- color: "#F1F5F9",
2757
- borderRadius: 10,
2758
- padding: "10px 14px",
2759
- fontSize: 11,
2760
- pointerEvents: "none",
2761
- boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
2762
- maxWidth: 340,
2763
- minWidth: 220,
2764
- lineHeight: 1.45,
2034
+ display: "flex",
2035
+ alignItems: "center",
2036
+ gap: 8,
2037
+ fontWeight: 600,
2038
+ color: "#78350F",
2039
+ letterSpacing: "0.01em",
2765
2040
  }}
2766
2041
  >
2767
- <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
2768
- <span
2769
- style={{
2770
- fontSize: 10,
2771
- fontWeight: 600,
2772
- padding: "2px 8px",
2773
- borderRadius: 4,
2774
- background: "#D97706",
2775
- color: "#fff",
2776
- }}
2777
- >
2778
- {label}
2779
- </span>
2780
- <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, opacity: 0.7 }}>
2781
- at {formatOffset(cold.tOffsetMs)}
2782
- </span>
2783
- </div>
2784
- <div style={{ fontWeight: 500, fontFamily: "var(--font-mono)" }}>
2785
- {detailLine}
2786
- {estUsd >= 0.005 && ` · est. ${formatCost(estUsd)}`}
2787
- </div>
2788
- <div
2789
- style={{
2790
- marginTop: 4,
2791
- fontSize: 10,
2792
- opacity: 0.65,
2793
- fontStyle: "italic",
2794
- whiteSpace: "normal",
2795
- }}
2796
- >
2797
- {hint}
2798
- </div>
2042
+ <span style={{ fontSize: 13 }}>⚡</span>
2043
+ {title}
2799
2044
  </div>
2800
- );
2801
- }
2802
-
2803
- if (pr) {
2804
- const label = pr.title
2805
- ? `PR${pr.prNumber ? ` #${pr.prNumber}` : ""}: ${pr.title}`
2806
- : pr.prNumber
2807
- ? `PR #${pr.prNumber}`
2808
- : "PR created";
2809
- return (
2810
- <div
2811
- style={{
2812
- position: "absolute",
2813
- ...posStyle,
2814
- zIndex: 100,
2815
- background: "#0F172A",
2816
- color: "#F1F5F9",
2817
- borderRadius: 10,
2818
- padding: "10px 14px",
2819
- fontSize: 11,
2820
- pointerEvents: "none",
2821
- boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
2822
- maxWidth: 360,
2823
- minWidth: 200,
2824
- lineHeight: 1.45,
2825
- }}
2826
- >
2827
- <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
2828
- <span
2829
- style={{
2830
- fontSize: 10,
2831
- fontWeight: 600,
2832
- padding: "2px 8px",
2833
- borderRadius: 4,
2834
- background: "#8b5cf6",
2835
- color: "#fff",
2836
- }}
2837
- >
2838
- PR
2839
- </span>
2840
- {pr.tOffsetMs !== undefined && (
2841
- <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, opacity: 0.7 }}>
2842
- at {formatOffset(pr.tOffsetMs)}
2843
- </span>
2844
- )}
2845
- </div>
2846
- <div style={{ fontWeight: 500 }}>{label}</div>
2045
+ <div style={{ marginTop: 4, fontFamily: "var(--font-mono)", fontSize: 11 }}>
2046
+ Rewrote <b>{formatTokens(writeTokens)}</b> tokens into prompt cache
2047
+ {estUsd >= 0.005 && (
2048
+ <>
2049
+ {" "}
2050
+ · est. <b>{formatCost(estUsd)}</b>
2051
+ </>
2052
+ )}
2053
+ {isCompact && compact && (
2054
+ <>
2055
+ {" "}
2056
+ · pre-compact <b>{formatTokens(compact.preTokens)}</b>
2057
+ </>
2058
+ )}
2059
+ {!isCompact && (
2060
+ <>
2061
+ {" "}
2062
+ · {fullyCold ? "fully cold" : "partial"} ({Math.round(writeRatio * 100)}% write)
2063
+ </>
2064
+ )}
2847
2065
  </div>
2848
- );
2849
- }
2850
-
2851
- if (idleMs !== undefined) {
2852
- return (
2853
2066
  <div
2854
2067
  style={{
2855
- position: "absolute",
2856
- ...posStyle,
2857
- zIndex: 100,
2858
- background: "#0F172A",
2859
- color: "#F1F5F9",
2860
- borderRadius: 10,
2861
- padding: "8px 12px",
2862
- fontSize: 11,
2863
- pointerEvents: "none",
2864
- boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
2068
+ marginTop: 3,
2069
+ fontSize: 10.5,
2070
+ color: "var(--af-text-tertiary)",
2071
+ fontStyle: "italic",
2865
2072
  }}
2866
2073
  >
2867
- <div style={{ fontWeight: 600, marginBottom: 2 }}>Session idle</div>
2868
- <div style={{ opacity: 0.78, fontFamily: "var(--font-mono)" }}>{formatGap(idleMs)}</div>
2074
+ {hint}
2869
2075
  </div>
2870
- );
2871
- }
2872
-
2873
- if (turn) {
2874
- const theme = ROLE_THEMES.agent;
2875
- const s = turn.summary;
2876
- const dur = turn.durationMs !== undefined ? formatGap(turn.durationMs) : "";
2877
- const startOff = formatOffset(turn.tOffsetMs);
2878
- const endOff =
2879
- turn.tOffsetMs !== undefined && turn.durationMs !== undefined
2880
- ? formatOffset(turn.tOffsetMs + turn.durationMs)
2881
- : undefined;
2882
- const hasFirstLast =
2883
- s.firstAgentPreview && s.finalAgentPreview && s.firstAgentPreview !== s.finalAgentPreview;
2884
- // Top tools aggregated summary line (up to 3 entries)
2885
- const topTools = s.toolNames
2886
- .slice(0, 3)
2887
- .map((t) =>
2888
- t.count > 1 ? `${shortenToolName(t.name)} ×${t.count}` : shortenToolName(t.name),
2889
- )
2890
- .join(" · ");
2891
-
2892
- return (
2893
- <div
2894
- style={{
2895
- position: "absolute",
2896
- ...posStyle,
2897
- zIndex: 100,
2898
- background: "#0F172A",
2899
- color: "#F1F5F9",
2900
- borderRadius: 10,
2901
- padding: "12px 14px",
2902
- fontSize: 11,
2903
- pointerEvents: "none",
2904
- boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
2905
- maxWidth: 440,
2906
- minWidth: 280,
2907
- lineHeight: 1.45,
2908
- }}
2909
- >
2910
- {/* Header: Turn pill + start→end range */}
2911
- <div
2912
- style={{
2913
- display: "flex",
2914
- alignItems: "center",
2915
- gap: 8,
2916
- marginBottom: 8,
2917
- }}
2918
- >
2919
- <span
2920
- style={{
2921
- fontSize: 10,
2922
- fontWeight: 600,
2923
- padding: "2px 8px",
2924
- borderRadius: 4,
2925
- background: theme.mini,
2926
- color: "#fff",
2927
- }}
2928
- >
2929
- Turn
2930
- </span>
2931
- <span
2932
- style={{
2933
- fontFamily: "var(--font-mono)",
2934
- fontSize: 10,
2935
- opacity: 0.7,
2936
- }}
2937
- >
2938
- {endOff ? `${startOff} → ${endOff}` : startOff}
2939
- </span>
2940
- {dur && (
2941
- <span
2942
- style={{
2943
- fontFamily: "var(--font-mono)",
2944
- fontSize: 10,
2945
- opacity: 0.55,
2946
- }}
2947
- >
2948
- · {dur}
2949
- </span>
2950
- )}
2951
- </div>
2952
-
2953
- {/* First agent message */}
2954
- {s.firstAgentPreview && (
2955
- <div
2956
- style={{
2957
- opacity: 0.95,
2958
- fontWeight: 500,
2959
- display: "-webkit-box",
2960
- WebkitLineClamp: 2,
2961
- WebkitBoxOrient: "vertical",
2962
- overflow: "hidden",
2963
- marginBottom: 6,
2964
- }}
2965
- >
2966
- {s.firstAgentPreview}
2967
- </div>
2968
- )}
2076
+ </div>
2077
+ );
2078
+ }
2969
2079
 
2970
- {/* Middle: stats + top tools */}
2971
- <div
2972
- style={{
2973
- fontSize: 10,
2974
- opacity: 0.65,
2975
- marginBottom: hasFirstLast ? 6 : 0,
2976
- borderTop: s.firstAgentPreview ? "1px solid rgba(241,245,249,0.08)" : undefined,
2977
- paddingTop: s.firstAgentPreview ? 6 : 0,
2978
- }}
2979
- >
2980
- {s.agentMessages} msg · {s.toolCalls} tools
2981
- {s.errors > 0 ? ` · ${s.errors} err` : ""}
2982
- {topTools && (
2983
- <>
2984
- <span style={{ opacity: 0.5, margin: "0 4px" }}>·</span>
2985
- <span style={{ fontFamily: "var(--font-mono)" }}>{topTools}</span>
2986
- {s.toolNames.length > 3 && (
2987
- <span style={{ opacity: 0.65 }}> +{s.toolNames.length - 3}</span>
2988
- )}
2989
- </>
2080
+ function ColdResumeSessionStat({
2081
+ count,
2082
+ writeTokens,
2083
+ model,
2084
+ }: {
2085
+ count: number;
2086
+ writeTokens: number;
2087
+ model?: string;
2088
+ }) {
2089
+ const { ref, anchor, open, close } = useAnchoredTooltip();
2090
+ const estUsd = estimateCost(
2091
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: writeTokens },
2092
+ model,
2093
+ );
2094
+ return (
2095
+ <span
2096
+ ref={ref}
2097
+ style={{
2098
+ display: "inline-flex",
2099
+ alignItems: "center",
2100
+ gap: 5,
2101
+ fontSize: 11.5,
2102
+ fontWeight: 600,
2103
+ color: "#78350F",
2104
+ background: "rgba(217, 119, 6, 0.12)",
2105
+ padding: "2px 8px",
2106
+ borderRadius: 100,
2107
+ cursor: "default",
2108
+ }}
2109
+ onMouseEnter={open}
2110
+ onMouseLeave={close}
2111
+ >
2112
+ <span style={{ fontSize: 12 }}>⚡</span>
2113
+ {count} cache rebuild{count === 1 ? "" : "s"} · {formatTokens(writeTokens)} rewritten
2114
+ {anchor && (
2115
+ <AnchoredTooltip anchor={anchor} width={320}>
2116
+ <TooltipRow label="Cache rebuilds" value={count.toLocaleString()} />
2117
+ <TooltipRow label="Tokens rewritten" value={writeTokens.toLocaleString()} />
2118
+ {estUsd >= 0.005 && (
2119
+ <TooltipRow label="Est. rebuild cost" value={formatCost(estUsd)} />
2990
2120
  )}
2991
- </div>
2992
-
2993
- {/* Last agent message */}
2994
- {hasFirstLast && s.finalAgentPreview && (
2995
2121
  <div
2996
2122
  style={{
2997
- opacity: 0.92,
2998
- display: "flex",
2999
- gap: 6,
3000
- alignItems: "flex-start",
3001
- borderTop: "1px solid rgba(241,245,249,0.08)",
2123
+ marginTop: 6,
3002
2124
  paddingTop: 6,
2125
+ borderTop: "1px solid rgba(241,245,249,0.12)",
2126
+ opacity: 0.65,
2127
+ fontSize: 10,
2128
+ whiteSpace: "normal",
2129
+ lineHeight: 1.4,
3003
2130
  }}
3004
2131
  >
3005
- <span style={{ opacity: 0.5 }}>→</span>
3006
- <span
3007
- style={{
3008
- display: "-webkit-box",
3009
- WebkitLineClamp: 2,
3010
- WebkitBoxOrient: "vertical",
3011
- overflow: "hidden",
3012
- }}
3013
- >
3014
- {s.finalAgentPreview}
3015
- </span>
2132
+ Turns where the whole prompt-cache prefix had to be rewritten.
2133
+ Either the cache expired during a long idle gap, or a /compact
2134
+ (manual or auto) summarized the conversation. Both cost tokens
2135
+ at 1.25× base input price.
3016
2136
  </div>
3017
- )}
3018
- </div>
3019
- );
3020
- }
3021
-
3022
- if (!row) return null;
3023
-
3024
- const theme = ROLE_THEMES[row.kind];
3025
- const preview = rowPreview(row);
2137
+ </AnchoredTooltip>
2138
+ )}
2139
+ </span>
2140
+ );
2141
+ }
3026
2142
 
2143
+ function ColdResumeChip({
2144
+ info,
2145
+ model,
2146
+ }: {
2147
+ info: NonNullable<SessionEvent["coldResume"]>;
2148
+ model?: string;
2149
+ }) {
2150
+ const { writeTokens } = info;
2151
+ const estUsd = estimateCost(
2152
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: writeTokens },
2153
+ model,
2154
+ );
3027
2155
  return (
3028
- <div
2156
+ <span
2157
+ title={`Prompt cache expired during idle; this turn rewrote ${writeTokens.toLocaleString()} tokens into cache${estUsd >= 0.005 ? ` (est. ${formatCost(estUsd)})` : ""}.`}
3029
2158
  style={{
3030
- position: "absolute",
3031
- ...posStyle,
3032
- zIndex: 100,
3033
- background: "#0F172A",
3034
- color: "#F1F5F9",
3035
- borderRadius: 10,
3036
- padding: "10px 14px",
3037
- fontSize: 11,
3038
- pointerEvents: "none",
3039
- boxShadow: "0 6px 24px rgba(15,23,42,0.22)",
3040
- maxWidth: 360,
3041
- minWidth: 220,
2159
+ display: "inline-flex",
2160
+ alignItems: "center",
2161
+ gap: 4,
2162
+ fontSize: 10.5,
2163
+ fontWeight: 600,
2164
+ padding: "2px 7px",
2165
+ borderRadius: 3,
2166
+ background: "rgba(217, 119, 6, 0.14)",
2167
+ color: "#78350F",
2168
+ letterSpacing: "0.01em",
2169
+ cursor: "default",
3042
2170
  }}
3043
2171
  >
3044
- <div
3045
- style={{
3046
- display: "flex",
3047
- alignItems: "center",
3048
- gap: 8,
3049
- marginBottom: 6,
3050
- }}
3051
- >
3052
- <span
3053
- style={{
3054
- fontSize: 10,
3055
- fontWeight: 600,
3056
- padding: "2px 8px",
3057
- borderRadius: 4,
3058
- background: theme.mini,
3059
- color: "#fff",
3060
- }}
3061
- >
3062
- {theme.label}
3063
- </span>
3064
- <span
3065
- style={{
3066
- fontFamily: "var(--font-mono)",
3067
- fontSize: 10,
3068
- opacity: 0.65,
3069
- }}
3070
- >
3071
- {formatOffset(row.tOffsetMs)}
3072
- </span>
3073
- </div>
3074
- <div
3075
- style={{
3076
- lineHeight: 1.45,
3077
- opacity: 0.92,
3078
- display: "-webkit-box",
3079
- WebkitLineClamp: 3,
3080
- WebkitBoxOrient: "vertical",
3081
- overflow: "hidden",
3082
- }}
3083
- >
3084
- {preview}
3085
- </div>
3086
- </div>
2172
+ ⚡ cold resume · {formatTokens(writeTokens)} rewritten
2173
+ </span>
3087
2174
  );
3088
2175
  }
3089
2176
 
3090
2177
  /* ------------------------------------------------------------------ */
3091
- /* Transcript list + rows */
2178
+ /* Collapsed turn row */
2179
+ /* */
2180
+ /* Three-line layout: */
2181
+ /* 1. First agent preview (intent / plan) */
2182
+ /* 2. Middle summary (counts + top tools + duration) */
2183
+ /* 3. Final agent preview (answer / conclusion) */
3092
2184
  /* ------------------------------------------------------------------ */
3093
2185
 
3094
- function TranscriptList({
3095
- displayRows,
3096
- rowRefs,
3097
- selectedIndex,
3098
- onSelect,
3099
- onToggleTurn,
2186
+ function CollapsedTurnRow({
2187
+ turn,
2188
+ onClick,
2189
+ refCb,
3100
2190
  stickyOffset,
3101
- isSessionLive,
2191
+ inProgress,
3102
2192
  team,
2193
+ coldResume,
3103
2194
  model,
3104
- rawIdleBands,
3105
- sessionDays,
3106
- entryByDay,
3107
- dayStripRefs,
3108
- onJumpAcrossDay,
3109
2195
  }: {
3110
- displayRows: DisplayRow[];
3111
- rowRefs: React.MutableRefObject<Record<number, HTMLDivElement | null>>;
3112
- selectedIndex: number | null;
3113
- onSelect: (i: number | null) => void;
3114
- onToggleTurn: (firstPrimaryIndex: number) => void;
2196
+ turn: TurnMegaRow;
2197
+ /** Fires when the user wants to fully expand this turn into the
2198
+ * transcript list (inner rows appear below as separate TranscriptRow
2199
+ * entries). Triggered by the "Show all N steps" bottom bar. */
2200
+ onClick: () => void;
2201
+ refCb: (el: HTMLDivElement | null) => void;
3115
2202
  stickyOffset: number;
3116
- isSessionLive?: boolean;
2203
+ inProgress?: boolean;
3117
2204
  team?: (TimelineData & { teamName: string }) | null;
2205
+ coldResume?: NonNullable<SessionEvent["coldResume"]>;
3118
2206
  model?: string;
3119
- /** Anchor-to-anchor idle bands. The body emits IdleDivider before any
3120
- * row whose start matches a band's end, so Codex sessions get the same
3121
- * "Session idle X minutes" markers Claude has always had. */
3122
- rawIdleBands?: { start: number; end: number; durationMs: number }[];
3123
- /** Day buckets (sorted by startMs) + per-day digest cards. The body drops a
3124
- * card at each day's first row so per-day perception sits inline with that
3125
- * day's work instead of stacked at the top. */
3126
- sessionDays: { key: string; startMs: number; endMs: number; count: number }[];
3127
- entryByDay: Map<string, Entry>;
3128
- dayStripRefs: React.MutableRefObject<Record<string, HTMLDivElement | null>>;
3129
- /** Hop to an adjacent day from an inline day-boundary button.
3130
- * (selectKey, scrollKey, block, fallbackRowIndex). */
3131
- onJumpAcrossDay: (
3132
- selectKey: string,
3133
- scrollKey: string,
3134
- block: "start" | "end",
3135
- fallbackIndex: number,
3136
- ) => void;
3137
2207
  }) {
3138
- // Find the last collapsed turn index so we can mark it as in-progress
3139
- // when the session is live.
3140
- let lastCollapsedTurnIdx = -1;
3141
- if (isSessionLive) {
3142
- for (let i = displayRows.length - 1; i >= 0; i--) {
3143
- if (displayRows[i].kind === "turn-collapsed") {
3144
- lastCollapsedTurnIdx = i;
3145
- break;
3146
- }
3147
- }
3148
- }
2208
+ const theme = ROLE_THEMES.agent;
2209
+ const s = turn.summary;
2210
+ const hasTokens = s.totalTokens.input > 0 || s.totalTokens.output > 0;
3149
2211
 
3150
- const idleBandList = (rawIdleBands ?? []).slice().sort((a, b) => a.start - b.start);
3151
- /** Returns the first matching idle band (or null) whose end falls within
3152
- * 1s of the given row's tOffset. Tolerance absorbs Date.parse rounding
3153
- * between anchor.ms and the presentation row's tOffsetMs. */
3154
- const idleBandBeforeOffset = (
3155
- tOff: number | undefined,
3156
- ): { start: number; durationMs: number } | null => {
3157
- if (tOff === undefined) return null;
3158
- for (const b of idleBandList) {
3159
- if (Math.abs(b.end - tOff) < 1000) return b;
3160
- }
3161
- return null;
3162
- };
3163
- /** Pull a t-offset off whichever display-row variant we have. */
3164
- const rowTOffset = (d: DisplayRow): number | undefined => {
3165
- if (d.kind === "turn-collapsed" || d.kind === "turn-expanded-header") return d.turn.tOffsetMs;
3166
- if (d.kind === "turn-expanded-footer") return undefined;
3167
- return d.row.tOffsetMs;
3168
- };
3169
- // A display row → its event PRIMARY index (what jumpAcrossDay's turn/row
3170
- // lookups are keyed by — NOT the displayRows loop position).
3171
- const rowPrimaryIndexOf = (d: DisplayRow): number =>
3172
- d.kind === "presentation" ? rowPrimaryIndex(d.row) : d.turn.firstPrimaryIndex;
3173
- /** Which local day a row falls in: the last day bucket whose start is at or
3174
- * before the row's offset. sessionDays is pre-sorted by startMs. */
3175
- const dayKeyForOffset = (tOff: number | undefined): string | null => {
3176
- if (tOff === undefined) return null;
3177
- let key: string | null = null;
3178
- for (const dd of sessionDays) {
3179
- if (dd.startMs <= tOff) key = dd.key;
3180
- else break;
3181
- }
3182
- return key;
3183
- };
2212
+ // First / conclusion agent-message indices are pre-computed by
2213
+ // buildMegaRows — the "conclusion" uses a heuristic that skips short
2214
+ // codas following a task-notification (so e.g. "Ship done..." beats
2215
+ // a later "Acknowledged — the background task closed out").
2216
+ const firstAgentIdx = s.firstAgentIndex ?? -1;
2217
+ const finalAgentIdx = s.finalAgentIndex ?? -1;
2218
+ const firstAgentRow = firstAgentIdx >= 0 ? turn.rows[firstAgentIdx] : undefined;
2219
+ const finalAgentRow =
2220
+ finalAgentIdx >= 0 && finalAgentIdx !== firstAgentIdx ? turn.rows[finalAgentIdx] : undefined;
3184
2221
 
3185
- const out: React.ReactNode[] = [];
3186
- // Tracks the day of the last emitted row so a day's digest card is dropped
3187
- // once, before that day's first non-indented row.
3188
- let currentDayKey: string | null = null;
3189
- // A user row and the turn-collapsed row that follows it share a
3190
- // tOffsetMs (turn anchors at the user message — see buildMegaRows), so
3191
- // both would match the same between-turn band without this guard. Track
3192
- // emitted band starts and skip duplicates.
3193
- const emittedBandStarts = new Set<number>();
3194
- for (let i = 0; i < displayRows.length; i++) {
3195
- const d = displayRows[i];
3196
- // Emit a "Session idle" divider before any row that starts right after
3197
- // an anchor-to-anchor idle band. Skips indented (child) rows so an
3198
- // expanded turn doesn't render dividers for its inner steps.
3199
- const indented = d.kind === "presentation" ? d.indented : false;
3200
- if (!indented) {
3201
- const band = idleBandBeforeOffset(rowTOffset(d));
3202
- if (
3203
- band !== null &&
3204
- band.durationMs > IDLE_THRESHOLD_MS &&
3205
- !emittedBandStarts.has(band.start)
3206
- ) {
3207
- emittedBandStarts.add(band.start);
3208
- out.push(<IdleDivider key={`idle-before-${i}`} gapMs={band.durationMs} />);
3209
- }
3210
- // Day boundary: drop this day's digest card at its first row (after any
3211
- // overnight idle divider that led into the day) so per-day perception
3212
- // sits inline with that day's work, not stacked at the top. The boundary
3213
- // also gets inline jump controls so you can page across days while
3214
- // reading — "↓ start of next day" sits at the bottom of the day that just
3215
- // ended, "↑ end of previous day" at the top of the new one.
3216
- const dk = dayKeyForOffset(rowTOffset(d));
3217
- if (dk && dk !== currentDayKey) {
3218
- const endedKey = currentDayKey; // day that just ended; null on the first day
3219
- currentDayKey = dk;
3220
- // Fallback scroll target for days with no digest card: the day's first
3221
- // row by PRIMARY index (i is the displayRows position — wrong key space).
3222
- const firstIdx = rowPrimaryIndexOf(d);
3223
- if (endedKey) {
3224
- out.push(
3225
- <DayJumpRow
3226
- key={`daydown-${dk}`}
3227
- dir="down"
3228
- label={formatDayKey(dk)}
3229
- onClick={() => onJumpAcrossDay(dk, dk, "start", firstIdx)}
3230
- />,
3231
- );
3232
- }
3233
- const dayEntry = entryByDay.get(dk);
3234
- if (dayEntry) {
3235
- out.push(
3236
- <div
3237
- key={`daystrip-${dk}`}
3238
- ref={(el) => {
3239
- dayStripRefs.current[dk] = el;
3240
- }}
3241
- style={{ marginBottom: 12, scrollMarginTop: stickyOffset }}
3242
- >
3243
- <EntryDayStrip entry={dayEntry} />
3244
- </div>,
3245
- );
3246
- }
3247
- if (endedKey) {
3248
- out.push(
3249
- <DayJumpRow
3250
- key={`dayup-${dk}`}
3251
- dir="up"
3252
- label={formatDayKey(endedKey)}
3253
- onClick={() => onJumpAcrossDay(endedKey, dk, "end", firstIdx)}
3254
- />,
3255
- );
3256
- }
3257
- }
3258
- }
2222
+ // Middle list excludes the first and conclusion messages. Any coda
2223
+ // messages (e.g. "Acknowledged ...") remain visible here as ordinary
2224
+ // steps — they happened, they just aren't the semantic conclusion.
2225
+ const middleRows = turn.rows.filter((_, i) => {
2226
+ if (i === firstAgentIdx) return false;
2227
+ if (finalAgentRow && i === finalAgentIdx) return false;
2228
+ return true;
2229
+ });
3259
2230
 
3260
- // Collapsed turn summary row
3261
- if (d.kind === "turn-collapsed") {
3262
- const idx = d.turn.firstPrimaryIndex;
3263
- const turnCold = findColdResumeInDisplayRow(d);
3264
- out.push(
3265
- <CollapsedTurnRow
3266
- key={`turn-${idx}`}
3267
- turn={d.turn}
3268
- stickyOffset={stickyOffset}
3269
- onClick={() => onToggleTurn(idx)}
3270
- inProgress={i === lastCollapsedTurnIdx}
3271
- team={team}
3272
- coldResume={turnCold ?? undefined}
3273
- model={model}
3274
- refCb={(el) => {
3275
- rowRefs.current[idx] = el;
3276
- }}
3277
- />,
3278
- );
3279
- continue;
3280
- }
3281
-
3282
- // Expanded turn header
3283
- if (d.kind === "turn-expanded-header") {
3284
- const turnCold = findColdResumeInDisplayRow(d);
3285
- out.push(
3286
- <ExpandedTurnHeader
3287
- key={`turnhead-${d.turn.firstPrimaryIndex}`}
3288
- turn={d.turn}
3289
- onClick={() => onToggleTurn(d.turn.firstPrimaryIndex)}
3290
- coldResume={turnCold ?? undefined}
3291
- model={model}
3292
- />,
3293
- );
3294
- continue;
3295
- }
3296
-
3297
- // Expanded turn footer — matching collapse control at the bottom of
3298
- // the expanded rows so the user can collapse right where their eye
3299
- // lands after reading through the content.
3300
- if (d.kind === "turn-expanded-footer") {
3301
- out.push(
3302
- <ExpandedTurnFooter
3303
- key={`turnfoot-${d.turn.firstPrimaryIndex}`}
3304
- turn={d.turn}
3305
- onClick={() => onToggleTurn(d.turn.firstPrimaryIndex)}
3306
- />,
3307
- );
3308
- continue;
3309
- }
2231
+ // Local state: whether the first / last message is rendered in full
2232
+ // markdown form inline (instead of the 2-line preview).
2233
+ const [firstExpanded, setFirstExpanded] = useState(false);
2234
+ const [lastExpanded, setLastExpanded] = useState(false);
3310
2235
 
3311
- // Normal presentation row (possibly indented as a child of an expanded
3312
- // turn). Idle dividers are now emitted at the top of the loop from the
3313
- // shared rawIdleBands list — no separate gap-before-user check needed.
3314
- const r = d.row;
3315
- const idx = rowPrimaryIndex(r);
3316
- out.push(
3317
- <TranscriptRow
3318
- key={`row-${idx}`}
3319
- row={r}
3320
- selected={selectedIndex === idx}
3321
- onSelect={() => onSelect(idx)}
3322
- stickyOffset={stickyOffset}
3323
- indented={d.indented}
3324
- refCb={(el) => {
3325
- rowRefs.current[idx] = el;
3326
- }}
3327
- />,
3328
- );
3329
- }
3330
- return <div>{out}</div>;
3331
- }
2236
+ // Stop click propagation on interactive children so clicks on the
2237
+ // expandable message areas don't bubble up and trigger onClick on the
2238
+ // whole row — we want explicit controls only.
2239
+ const stop = (e: React.MouseEvent) => e.stopPropagation();
3332
2240
 
3333
- function IdleDivider({ gapMs }: { gapMs: number }) {
3334
2241
  return (
3335
2242
  <div
2243
+ ref={refCb}
2244
+ data-sl-row-index={turn.firstPrimaryIndex}
2245
+ data-sl-toffset={turn.tOffsetMs ?? 0}
3336
2246
  style={{
3337
- margin: "8px 0",
3338
- padding: "6px 12px",
3339
- textAlign: "center",
3340
- background:
3341
- "repeating-linear-gradient(135deg, rgba(107,101,96,0.04) 0px, rgba(107,101,96,0.04) 6px, rgba(107,101,96,0.12) 6px, rgba(107,101,96,0.12) 12px)",
3342
- border: "1px solid var(--af-border-subtle)",
3343
- borderRadius: 6,
3344
- fontSize: 11,
3345
- color: "var(--af-text-tertiary)",
3346
- letterSpacing: "0.02em",
2247
+ display: "grid",
2248
+ gridTemplateColumns: "20px 84px 1fr auto auto",
2249
+ columnGap: 12,
2250
+ alignItems: "start",
2251
+ padding: "14px 12px 0 12px",
2252
+ borderBottom: "1px solid var(--af-border-subtle)",
2253
+ transition: "background 0.08s",
2254
+ scrollMarginTop: stickyOffset,
3347
2255
  }}
3348
2256
  >
3349
- Session idle · {formatGap(gapMs)}
3350
- </div>
3351
- );
3352
- }
2257
+ {/* Col 1 — empty spacer to keep the Agent pill aligned with
2258
+ TranscriptRow's pill column (which also has an empty 20px slot). */}
2259
+ <span />
3353
2260
 
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}`}
2261
+ {/* Col 2 — "Agent" role pill */}
2262
+ <span
3378
2263
  style={{
3379
- display: "inline-flex",
3380
- alignItems: "center",
3381
- gap: 6,
2264
+ justifySelf: "start",
3382
2265
  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",
2266
+ fontWeight: 600,
2267
+ padding: "3px 10px",
2268
+ borderRadius: 4,
2269
+ background: theme.bg,
2270
+ color: theme.fg,
2271
+ marginTop: 2,
3390
2272
  }}
3391
2273
  >
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
- }
3423
- return null;
3424
- }
2274
+ Agent
2275
+ </span>
3425
2276
 
3426
- function ColdResumeNotice({
3427
- info,
3428
- model,
3429
- }: {
3430
- info: NonNullable<SessionEvent["coldResume"]>;
3431
- model?: string;
3432
- }) {
3433
- const { trigger, gapMs, writeTokens, writeRatio, compact } = info;
3434
- const estUsd = estimateCost(
3435
- { input: 0, output: 0, cacheRead: 0, cacheWrite: writeTokens },
3436
- model,
3437
- );
3438
- const isCompact = trigger === "compact";
3439
- const fullyCold = writeRatio >= 0.9;
3440
- let title: string;
3441
- if (!isCompact) title = `Session resumed cold · idle ${formatGap(gapMs)}`;
3442
- else if (compact?.trigger === "auto") title = "Auto-compact rebuilt the cache";
3443
- else title = "Conversation compacted · cache rebuilt";
3444
- const hint = isCompact
3445
- ? "Compaction summarizes the conversation, so the prefix must be rewritten into a fresh cache. Any /compact or auto-compact will cost this rewrite."
3446
- : "Prompt cache expired during idle. Resuming within 5 min keeps the cache warm and avoids the rewrite tax.";
3447
- return (
3448
- <div
3449
- style={{
3450
- padding: "8px 12px",
3451
- background: "rgba(217, 119, 6, 0.08)",
3452
- border: "1px solid rgba(217, 119, 6, 0.35)",
3453
- borderLeft: "3px solid rgba(217, 119, 6, 0.9)",
3454
- borderRadius: 6,
3455
- fontSize: 11.5,
3456
- color: "var(--af-text-secondary)",
3457
- lineHeight: 1.5,
3458
- }}
3459
- >
2277
+ {/* Col 3 — content (cold-resume notice · first · stats · steps · last · bottom bar) */}
3460
2278
  <div
3461
2279
  style={{
2280
+ minWidth: 0,
3462
2281
  display: "flex",
3463
- alignItems: "center",
3464
- gap: 8,
3465
- fontWeight: 600,
3466
- color: "#78350F",
3467
- letterSpacing: "0.01em",
2282
+ flexDirection: "column",
2283
+ gap: 10,
2284
+ paddingBottom: 10,
3468
2285
  }}
3469
2286
  >
3470
- <span style={{ fontSize: 13 }}>⚡</span>
3471
- {title}
3472
- </div>
3473
- <div style={{ marginTop: 4, fontFamily: "var(--font-mono)", fontSize: 11 }}>
3474
- Rewrote <b>{formatTokens(writeTokens)}</b> tokens into prompt cache
3475
- {estUsd >= 0.005 && (
3476
- <>
3477
- {" "}
3478
- · est. <b>{formatCost(estUsd)}</b>
3479
- </>
3480
- )}
3481
- {isCompact && compact && (
3482
- <>
3483
- {" "}
3484
- · pre-compact <b>{formatTokens(compact.preTokens)}</b>
3485
- </>
3486
- )}
3487
- {!isCompact && (
3488
- <>
3489
- {" "}
3490
- · {fullyCold ? "fully cold" : "partial"} ({Math.round(writeRatio * 100)}% write)
3491
- </>
3492
- )}
3493
- </div>
3494
- <div
3495
- style={{
3496
- marginTop: 3,
3497
- fontSize: 10.5,
3498
- color: "var(--af-text-tertiary)",
3499
- fontStyle: "italic",
3500
- }}
3501
- >
3502
- {hint}
3503
- </div>
3504
- </div>
3505
- );
3506
- }
3507
-
3508
- function ColdResumeSessionStat({
3509
- count,
3510
- writeTokens,
3511
- model,
3512
- }: {
3513
- count: number;
3514
- writeTokens: number;
3515
- model?: string;
3516
- }) {
3517
- const { ref, anchor, open, close } = useAnchoredTooltip();
3518
- const estUsd = estimateCost(
3519
- { input: 0, output: 0, cacheRead: 0, cacheWrite: writeTokens },
3520
- model,
3521
- );
3522
- return (
3523
- <span
3524
- ref={ref}
3525
- style={{
3526
- display: "inline-flex",
3527
- alignItems: "center",
3528
- gap: 5,
3529
- fontSize: 11.5,
3530
- fontWeight: 600,
3531
- color: "#78350F",
3532
- background: "rgba(217, 119, 6, 0.12)",
3533
- padding: "2px 8px",
3534
- borderRadius: 100,
3535
- cursor: "default",
3536
- }}
3537
- onMouseEnter={open}
3538
- onMouseLeave={close}
3539
- >
3540
- <span style={{ fontSize: 12 }}>⚡</span>
3541
- {count} cache rebuild{count === 1 ? "" : "s"} · {formatTokens(writeTokens)} rewritten
3542
- {anchor && (
3543
- <AnchoredTooltip anchor={anchor} width={320}>
3544
- <TooltipRow label="Cache rebuilds" value={count.toLocaleString()} />
3545
- <TooltipRow label="Tokens rewritten" value={writeTokens.toLocaleString()} />
3546
- {estUsd >= 0.005 && (
3547
- <TooltipRow label="Est. rebuild cost" value={formatCost(estUsd)} />
3548
- )}
3549
- <div
3550
- style={{
3551
- marginTop: 6,
3552
- paddingTop: 6,
3553
- borderTop: "1px solid rgba(241,245,249,0.12)",
3554
- opacity: 0.65,
3555
- fontSize: 10,
3556
- whiteSpace: "normal",
3557
- lineHeight: 1.4,
3558
- }}
3559
- >
3560
- Turns where the whole prompt-cache prefix had to be rewritten.
3561
- Either the cache expired during a long idle gap, or a /compact
3562
- (manual or auto) summarized the conversation. Both cost tokens
3563
- at 1.25× base input price.
3564
- </div>
3565
- </AnchoredTooltip>
3566
- )}
3567
- </span>
3568
- );
3569
- }
3570
-
3571
- function ColdResumeChip({
3572
- info,
3573
- model,
3574
- }: {
3575
- info: NonNullable<SessionEvent["coldResume"]>;
3576
- model?: string;
3577
- }) {
3578
- const { writeTokens } = info;
3579
- const estUsd = estimateCost(
3580
- { input: 0, output: 0, cacheRead: 0, cacheWrite: writeTokens },
3581
- model,
3582
- );
3583
- return (
3584
- <span
3585
- title={`Prompt cache expired during idle; this turn rewrote ${writeTokens.toLocaleString()} tokens into cache${estUsd >= 0.005 ? ` (est. ${formatCost(estUsd)})` : ""}.`}
3586
- style={{
3587
- display: "inline-flex",
3588
- alignItems: "center",
3589
- gap: 4,
3590
- fontSize: 10.5,
3591
- fontWeight: 600,
3592
- padding: "2px 7px",
3593
- borderRadius: 3,
3594
- background: "rgba(217, 119, 6, 0.14)",
3595
- color: "#78350F",
3596
- letterSpacing: "0.01em",
3597
- cursor: "default",
3598
- }}
3599
- >
3600
- ⚡ cold resume · {formatTokens(writeTokens)} rewritten
3601
- </span>
3602
- );
3603
- }
3604
-
3605
- /* ------------------------------------------------------------------ */
3606
- /* Collapsed turn row */
3607
- /* */
3608
- /* Three-line layout: */
3609
- /* 1. First agent preview (intent / plan) */
3610
- /* 2. Middle summary (counts + top tools + duration) */
3611
- /* 3. Final agent preview (answer / conclusion) */
3612
- /* ------------------------------------------------------------------ */
3613
-
3614
- function CollapsedTurnRow({
3615
- turn,
3616
- onClick,
3617
- refCb,
3618
- stickyOffset,
3619
- inProgress,
3620
- team,
3621
- coldResume,
3622
- model,
3623
- }: {
3624
- turn: TurnMegaRow;
3625
- /** Fires when the user wants to fully expand this turn into the
3626
- * transcript list (inner rows appear below as separate TranscriptRow
3627
- * entries). Triggered by the "Show all N steps" bottom bar. */
3628
- onClick: () => void;
3629
- refCb: (el: HTMLDivElement | null) => void;
3630
- stickyOffset: number;
3631
- inProgress?: boolean;
3632
- team?: (TimelineData & { teamName: string }) | null;
3633
- coldResume?: NonNullable<SessionEvent["coldResume"]>;
3634
- model?: string;
3635
- }) {
3636
- const theme = ROLE_THEMES.agent;
3637
- const s = turn.summary;
3638
- const hasTokens = s.totalTokens.input > 0 || s.totalTokens.output > 0;
3639
-
3640
- // First / conclusion agent-message indices are pre-computed by
3641
- // buildMegaRows — the "conclusion" uses a heuristic that skips short
3642
- // codas following a task-notification (so e.g. "Ship done..." beats
3643
- // a later "Acknowledged — the background task closed out").
3644
- const firstAgentIdx = s.firstAgentIndex ?? -1;
3645
- const finalAgentIdx = s.finalAgentIndex ?? -1;
3646
- const firstAgentRow = firstAgentIdx >= 0 ? turn.rows[firstAgentIdx] : undefined;
3647
- const finalAgentRow =
3648
- finalAgentIdx >= 0 && finalAgentIdx !== firstAgentIdx ? turn.rows[finalAgentIdx] : undefined;
3649
-
3650
- // Middle list excludes the first and conclusion messages. Any coda
3651
- // messages (e.g. "Acknowledged ...") remain visible here as ordinary
3652
- // steps — they happened, they just aren't the semantic conclusion.
3653
- const middleRows = turn.rows.filter((_, i) => {
3654
- if (i === firstAgentIdx) return false;
3655
- if (finalAgentRow && i === finalAgentIdx) return false;
3656
- return true;
3657
- });
3658
-
3659
- // Local state: whether the first / last message is rendered in full
3660
- // markdown form inline (instead of the 2-line preview).
3661
- const [firstExpanded, setFirstExpanded] = useState(false);
3662
- const [lastExpanded, setLastExpanded] = useState(false);
3663
-
3664
- // Stop click propagation on interactive children so clicks on the
3665
- // expandable message areas don't bubble up and trigger onClick on the
3666
- // whole row — we want explicit controls only.
3667
- const stop = (e: React.MouseEvent) => e.stopPropagation();
3668
-
3669
- return (
3670
- <div
3671
- ref={refCb}
3672
- data-sl-row-index={turn.firstPrimaryIndex}
3673
- data-sl-toffset={turn.tOffsetMs ?? 0}
3674
- style={{
3675
- display: "grid",
3676
- gridTemplateColumns: "20px 84px 1fr auto auto",
3677
- columnGap: 12,
3678
- alignItems: "start",
3679
- padding: "14px 12px 0 12px",
3680
- borderBottom: "1px solid var(--af-border-subtle)",
3681
- transition: "background 0.08s",
3682
- scrollMarginTop: stickyOffset,
3683
- }}
3684
- >
3685
- {/* Col 1 — empty spacer to keep the Agent pill aligned with
3686
- TranscriptRow's pill column (which also has an empty 20px slot). */}
3687
- <span />
3688
-
3689
- {/* Col 2 — "Agent" role pill */}
3690
- <span
3691
- style={{
3692
- justifySelf: "start",
3693
- fontSize: 11,
3694
- fontWeight: 600,
3695
- padding: "3px 10px",
3696
- borderRadius: 4,
3697
- background: theme.bg,
3698
- color: theme.fg,
3699
- marginTop: 2,
3700
- }}
3701
- >
3702
- Agent
3703
- </span>
3704
-
3705
- {/* Col 3 — content (cold-resume notice · first · stats · steps · last · bottom bar) */}
3706
- <div
3707
- style={{
3708
- minWidth: 0,
3709
- display: "flex",
3710
- flexDirection: "column",
3711
- gap: 10,
3712
- paddingBottom: 10,
3713
- }}
3714
- >
3715
- {coldResume && (
3716
- <div onClick={stop}>
3717
- <ColdResumeNotice info={coldResume} model={model} />
3718
- </div>
2287
+ {coldResume && (
2288
+ <div onClick={stop}>
2289
+ <ColdResumeNotice info={coldResume} model={model} />
2290
+ </div>
3719
2291
  )}
3720
2292
 
3721
2293
  {/* 1. First agent message — clickable to expand into full markdown */}
@@ -4184,1226 +2756,340 @@ function TurnStatsLine({
4184
2756
  <>
4185
2757
  <span style={{ opacity: 0.5, marginLeft: 4 }}>·</span>
4186
2758
  <span style={{ fontFamily: "var(--font-mono)", fontSize: 10 }}>
4187
- {formatGap(durationMs)}
4188
- </span>
4189
- </>
4190
- )}
4191
- </div>
4192
-
4193
- {/* Last file path (Ghostty "└ ..." style) */}
4194
- {shortPath && (
4195
- <div
4196
- style={{
4197
- fontSize: 10,
4198
- color: "var(--af-text-tertiary)",
4199
- fontFamily: "var(--font-mono)",
4200
- marginTop: 1,
4201
- opacity: 0.75,
4202
- }}
4203
- title={lastFilePath}
4204
- >
4205
- └ {shortPath}
4206
- </div>
4207
- )}
4208
- </div>
4209
- );
4210
- }
4211
-
4212
- function ExpandedTurnHeader({
4213
- turn,
4214
- onClick,
4215
- coldResume,
4216
- model,
4217
- }: {
4218
- turn: TurnMegaRow;
4219
- onClick: () => void;
4220
- coldResume?: NonNullable<SessionEvent["coldResume"]>;
4221
- model?: string;
4222
- }) {
4223
- const s = turn.summary;
4224
- return (
4225
- <div
4226
- onClick={onClick}
4227
- style={{
4228
- display: "flex",
4229
- alignItems: "center",
4230
- gap: 8,
4231
- padding: "6px 12px 6px 18px",
4232
- marginLeft: 12,
4233
- borderLeft: "2px solid var(--af-accent)",
4234
- borderBottom: "1px dashed var(--af-border-subtle)",
4235
- fontSize: 11,
4236
- color: "var(--af-text-tertiary)",
4237
- cursor: "pointer",
4238
- background: "rgba(92, 132, 195, 0.04)",
4239
- }}
4240
- onMouseEnter={(e) => {
4241
- e.currentTarget.style.background = "rgba(92, 132, 195, 0.10)";
4242
- e.currentTarget.style.color = "var(--af-text-secondary)";
4243
- }}
4244
- onMouseLeave={(e) => {
4245
- e.currentTarget.style.background = "rgba(92, 132, 195, 0.04)";
4246
- e.currentTarget.style.color = "var(--af-text-tertiary)";
4247
- }}
4248
- >
4249
- <ChevronDown size={13} />
4250
- <span>
4251
- Hide turn · {s.agentMessages} message{s.agentMessages === 1 ? "" : "s"} · {s.toolCalls} tool
4252
- {s.toolCalls === 1 ? "" : "s"}
4253
- {turn.durationMs !== undefined ? ` · ${formatGap(turn.durationMs)}` : ""}
4254
- </span>
4255
- {coldResume && (
4256
- <span style={{ marginLeft: "auto" }}>
4257
- <ColdResumeChip info={coldResume} model={model} />
4258
- </span>
4259
- )}
4260
- </div>
4261
- );
4262
- }
4263
-
4264
- /** Matching collapse control at the bottom of an expanded turn. Mirrors
4265
- * the top ExpandedTurnHeader so after reading through the inner rows
4266
- * the user has a collapse button right where their eye lands. */
4267
- function ExpandedTurnFooter({ turn, onClick }: { turn: TurnMegaRow; onClick: () => void }) {
4268
- const s = turn.summary;
4269
- return (
4270
- <div
4271
- onClick={onClick}
4272
- style={{
4273
- display: "flex",
4274
- alignItems: "center",
4275
- gap: 8,
4276
- padding: "7px 12px 7px 18px",
4277
- marginLeft: 12,
4278
- marginBottom: 8,
4279
- borderLeft: "2px solid var(--af-accent)",
4280
- borderTop: "1px dashed var(--af-border-subtle)",
4281
- borderBottomLeftRadius: 6,
4282
- borderBottomRightRadius: 6,
4283
- fontSize: 11,
4284
- color: "var(--af-text-tertiary)",
4285
- cursor: "pointer",
4286
- background: "rgba(92, 132, 195, 0.04)",
4287
- }}
4288
- onMouseEnter={(e) => {
4289
- e.currentTarget.style.background = "rgba(92, 132, 195, 0.10)";
4290
- e.currentTarget.style.color = "var(--af-text-secondary)";
4291
- }}
4292
- onMouseLeave={(e) => {
4293
- e.currentTarget.style.background = "rgba(92, 132, 195, 0.04)";
4294
- e.currentTarget.style.color = "var(--af-text-tertiary)";
4295
- }}
4296
- >
4297
- <ChevronUp size={13} />
4298
- <span>
4299
- Collapse turn · {s.agentMessages} message{s.agentMessages === 1 ? "" : "s"} · {s.toolCalls}{" "}
4300
- tool{s.toolCalls === 1 ? "" : "s"}
4301
- {turn.durationMs !== undefined ? ` · ${formatGap(turn.durationMs)}` : ""}
4302
- </span>
4303
- </div>
4304
- );
4305
- }
4306
-
4307
- function TranscriptRow({
4308
- row,
4309
- selected,
4310
- onSelect,
4311
- refCb,
4312
- stickyOffset,
4313
- indented,
4314
- }: {
4315
- row: PresentationRow;
4316
- selected: boolean;
4317
- onSelect: () => void;
4318
- refCb: (el: HTMLDivElement | null) => void;
4319
- stickyOffset: number;
4320
- indented?: boolean;
4321
- }) {
4322
- const theme = ROLE_THEMES[row.kind];
4323
- const event = row.kind === "tool-group" ? row.events[0] : row.event;
4324
- const usage = event.usage;
4325
- const hasUsage = row.kind === "agent" && usage && (usage.input > 0 || usage.output > 0);
4326
- const isTeammateMsg = row.kind === "user" && !!row.event.teammateMessage;
4327
- const roleLabel = isTeammateMsg ? "Team Lead" : theme.label;
4328
-
4329
- const preview = rowPreview(row);
4330
-
4331
- return (
4332
- <div
4333
- ref={refCb}
4334
- onClick={onSelect}
4335
- data-sl-row-index={row.kind === "tool-group" ? row.events[0].index : row.event.index}
4336
- data-sl-toffset={row.tOffsetMs ?? 0}
4337
- style={{
4338
- display: "grid",
4339
- // Empty 20px prefix column keeps the role pill at the same x-offset
4340
- // as collapsed turn rows (which have a chevron there). Ensures the
4341
- // User/Agent/Tool tags align vertically in the transcript.
4342
- gridTemplateColumns: "20px 84px 1fr auto auto",
4343
- gap: 14,
4344
- alignItems: "center",
4345
- padding: "11px 12px",
4346
- paddingLeft: indented ? 28 : 12,
4347
- borderBottom: "1px solid var(--af-border-subtle)",
4348
- borderLeft: indented ? "2px solid var(--af-accent)" : "2px solid transparent",
4349
- marginLeft: indented ? 12 : 0,
4350
- cursor: "pointer",
4351
- background: selected
4352
- ? "var(--af-accent-subtle)"
4353
- : indented
4354
- ? "rgba(92, 132, 195, 0.03)"
4355
- : "transparent",
4356
- transition: "background 0.08s",
4357
- scrollMarginTop: stickyOffset,
4358
- }}
4359
- onMouseEnter={(e) => {
4360
- if (!selected) e.currentTarget.style.background = "var(--af-surface-hover)";
4361
- }}
4362
- onMouseLeave={(e) => {
4363
- if (!selected) e.currentTarget.style.background = "transparent";
4364
- }}
4365
- >
4366
- {/* Empty chevron-slot column — keeps the pill column aligned with
4367
- collapsed-turn rows that do render a chevron. */}
4368
- <span />
4369
- <span
4370
- style={{
4371
- justifySelf: "start",
4372
- fontSize: 11,
4373
- fontWeight: 600,
4374
- padding: "3px 10px",
4375
- borderRadius: 4,
4376
- background: theme.bg,
4377
- color: theme.fg,
4378
- }}
4379
- >
4380
- {roleLabel}
4381
- </span>
4382
-
4383
- <span
4384
- style={{
4385
- fontSize: 13,
4386
- color: row.kind === "error" ? "var(--af-danger)" : "var(--af-text)",
4387
- overflow: "hidden",
4388
- textOverflow: "ellipsis",
4389
- whiteSpace: "nowrap",
4390
- display: "flex",
4391
- alignItems: "center",
4392
- gap: 8,
4393
- }}
4394
- >
4395
- {row.kind === "tool-group" && (
4396
- <Wrench size={13} style={{ color: "var(--af-text-tertiary)", flexShrink: 0 }} />
4397
- )}
4398
- <span
4399
- style={{
4400
- overflow: "hidden",
4401
- textOverflow: "ellipsis",
4402
- whiteSpace: "nowrap",
4403
- }}
4404
- >
4405
- {row.kind === "tool-group" ? (
4406
- <ToolGroupLabel toolNames={row.toolNames} count={row.count} />
4407
- ) : (
4408
- preview
4409
- )}
4410
- </span>
4411
- </span>
4412
-
4413
- {hasUsage && usage ? <TokenChip usage={usage} /> : <span />}
4414
-
4415
- <span
4416
- style={{
4417
- fontFamily: "var(--font-mono)",
4418
- fontSize: 11,
4419
- color: "var(--af-text-tertiary)",
4420
- minWidth: 64,
4421
- textAlign: "right",
4422
- }}
4423
- >
4424
- {formatOffset(row.tOffsetMs)}
4425
- </span>
4426
- </div>
4427
- );
4428
- }
4429
-
4430
- /* -------- Token chip w/ hover tooltip -------- */
4431
-
4432
- /** Pretty inline label for a collapsed tool-group row. Shows unique tool
4433
- * names with bolded names and subtle "×N" counts. */
4434
- function ToolGroupLabel({
4435
- toolNames,
4436
- count,
4437
- }: {
4438
- toolNames: { name: string; count: number }[];
4439
- count: number;
4440
- }) {
4441
- const MAX = 4;
4442
- const shown = toolNames.slice(0, MAX);
4443
- const overflow = toolNames.length - MAX;
4444
- return (
4445
- <span>
4446
- {shown.map((t, i) => (
4447
- <span key={t.name}>
4448
- {i > 0 && <span style={{ color: "var(--af-text-tertiary)", margin: "0 6px" }}>·</span>}
4449
- <b>{shortenToolName(t.name)}</b>
4450
- {t.count > 1 && (
4451
- <span
4452
- style={{
4453
- color: "var(--af-text-tertiary)",
4454
- fontWeight: 400,
4455
- marginLeft: 4,
4456
- fontFamily: "var(--font-mono)",
4457
- fontSize: 11,
4458
- }}
4459
- >
4460
- ×{t.count}
4461
- </span>
4462
- )}
4463
- </span>
4464
- ))}
4465
- {overflow > 0 && (
4466
- <span
4467
- style={{
4468
- color: "var(--af-text-tertiary)",
4469
- marginLeft: 8,
4470
- fontSize: 11,
4471
- }}
4472
- >
4473
- +{overflow} more
4474
- </span>
4475
- )}
4476
- {count > 3 && (
4477
- <span
4478
- style={{
4479
- color: "var(--af-text-tertiary)",
4480
- marginLeft: 10,
4481
- fontSize: 11,
4482
- fontFamily: "var(--font-mono)",
4483
- }}
4484
- >
4485
- ({count} calls)
4486
- </span>
4487
- )}
4488
- </span>
4489
- );
4490
- }
4491
-
4492
- // TokenChip, TurnTokenChip moved to ./session-view/token-stats.tsx
4493
-
4494
- /* ------------------------------------------------------------------ */
4495
- /* Debug list */
4496
- /* ------------------------------------------------------------------ */
4497
-
4498
- // DebugList moved to ./session-view/debug-list.tsx
4499
-
4500
- /* ------------------------------------------------------------------ */
4501
- /* SubagentDrawer */
4502
- /* */
4503
- /* Opened when the user clicks a sub-agent lane bar on the mini-map. */
4504
- /* Renders everything we know about the run — type pill, background */
4505
- /* badge, timing range, duration, stats, the full prompt the parent */
4506
- /* dispatched, the final agent text, and a "Jump to parent" action */
4507
- /* that scrolls the transcript to the Agent tool_use row that kicked */
4508
- /* this subagent off. */
4509
- /* ------------------------------------------------------------------ */
4510
-
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,
4528
- onJumpToParent,
4529
- onClearFocus,
4530
- }: {
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;
4538
- }) {
4539
- const totalTokens = workflows.reduce((n, w) => n + w.totalTokens, 0);
4540
- const totalTools = workflows.reduce((n, w) => n + w.toolCallCount, 0);
4541
- return (
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
- >
4551
- <div
4552
- style={{
4553
- display: "flex",
4554
- alignItems: "center",
4555
- gap: 10,
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))",
4560
- }}
4561
- >
4562
- <span
4563
- style={{
4564
- display: "inline-flex",
4565
- alignItems: "center",
4566
- gap: 7,
4567
- fontSize: 13,
4568
- fontWeight: 600,
4569
- color: "var(--af-text)",
4570
- }}
4571
- >
4572
- <WorkflowIcon size={14} style={{ color: "#EA580C" }} />
4573
- Workflows
4574
- </span>
4575
- <span
2759
+ {formatGap(durationMs)}
2760
+ </span>
2761
+ </>
2762
+ )}
2763
+ </div>
2764
+
2765
+ {/* Last file path (Ghostty "└ ..." style) */}
2766
+ {shortPath && (
2767
+ <div
4576
2768
  style={{
4577
- fontSize: 11.5,
2769
+ fontSize: 10,
4578
2770
  color: "var(--af-text-tertiary)",
4579
2771
  fontFamily: "var(--font-mono)",
2772
+ marginTop: 1,
2773
+ opacity: 0.75,
4580
2774
  }}
2775
+ title={lastFilePath}
4581
2776
  >
4582
- {workflows.length} run{workflows.length === 1 ? "" : "s"} ·{" "}
4583
- {spawnedAgentCount.toLocaleString()} agents · {totalTools.toLocaleString()} tool calls ·{" "}
4584
- {formatTokens(totalTokens)} tok orchestrated
4585
- </span>
4586
- </div>
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
- ))}
4599
- </div>
4600
- </section>
2777
+ └ {shortPath}
2778
+ </div>
2779
+ )}
2780
+ </div>
4601
2781
  );
4602
2782
  }
4603
2783
 
4604
- function WorkflowCard({
4605
- w,
4606
- focused = false,
4607
- selectedAgentKey,
4608
- onOpenAgent,
4609
- onJumpToParent,
4610
- onClearFocus,
2784
+ function ExpandedTurnHeader({
2785
+ turn,
2786
+ onClick,
2787
+ coldResume,
2788
+ model,
4611
2789
  }: {
4612
- w: WorkflowRun;
4613
- focused?: boolean;
4614
- selectedAgentKey?: string | null;
4615
- onOpenAgent: (runId: string, agent: WorkflowRun["agents"][number]) => void;
4616
- onJumpToParent: (toolUseId?: string) => void;
4617
- onClearFocus?: () => void;
2790
+ turn: TurnMegaRow;
2791
+ onClick: () => void;
2792
+ coldResume?: NonNullable<SessionEvent["coldResume"]>;
2793
+ model?: string;
4618
2794
  }) {
4619
- const [open, setOpen] = useState(focused);
4620
- const cardRef = useRef<HTMLDivElement>(null);
4621
- // Focused via a minimap lane click — expand and scroll this run into view.
4622
- useEffect(() => {
4623
- if (focused) {
4624
- setOpen(true);
4625
- cardRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
4626
- }
4627
- }, [focused]);
4628
- const color = workflowColor(w.status);
4629
- const dur = w.durationMs !== undefined ? formatGap(w.durationMs) : "—";
4630
- const startOff = formatOffset(w.startTOffsetMs);
4631
-
2795
+ const s = turn.summary;
4632
2796
  return (
4633
2797
  <div
4634
- ref={cardRef}
2798
+ onClick={onClick}
4635
2799
  style={{
4636
- borderTop: "1px solid var(--af-border-subtle)",
4637
- scrollMarginTop: 120,
2800
+ display: "flex",
2801
+ alignItems: "center",
2802
+ gap: 8,
2803
+ padding: "6px 12px 6px 18px",
2804
+ marginLeft: 12,
2805
+ borderLeft: "2px solid var(--af-accent)",
2806
+ borderBottom: "1px dashed var(--af-border-subtle)",
2807
+ fontSize: 11,
2808
+ color: "var(--af-text-tertiary)",
2809
+ cursor: "pointer",
2810
+ background: "rgba(92, 132, 195, 0.04)",
2811
+ }}
2812
+ onMouseEnter={(e) => {
2813
+ e.currentTarget.style.background = "rgba(92, 132, 195, 0.10)";
2814
+ e.currentTarget.style.color = "var(--af-text-secondary)";
2815
+ }}
2816
+ onMouseLeave={(e) => {
2817
+ e.currentTarget.style.background = "rgba(92, 132, 195, 0.04)";
2818
+ e.currentTarget.style.color = "var(--af-text-tertiary)";
4638
2819
  }}
4639
2820
  >
4640
- {/* Collapsed header — click to expand */}
4641
- <button
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
- }}
4648
- style={{
4649
- width: "100%",
4650
- display: "flex",
4651
- alignItems: "center",
4652
- gap: 10,
4653
- padding: "10px 14px",
4654
- background: "transparent",
4655
- border: "none",
4656
- cursor: "pointer",
4657
- textAlign: "left",
4658
- color: "inherit",
4659
- flexWrap: "wrap",
4660
- }}
4661
- >
4662
- <span style={{ color: "var(--af-text-tertiary)", display: "inline-flex" }}>
4663
- {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
4664
- </span>
4665
- <span
4666
- style={{
4667
- fontFamily: "var(--font-mono)",
4668
- fontSize: 13,
4669
- fontWeight: 600,
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",
4675
- minWidth: 0,
4676
- overflow: "hidden",
4677
- textOverflow: "ellipsis",
4678
- whiteSpace: "nowrap",
4679
- }}
4680
- title={w.name}
4681
- >
4682
- {w.name}
4683
- </span>
4684
- <span
4685
- style={{
4686
- fontSize: 9.5,
4687
- fontWeight: 600,
4688
- padding: "2px 7px",
4689
- borderRadius: 100,
4690
- background: color,
4691
- color: "#fff",
4692
- textTransform: "uppercase",
4693
- letterSpacing: "0.04em",
4694
- }}
4695
- >
4696
- {w.status}
4697
- </span>
4698
- <span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 12 }}>
4699
- <span
4700
- style={{
4701
- fontSize: 12,
4702
- fontWeight: 700,
4703
- padding: "2px 10px",
4704
- borderRadius: 100,
4705
- background: "rgba(234,88,12,0.14)",
4706
- color: "#EA580C",
4707
- fontFamily: "var(--font-mono)",
4708
- whiteSpace: "nowrap",
4709
- }}
4710
- >
4711
- {w.agentCount} agents
4712
- </span>
4713
- <WfMiniStat icon={<Wrench size={11} />} value={`${w.toolCallCount.toLocaleString()}`} />
4714
- <WfMiniStat value={`${formatTokens(w.totalTokens)} tok`} />
4715
- <WfMiniStat icon={<Clock size={11} />} value={dur} />
2821
+ <ChevronDown size={13} />
2822
+ <span>
2823
+ Hide turn · {s.agentMessages} message{s.agentMessages === 1 ? "" : "s"} · {s.toolCalls} tool
2824
+ {s.toolCalls === 1 ? "" : "s"}
2825
+ {turn.durationMs !== undefined ? ` · ${formatGap(turn.durationMs)}` : ""}
2826
+ </span>
2827
+ {coldResume && (
2828
+ <span style={{ marginLeft: "auto" }}>
2829
+ <ColdResumeChip info={coldResume} model={model} />
4716
2830
  </span>
4717
- </button>
4718
-
4719
- {/* Expanded detail */}
4720
- {open && (
4721
- <div style={{ padding: "0 14px 14px 38px", display: "flex", flexDirection: "column", gap: 12 }}>
4722
- {w.description && (
4723
- <div style={{ fontSize: 12.5, color: "var(--af-text-secondary)", lineHeight: 1.5 }}>
4724
- {w.description}
4725
- </div>
4726
- )}
4727
-
4728
- <div
4729
- style={{
4730
- display: "flex",
4731
- gap: 14,
4732
- flexWrap: "wrap",
4733
- fontSize: 11,
4734
- fontFamily: "var(--font-mono)",
4735
- color: "var(--af-text-tertiary)",
4736
- }}
4737
- >
4738
- <span>starts at {startOff}</span>
4739
- {w.model && <span>· {w.model}</span>}
4740
- <span>· runId {w.runId}</span>
4741
- {w.parentToolUseId && (
4742
- <button
4743
- type="button"
4744
- onClick={() => onJumpToParent(w.parentToolUseId)}
4745
- style={{
4746
- marginLeft: "auto",
4747
- fontSize: 11,
4748
- color: "var(--af-accent)",
4749
- background: "transparent",
4750
- border: "none",
4751
- cursor: "pointer",
4752
- padding: 0,
4753
- fontFamily: "var(--font-mono)",
4754
- }}
4755
- >
4756
- Jump to timeline →
4757
- </button>
4758
- )}
4759
- </div>
4760
-
4761
- {w.agents.length > 0 ? (
4762
- <WorkflowPhaseTabs
4763
- w={w}
4764
- selectedAgentKey={selectedAgentKey}
4765
- onOpenAgent={onOpenAgent}
4766
- />
4767
- ) : (
4768
- w.phases.length > 0 && (
4769
- <div>
4770
- <SectionLabel>Phases</SectionLabel>
4771
- <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
4772
- {w.phases.map((p, i) => (
4773
- <span
4774
- key={`${p.title}-${i}`}
4775
- title={p.detail}
4776
- style={{
4777
- display: "inline-flex",
4778
- alignItems: "center",
4779
- gap: 5,
4780
- fontSize: 11,
4781
- padding: "3px 9px",
4782
- borderRadius: 4,
4783
- background: "var(--af-border-subtle)",
4784
- color: "var(--af-text)",
4785
- }}
4786
- >
4787
- <b style={{ fontWeight: 600, fontFamily: "var(--font-mono)" }}>{p.title}</b>
4788
- {p.detail && (
4789
- <span style={{ color: "var(--af-text-tertiary)", maxWidth: 360, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
4790
- {p.detail}
4791
- </span>
4792
- )}
4793
- </span>
4794
- ))}
4795
- </div>
4796
- </div>
4797
- )
4798
- )}
4799
-
4800
- {w.logs.length > 0 && <WorkflowRunLog logs={w.logs} />}
4801
- </div>
4802
2831
  )}
4803
2832
  </div>
4804
2833
  );
4805
2834
  }
4806
2835
 
4807
- /** Compact model label: "claude-opus-4-8[1m]" → "opus-4-8", "claude-haiku-4-5-20251001" → "haiku-4-5". */
4808
- function shortenModel(model: string): string {
4809
- return model
4810
- .replace(/^claude-/, "")
4811
- .replace(/\[1m\]$/, "")
4812
- .replace(/-\d{6,}$/, "")
4813
- .trim();
4814
- }
4815
-
4816
- /** Status dot color for a single workflow-spawned agent. */
4817
- function agentStateColor(state?: string): string {
4818
- const s = (state ?? "").toLowerCase();
4819
- if (s === "done" || s === "completed" || s === "success") return "#10B981";
4820
- if (s === "error" || s === "failed") return "#EF4444";
4821
- if (s === "running" || s === "active" || s === "in_progress") return "#F59E0B";
4822
- return "#8A8580";
4823
- }
4824
-
4825
- /** Per-phase tabs for a workflow run. Each tab lists the agents (actions)
4826
- * that ran in that phase so you can review what actually happened. */
4827
- function WorkflowPhaseTabs({
4828
- w,
4829
- selectedAgentKey,
4830
- onOpenAgent,
4831
- }: {
4832
- w: WorkflowRun;
4833
- selectedAgentKey?: string | null;
4834
- onOpenAgent: (runId: string, agent: WorkflowRun["agents"][number]) => void;
4835
- }) {
4836
- // Build the phase list from meta.phases (ordered), bucketing agents by
4837
- // phaseIndex. Agents whose phaseIndex isn't in meta.phases fall into an
4838
- // appended bucket keyed by their phaseTitle so nothing is dropped.
4839
- const groups = useMemo(() => {
4840
- const byIndex = new Map<number, WorkflowRun["agents"]>();
4841
- const extras = new Map<string, WorkflowRun["agents"]>();
4842
- for (const a of w.agents) {
4843
- if (a.phaseIndex && a.phaseIndex >= 1 && a.phaseIndex <= w.phases.length) {
4844
- const arr = byIndex.get(a.phaseIndex) ?? [];
4845
- arr.push(a);
4846
- byIndex.set(a.phaseIndex, arr);
4847
- } else {
4848
- const key = a.phaseTitle ?? "Other";
4849
- const arr = extras.get(key) ?? [];
4850
- arr.push(a);
4851
- extras.set(key, arr);
4852
- }
4853
- }
4854
- const out = w.phases.map((p, i) => ({
4855
- key: `p${i + 1}`,
4856
- title: p.title,
4857
- detail: p.detail,
4858
- agents: byIndex.get(i + 1) ?? [],
4859
- }));
4860
- for (const [title, agents] of extras) {
4861
- out.push({ key: `x-${title}`, title, detail: undefined, agents });
4862
- }
4863
- return out;
4864
- }, [w]);
4865
-
4866
- // Default to the first phase that actually has agents.
4867
- const firstWithAgents = groups.find((g) => g.agents.length > 0)?.key ?? groups[0]?.key ?? "";
4868
- const [active, setActive] = useState(firstWithAgents);
4869
- const activeGroup = groups.find((g) => g.key === active) ?? groups[0];
4870
-
2836
+ /** Matching collapse control at the bottom of an expanded turn. Mirrors
2837
+ * the top ExpandedTurnHeader so after reading through the inner rows
2838
+ * the user has a collapse button right where their eye lands. */
2839
+ function ExpandedTurnFooter({ turn, onClick }: { turn: TurnMegaRow; onClick: () => void }) {
2840
+ const s = turn.summary;
4871
2841
  return (
4872
- <div>
4873
- <SectionLabel>Phases &amp; actions</SectionLabel>
4874
- {/* Phase tab strip */}
4875
- <div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 10 }}>
4876
- {groups.map((g) => {
4877
- const isActive = g.key === active;
4878
- return (
4879
- <button
4880
- key={g.key}
4881
- onClick={() => setActive(g.key)}
4882
- style={{
4883
- display: "inline-flex",
4884
- alignItems: "center",
4885
- gap: 6,
4886
- fontSize: 11.5,
4887
- fontWeight: isActive ? 600 : 500,
4888
- padding: "4px 10px",
4889
- borderRadius: 6,
4890
- border: "1px solid",
4891
- borderColor: isActive ? "#EA580C" : "var(--af-border-subtle)",
4892
- background: isActive ? "rgba(234,88,12,0.12)" : "transparent",
4893
- color: isActive ? "#EA580C" : "var(--af-text-secondary)",
4894
- cursor: "pointer",
4895
- fontFamily: "var(--font-mono)",
4896
- }}
4897
- >
4898
- {g.title}
4899
- <span
4900
- style={{
4901
- fontSize: 10,
4902
- fontWeight: 700,
4903
- padding: "0px 5px",
4904
- borderRadius: 100,
4905
- background: isActive ? "#EA580C" : "var(--af-border-subtle)",
4906
- color: isActive ? "#fff" : "var(--af-text-tertiary)",
4907
- }}
4908
- >
4909
- {g.agents.length}
4910
- </span>
4911
- </button>
4912
- );
4913
- })}
4914
- </div>
4915
-
4916
- {/* Active phase body */}
4917
- {activeGroup && (
4918
- <div>
4919
- {activeGroup.detail && (
4920
- <div
4921
- style={{
4922
- fontSize: 11.5,
4923
- color: "var(--af-text-tertiary)",
4924
- marginBottom: 8,
4925
- fontStyle: "italic",
4926
- }}
4927
- >
4928
- {activeGroup.detail}
4929
- </div>
4930
- )}
4931
- {activeGroup.agents.length === 0 ? (
4932
- <div style={{ fontSize: 12, color: "var(--af-text-tertiary)", padding: "8px 0" }}>
4933
- No agents recorded for this phase.
4934
- </div>
4935
- ) : (
4936
- <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
4937
- {activeGroup.agents.map((a) => (
4938
- <WorkflowAgentRow
4939
- key={`${a.index}-${a.agentId ?? a.label}`}
4940
- a={a}
4941
- selected={selectedAgentKey === `${w.runId}:${a.agentId ?? a.index}`}
4942
- onOpen={() => onOpenAgent(w.runId, a)}
4943
- />
4944
- ))}
4945
- </div>
4946
- )}
4947
- </div>
4948
- )}
2842
+ <div
2843
+ onClick={onClick}
2844
+ style={{
2845
+ display: "flex",
2846
+ alignItems: "center",
2847
+ gap: 8,
2848
+ padding: "7px 12px 7px 18px",
2849
+ marginLeft: 12,
2850
+ marginBottom: 8,
2851
+ borderLeft: "2px solid var(--af-accent)",
2852
+ borderTop: "1px dashed var(--af-border-subtle)",
2853
+ borderBottomLeftRadius: 6,
2854
+ borderBottomRightRadius: 6,
2855
+ fontSize: 11,
2856
+ color: "var(--af-text-tertiary)",
2857
+ cursor: "pointer",
2858
+ background: "rgba(92, 132, 195, 0.04)",
2859
+ }}
2860
+ onMouseEnter={(e) => {
2861
+ e.currentTarget.style.background = "rgba(92, 132, 195, 0.10)";
2862
+ e.currentTarget.style.color = "var(--af-text-secondary)";
2863
+ }}
2864
+ onMouseLeave={(e) => {
2865
+ e.currentTarget.style.background = "rgba(92, 132, 195, 0.04)";
2866
+ e.currentTarget.style.color = "var(--af-text-tertiary)";
2867
+ }}
2868
+ >
2869
+ <ChevronUp size={13} />
2870
+ <span>
2871
+ Collapse turn · {s.agentMessages} message{s.agentMessages === 1 ? "" : "s"} · {s.toolCalls}{" "}
2872
+ tool{s.toolCalls === 1 ? "" : "s"}
2873
+ {turn.durationMs !== undefined ? ` · ${formatGap(turn.durationMs)}` : ""}
2874
+ </span>
4949
2875
  </div>
4950
2876
  );
4951
2877
  }
4952
2878
 
4953
- /** One agent (action) inside a phase — collapsed shows label + metering;
4954
- * expanding reveals the task prompt and the returned result. */
4955
- /** Full transcript detail for one workflow agent — fetched on demand from
4956
- * /api/workflow-agent. Mirrors the parser's WorkflowAgentDetail. */
4957
- type WfAgentDetail = {
4958
- prompt?: string;
4959
- finalText?: string;
4960
- steps: { index: number; tool: string; preview: string; full?: string; isError?: boolean }[];
4961
- toolCalls: { name: string; count: number }[];
4962
- toolCallCount: number;
4963
- assistantMessageCount: number;
4964
- eventCount: number;
4965
- totalUsage: { input: number; output: number; cacheRead: number; cacheWrite: number };
4966
- model?: string;
4967
- durationMs?: number;
4968
- };
4969
-
4970
- function WorkflowAgentRow({
4971
- a,
2879
+ function TranscriptRow({
2880
+ row,
4972
2881
  selected,
4973
- onOpen,
2882
+ onSelect,
2883
+ refCb,
2884
+ stickyOffset,
2885
+ indented,
4974
2886
  }: {
4975
- a: WorkflowRun["agents"][number];
2887
+ row: PresentationRow;
4976
2888
  selected: boolean;
4977
- onOpen: () => void;
2889
+ onSelect: () => void;
2890
+ refCb: (el: HTMLDivElement | null) => void;
2891
+ stickyOffset: number;
2892
+ indented?: boolean;
4978
2893
  }) {
4979
- const dur = a.durationMs !== undefined ? formatGap(a.durationMs) : undefined;
2894
+ const theme = ROLE_THEMES[row.kind];
2895
+ const event = row.kind === "tool-group" ? row.events[0] : row.event;
2896
+ const usage = event.usage;
2897
+ const hasUsage = row.kind === "agent" && usage && (usage.input > 0 || usage.output > 0);
2898
+ const isTeammateMsg = row.kind === "user" && !!row.event.teammateMessage;
2899
+ const roleLabel = isTeammateMsg ? "Team Lead" : theme.label;
2900
+
2901
+ const preview = rowPreview(row);
2902
+
4980
2903
  return (
4981
- <button
4982
- onClick={onOpen}
2904
+ <div
2905
+ ref={refCb}
2906
+ onClick={onSelect}
2907
+ data-sl-row-index={row.kind === "tool-group" ? row.events[0].index : row.event.index}
2908
+ data-sl-toffset={row.tOffsetMs ?? 0}
4983
2909
  style={{
4984
- width: "100%",
4985
- display: "flex",
2910
+ display: "grid",
2911
+ // Empty 20px prefix column keeps the role pill at the same x-offset
2912
+ // as collapsed turn rows (which have a chevron there). Ensures the
2913
+ // User/Agent/Tool tags align vertically in the transcript.
2914
+ gridTemplateColumns: "20px 84px 1fr auto auto",
2915
+ gap: 14,
4986
2916
  alignItems: "center",
4987
- gap: 8,
4988
- padding: "8px 10px",
4989
- textAlign: "left",
2917
+ padding: "11px 12px",
2918
+ paddingLeft: indented ? 28 : 12,
2919
+ borderBottom: "1px solid var(--af-border-subtle)",
2920
+ borderLeft: indented ? "2px solid var(--af-accent)" : "2px solid transparent",
2921
+ marginLeft: indented ? 12 : 0,
4990
2922
  cursor: "pointer",
4991
- border: "1px solid",
4992
- borderColor: selected ? "#EA580C" : "var(--af-border-subtle)",
4993
- borderRadius: 6,
4994
2923
  background: selected
4995
- ? "rgba(234,88,12,0.08)"
4996
- : "var(--af-surface-subtle, rgba(120,115,108,0.04))",
4997
- color: "inherit",
4998
- flexWrap: "wrap",
2924
+ ? "var(--af-accent-subtle)"
2925
+ : indented
2926
+ ? "rgba(92, 132, 195, 0.03)"
2927
+ : "transparent",
2928
+ transition: "background 0.08s",
2929
+ scrollMarginTop: stickyOffset,
2930
+ }}
2931
+ onMouseEnter={(e) => {
2932
+ if (!selected) e.currentTarget.style.background = "var(--af-surface-hover)";
2933
+ }}
2934
+ onMouseLeave={(e) => {
2935
+ if (!selected) e.currentTarget.style.background = "transparent";
4999
2936
  }}
5000
2937
  >
5001
- <span
5002
- title={a.state}
5003
- style={{ width: 7, height: 7, borderRadius: "50%", background: agentStateColor(a.state), flexShrink: 0 }}
5004
- />
2938
+ {/* Empty chevron-slot column — keeps the pill column aligned with
2939
+ collapsed-turn rows that do render a chevron. */}
2940
+ <span />
5005
2941
  <span
5006
2942
  style={{
5007
- fontFamily: "var(--font-mono)",
5008
- fontSize: 12,
2943
+ justifySelf: "start",
2944
+ fontSize: 11,
5009
2945
  fontWeight: 600,
5010
- color: "var(--af-text)",
5011
- minWidth: 0,
5012
- overflow: "hidden",
5013
- textOverflow: "ellipsis",
5014
- whiteSpace: "nowrap",
5015
- maxWidth: 280,
2946
+ padding: "3px 10px",
2947
+ borderRadius: 4,
2948
+ background: theme.bg,
2949
+ color: theme.fg,
5016
2950
  }}
5017
- title={a.label}
5018
2951
  >
5019
- {a.label}
5020
- </span>
5021
- {a.model && (
5022
- <span style={{ fontSize: 10, color: "var(--af-text-tertiary)", fontFamily: "var(--font-mono)" }}>
5023
- {shortenModel(a.model)}
5024
- </span>
5025
- )}
5026
- {a.lastToolSummary && (
5027
- <span
5028
- style={{
5029
- fontSize: 11,
5030
- color: "var(--af-text-tertiary)",
5031
- overflow: "hidden",
5032
- textOverflow: "ellipsis",
5033
- whiteSpace: "nowrap",
5034
- maxWidth: 240,
5035
- }}
5036
- title={a.lastToolSummary}
5037
- >
5038
- → {a.lastToolSummary}
5039
- </span>
5040
- )}
5041
- <span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
5042
- {dur && <WfMiniStat icon={<Clock size={10} />} value={dur} />}
5043
- {a.tokens !== undefined && <WfMiniStat value={`${formatTokens(a.tokens)} tok`} />}
5044
- {a.toolCalls !== undefined && <WfMiniStat icon={<Wrench size={10} />} value={`${a.toolCalls}`} />}
5045
- <span style={{ color: selected ? "#EA580C" : "var(--af-text-tertiary)", display: "inline-flex" }}>
5046
- <ChevronRight size={14} />
5047
- </span>
2952
+ {roleLabel}
5048
2953
  </span>
5049
- </button>
5050
- );
5051
- }
5052
2954
 
5053
- /** Right side-sheet showing one workflow agent's full transcript — Task,
5054
- * ordered Steps, and Result — fetched on demand. Kept out of the card so the
5055
- * Workflows tab stays short even for 100+ step agents. */
5056
- function WorkflowAgentDrawer({
5057
- sessionId,
5058
- runId,
5059
- agent,
5060
- onClose,
5061
- }: {
5062
- sessionId: string;
5063
- runId: string;
5064
- agent: WorkflowRun["agents"][number];
5065
- onClose: () => void;
5066
- }) {
5067
- const [detail, setDetail] = useState<WfAgentDetail | null>(null);
5068
- const [loading, setLoading] = useState(false);
5069
- const [failed, setFailed] = useState(false);
5070
-
5071
- useEffect(() => {
5072
- setDetail(null);
5073
- setFailed(false);
5074
- if (!agent.agentId) return;
5075
- setLoading(true);
5076
- const ctrl = new AbortController();
5077
- const url = `/api/workflow-agent?session=${encodeURIComponent(sessionId)}&run=${encodeURIComponent(runId)}&agent=${encodeURIComponent(agent.agentId)}`;
5078
- fetch(url, { signal: ctrl.signal })
5079
- .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
5080
- .then((d: WfAgentDetail) => setDetail(d))
5081
- .catch((e) => {
5082
- if (e?.name !== "AbortError") setFailed(true);
5083
- })
5084
- .finally(() => setLoading(false));
5085
- return () => ctrl.abort();
5086
- }, [sessionId, runId, agent.agentId]);
5087
-
5088
- const prompt = detail?.prompt ?? agent.promptPreview;
5089
- const finalText = detail?.finalText ?? agent.resultPreview;
5090
- const dur = detail?.durationMs ?? agent.durationMs;
5091
- const model = detail?.model ?? agent.model;
5092
- const toolCount = detail?.toolCallCount ?? agent.toolCalls;
5093
- const tok = detail ? detail.totalUsage.input + detail.totalUsage.output : agent.tokens;
5094
-
5095
- return (
5096
- <div>
5097
- {/* Sticky title bar */}
5098
- <div
2955
+ <span
5099
2956
  style={{
5100
- padding: "14px 18px",
5101
- borderBottom: "1px solid var(--af-border-subtle)",
2957
+ fontSize: 13,
2958
+ color: row.kind === "error" ? "var(--af-danger)" : "var(--af-text)",
2959
+ overflow: "hidden",
2960
+ textOverflow: "ellipsis",
2961
+ whiteSpace: "nowrap",
5102
2962
  display: "flex",
5103
2963
  alignItems: "center",
5104
- gap: 10,
5105
- background: "var(--af-surface)",
5106
- position: "sticky",
5107
- top: 0,
5108
- zIndex: 1,
2964
+ gap: 8,
5109
2965
  }}
5110
2966
  >
5111
- <span
5112
- title={agent.state}
5113
- style={{ width: 8, height: 8, borderRadius: "50%", background: agentStateColor(agent.state), flexShrink: 0 }}
5114
- />
2967
+ {row.kind === "tool-group" && (
2968
+ <Wrench size={13} style={{ color: "var(--af-text-tertiary)", flexShrink: 0 }} />
2969
+ )}
5115
2970
  <span
5116
2971
  style={{
5117
- fontSize: 13.5,
5118
- fontWeight: 600,
5119
- fontFamily: "var(--font-mono)",
5120
- color: "var(--af-text)",
5121
- flex: 1,
5122
- minWidth: 0,
5123
2972
  overflow: "hidden",
5124
2973
  textOverflow: "ellipsis",
5125
2974
  whiteSpace: "nowrap",
5126
2975
  }}
5127
- title={agent.label}
5128
2976
  >
5129
- {agent.label}
2977
+ {row.kind === "tool-group" ? (
2978
+ <ToolGroupLabel toolNames={row.toolNames} count={row.count} />
2979
+ ) : (
2980
+ preview
2981
+ )}
5130
2982
  </span>
5131
- <button
5132
- onClick={onClose}
5133
- style={{ background: "transparent", border: "none", cursor: "pointer", color: "var(--af-text-tertiary)", padding: 4 }}
5134
- aria-label="Close"
5135
- >
5136
- <X size={16} />
5137
- </button>
5138
- </div>
2983
+ </span>
5139
2984
 
5140
- {/* Meta strip */}
5141
- <div
2985
+ {hasUsage && usage ? <TokenChip usage={usage} /> : <span />}
2986
+
2987
+ <span
5142
2988
  style={{
5143
- padding: "9px 18px",
5144
2989
  fontFamily: "var(--font-mono)",
5145
2990
  fontSize: 11,
5146
2991
  color: "var(--af-text-tertiary)",
5147
- borderBottom: "1px solid var(--af-border-subtle)",
5148
- display: "flex",
5149
- gap: 12,
5150
- flexWrap: "wrap",
2992
+ minWidth: 64,
2993
+ textAlign: "right",
5151
2994
  }}
5152
2995
  >
5153
- {agent.phaseTitle && <span>phase {agent.phaseTitle}</span>}
5154
- {agent.state && <span>· {agent.state}</span>}
5155
- {model && <span>· {shortenModel(model)}</span>}
5156
- {dur !== undefined && <span>· {formatGap(dur)}</span>}
5157
- {toolCount !== undefined && <span>· {toolCount} tools</span>}
5158
- {tok !== undefined && <span>· {formatTokens(tok)} tok</span>}
5159
- </div>
5160
-
5161
- {/* Body — each section collapsed by default; click to expand. */}
5162
- <div style={{ padding: "12px 18px 18px", display: "flex", flexDirection: "column", gap: 8 }}>
5163
- {loading && (
5164
- <div style={{ fontSize: 11.5, color: "var(--af-text-tertiary)", fontStyle: "italic" }}>
5165
- Loading full transcript…
5166
- </div>
5167
- )}
5168
- {failed && (
5169
- <div style={{ fontSize: 11.5, color: "var(--af-text-tertiary)", fontStyle: "italic" }}>
5170
- Full transcript unavailable — showing the journal summary.
5171
- </div>
5172
- )}
5173
-
5174
- {prompt && (
5175
- <DrawerCollapsible title="Task" hint={detail ? undefined : "preview"}>
5176
- <div style={{ fontSize: 12, color: "var(--af-text-secondary)", whiteSpace: "pre-wrap", lineHeight: 1.5 }}>
5177
- {prompt}
5178
- </div>
5179
- </DrawerCollapsible>
5180
- )}
5181
-
5182
- {detail && detail.steps.length > 0 && (
5183
- <DrawerCollapsible
5184
- title="Steps"
5185
- count={detail.steps.length}
5186
- hint={detail.toolCalls.slice(0, 6).map((t) => `${shortenToolName(t.name)} ×${t.count}`).join(" · ")}
5187
- >
5188
- <div style={{ border: "1px solid var(--af-border-subtle)", borderRadius: 6, background: "var(--af-surface)" }}>
5189
- {detail.steps.map((s) => (
5190
- <WorkflowStepRow key={s.index} s={s} />
5191
- ))}
5192
- </div>
5193
- </DrawerCollapsible>
5194
- )}
5195
-
5196
- {finalText && (
5197
- <DrawerCollapsible title="Result" hint={detail ? undefined : "preview"}>
5198
- <div
5199
- style={{
5200
- fontFamily: "var(--font-mono)",
5201
- fontSize: 11,
5202
- color: "var(--af-text-secondary)",
5203
- whiteSpace: "pre-wrap",
5204
- wordBreak: "break-word",
5205
- lineHeight: 1.5,
5206
- background: "var(--af-surface)",
5207
- border: "1px solid var(--af-border-subtle)",
5208
- borderRadius: 4,
5209
- padding: "8px 10px",
5210
- }}
5211
- >
5212
- {finalText}
5213
- </div>
5214
- </DrawerCollapsible>
5215
- )}
5216
- </div>
2996
+ {formatOffset(row.tOffsetMs)}
2997
+ </span>
5217
2998
  </div>
5218
2999
  );
5219
3000
  }
5220
3001
 
5221
- /** Collapsible section inside the agent sheet — default collapsed. */
5222
- function DrawerCollapsible({
5223
- title,
3002
+ /* -------- Token chip w/ hover tooltip -------- */
3003
+
3004
+ /** Pretty inline label for a collapsed tool-group row. Shows unique tool
3005
+ * names with bolded names and subtle "×N" counts. */
3006
+ function ToolGroupLabel({
3007
+ toolNames,
5224
3008
  count,
5225
- hint,
5226
- defaultOpen = false,
5227
- children,
5228
3009
  }: {
5229
- title: string;
5230
- count?: number;
5231
- hint?: string;
5232
- defaultOpen?: boolean;
5233
- children: React.ReactNode;
3010
+ toolNames: { name: string; count: number }[];
3011
+ count: number;
5234
3012
  }) {
5235
- const [open, setOpen] = useState(defaultOpen);
5236
- return (
5237
- <div style={{ border: "1px solid var(--af-border-subtle)", borderRadius: 8, overflow: "hidden" }}>
5238
- <button
5239
- onClick={() => setOpen((v) => !v)}
5240
- style={{
5241
- width: "100%",
5242
- display: "flex",
5243
- alignItems: "center",
5244
- gap: 8,
5245
- padding: "9px 12px",
5246
- background: open ? "var(--af-surface-subtle, rgba(120,115,108,0.05))" : "transparent",
5247
- border: "none",
5248
- cursor: "pointer",
5249
- textAlign: "left",
5250
- color: "inherit",
5251
- }}
5252
- >
5253
- <span style={{ color: "var(--af-text-tertiary)", display: "inline-flex" }}>
5254
- {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
5255
- </span>
5256
- <span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.04em", color: "var(--af-text-secondary)" }}>
5257
- {title}
5258
- </span>
5259
- {count !== undefined && (
5260
- <span
5261
- style={{
5262
- fontSize: 10,
5263
- fontWeight: 700,
5264
- padding: "1px 7px",
5265
- borderRadius: 100,
5266
- background: "rgba(234,88,12,0.14)",
5267
- color: "#EA580C",
5268
- fontFamily: "var(--font-mono)",
5269
- }}
5270
- >
5271
- {count}
5272
- </span>
5273
- )}
5274
- {hint && (
5275
- <span
5276
- style={{
5277
- marginLeft: "auto",
5278
- fontSize: 10.5,
5279
- color: "var(--af-text-tertiary)",
5280
- fontFamily: "var(--font-mono)",
5281
- overflow: "hidden",
5282
- textOverflow: "ellipsis",
5283
- whiteSpace: "nowrap",
5284
- maxWidth: 280,
5285
- }}
5286
- >
5287
- {hint}
5288
- </span>
5289
- )}
5290
- </button>
5291
- {open && <div style={{ padding: "0 12px 12px" }}>{children}</div>}
5292
- </div>
5293
- );
5294
- }
5295
-
5296
- /** One step row — click to reveal the full tool input (e.g. the complete
5297
- * multi-line bash command) when it's longer than the one-line preview. */
5298
- function WorkflowStepRow({ s }: { s: WfAgentDetail["steps"][number] }) {
5299
- const [open, setOpen] = useState(false);
5300
- const expandable = !!s.full;
3013
+ const MAX = 4;
3014
+ const shown = toolNames.slice(0, MAX);
3015
+ const overflow = toolNames.length - MAX;
5301
3016
  return (
5302
- <div style={{ borderTop: s.index === 1 ? "none" : "1px solid var(--af-border-subtle)" }}>
5303
- <div
5304
- onClick={() => expandable && setOpen((v) => !v)}
5305
- style={{
5306
- display: "flex",
5307
- alignItems: "baseline",
5308
- gap: 8,
5309
- padding: "4px 10px",
5310
- fontFamily: "var(--font-mono)",
5311
- fontSize: 11,
5312
- cursor: expandable ? "pointer" : "default",
5313
- }}
5314
- >
5315
- <span style={{ color: "var(--af-text-tertiary)", minWidth: 28, textAlign: "right", flexShrink: 0 }}>
5316
- {s.index}
5317
- </span>
5318
- <span style={{ fontWeight: 600, color: s.isError ? "#EF4444" : "var(--af-text)", flexShrink: 0, minWidth: 104 }}>
5319
- {s.isError ? "⚠ " : ""}
5320
- {shortenToolName(s.tool)}
3017
+ <span>
3018
+ {shown.map((t, i) => (
3019
+ <span key={t.name}>
3020
+ {i > 0 && <span style={{ color: "var(--af-text-tertiary)", margin: "0 6px" }}>·</span>}
3021
+ <b>{shortenToolName(t.name)}</b>
3022
+ {t.count > 1 && (
3023
+ <span
3024
+ style={{
3025
+ color: "var(--af-text-tertiary)",
3026
+ fontWeight: 400,
3027
+ marginLeft: 4,
3028
+ fontFamily: "var(--font-mono)",
3029
+ fontSize: 11,
3030
+ }}
3031
+ >
3032
+ ×{t.count}
3033
+ </span>
3034
+ )}
5321
3035
  </span>
3036
+ ))}
3037
+ {overflow > 0 && (
5322
3038
  <span
5323
- style={{ flex: 1, color: "var(--af-text-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
5324
- title={s.preview}
5325
- >
5326
- {s.preview}
5327
- </span>
5328
- {expandable && (
5329
- <span style={{ color: "var(--af-text-tertiary)", display: "inline-flex", flexShrink: 0 }}>
5330
- {open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
5331
- </span>
5332
- )}
5333
- </div>
5334
- {open && s.full && (
5335
- <pre
5336
3039
  style={{
5337
- margin: 0,
5338
- padding: "6px 10px 10px 40px",
5339
- fontFamily: "var(--font-mono)",
3040
+ color: "var(--af-text-tertiary)",
3041
+ marginLeft: 8,
5340
3042
  fontSize: 11,
5341
- whiteSpace: "pre-wrap",
5342
- wordBreak: "break-word",
5343
- color: "var(--af-text-secondary)",
5344
- background: "var(--af-surface-subtle, rgba(120,115,108,0.05))",
5345
- lineHeight: 1.5,
5346
3043
  }}
5347
3044
  >
5348
- {s.full}
5349
- </pre>
3045
+ +{overflow} more
3046
+ </span>
5350
3047
  )}
5351
- </div>
5352
- );
5353
- }
5354
-
5355
- /** Collapsible coarse run log (▶ / ✓ task markers) — secondary to the
5356
- * per-phase agent view. */
5357
- function WorkflowRunLog({ logs }: { logs: string[] }) {
5358
- const [open, setOpen] = useState(false);
5359
- return (
5360
- <div>
5361
- <button
5362
- onClick={() => setOpen((v) => !v)}
5363
- style={{
5364
- display: "inline-flex",
5365
- alignItems: "center",
5366
- gap: 5,
5367
- fontSize: 10,
5368
- fontWeight: 600,
5369
- textTransform: "uppercase",
5370
- letterSpacing: "0.04em",
5371
- color: "var(--af-text-tertiary)",
5372
- background: "transparent",
5373
- border: "none",
5374
- cursor: "pointer",
5375
- padding: 0,
5376
- }}
5377
- >
5378
- {open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
5379
- Run log ({logs.length})
5380
- </button>
5381
- {open && (
5382
- <div
3048
+ {count > 3 && (
3049
+ <span
5383
3050
  style={{
5384
- marginTop: 6,
3051
+ color: "var(--af-text-tertiary)",
3052
+ marginLeft: 10,
3053
+ fontSize: 11,
5385
3054
  fontFamily: "var(--font-mono)",
5386
- fontSize: 11.5,
5387
- lineHeight: 1.7,
5388
- color: "var(--af-text-secondary)",
5389
- background: "var(--af-surface-subtle, rgba(120,115,108,0.05))",
5390
- border: "1px solid var(--af-border-subtle)",
5391
- borderRadius: 6,
5392
- padding: "8px 12px",
5393
- maxHeight: 240,
5394
- overflowY: "auto",
5395
- whiteSpace: "pre-wrap",
5396
3055
  }}
5397
3056
  >
5398
- {logs.map((l, i) => (
5399
- <div key={i}>{l}</div>
5400
- ))}
5401
- </div>
3057
+ ({count} calls)
3058
+ </span>
5402
3059
  )}
5403
- </div>
3060
+ </span>
5404
3061
  );
5405
3062
  }
5406
3063
 
3064
+ // TokenChip, TurnTokenChip moved to ./session-view/token-stats.tsx
3065
+
3066
+ /* ------------------------------------------------------------------ */
3067
+ /* Debug list */
3068
+ /* ------------------------------------------------------------------ */
3069
+
3070
+ // DebugList moved to ./session-view/debug-list.tsx
3071
+
3072
+ /* ------------------------------------------------------------------ */
3073
+ /* SubagentDrawer */
3074
+ /* */
3075
+ /* Opened when the user clicks a sub-agent lane bar on the mini-map. */
3076
+ /* Renders everything we know about the run — type pill, background */
3077
+ /* badge, timing range, duration, stats, the full prompt the parent */
3078
+ /* dispatched, the final agent text, and a "Jump to parent" action */
3079
+ /* that scrolls the transcript to the Agent tool_use row that kicked */
3080
+ /* this subagent off. */
3081
+ /* ------------------------------------------------------------------ */
3082
+
3083
+ // SubagentDrawer moved to ./session-view/subagent-drawer.tsx
3084
+
3085
+ /* ------------------------------------------------------------------ */
3086
+ /* Workflows panel */
3087
+ /* ------------------------------------------------------------------ */
3088
+
3089
+ // WorkflowsPanel, WorkflowCard, WorkflowPhaseTabs, WorkflowAgentRow,
3090
+ // WorkflowAgentDrawer, DrawerCollapsible, WorkflowStepRow, WorkflowRunLog,
3091
+ // shortenModel, agentStateColor, WfAgentDetail moved to ./session-view/workflows-panel.tsx
3092
+
5407
3093
  // WfMiniStat, SectionLabel, StatCell, TokenLine moved to ./session-view/workflows-shared.tsx
5408
3094
 
5409
3095
  /* ------------------------------------------------------------------ */