fleetlens 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/app/apps/web/.next/BUILD_ID +1 -1
  2. package/app/apps/web/.next/build-manifest.json +3 -3
  3. package/app/apps/web/.next/prerender-manifest.json +3 -3
  4. package/app/apps/web/.next/server/app/_global-error.html +1 -1
  5. package/app/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/app/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/app/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/app/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/app/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/app/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/app/apps/web/.next/server/app/_not-found/page.js +1 -1
  12. package/app/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  13. package/app/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/app/apps/web/.next/server/app/page.js +3 -3
  15. package/app/apps/web/.next/server/app/page.js.nft.json +1 -1
  16. package/app/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  17. package/app/apps/web/.next/server/app/parallelism/page.js +1 -1
  18. package/app/apps/web/.next/server/app/parallelism/page.js.nft.json +1 -1
  19. package/app/apps/web/.next/server/app/parallelism/page_client-reference-manifest.js +1 -1
  20. package/app/apps/web/.next/server/app/projects/[slug]/page.js +3 -3
  21. package/app/apps/web/.next/server/app/projects/[slug]/page.js.nft.json +1 -1
  22. package/app/apps/web/.next/server/app/projects/[slug]/page_client-reference-manifest.js +1 -1
  23. package/app/apps/web/.next/server/app/projects/page.js +1 -1
  24. package/app/apps/web/.next/server/app/projects/page.js.nft.json +1 -1
  25. package/app/apps/web/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  26. package/app/apps/web/.next/server/app/sessions/[id]/page.js +3 -2
  27. package/app/apps/web/.next/server/app/sessions/[id]/page.js.nft.json +1 -1
  28. package/app/apps/web/.next/server/app/sessions/[id]/page_client-reference-manifest.js +1 -1
  29. package/app/apps/web/.next/server/app/sessions/page.js +1 -1
  30. package/app/apps/web/.next/server/app/sessions/page.js.nft.json +1 -1
  31. package/app/apps/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  32. package/app/apps/web/.next/server/app/usage/page.js +1 -1
  33. package/app/apps/web/.next/server/app/usage/page.js.nft.json +1 -1
  34. package/app/apps/web/.next/server/app/usage/page_client-reference-manifest.js +1 -1
  35. package/app/apps/web/.next/server/chunks/[root-of-the-server]__0op9af~._.js +1 -1
  36. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__00z7zlm._.js +3 -0
  37. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__05lbtny._.js +12 -0
  38. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0ms7h0~._.js +1 -1
  39. package/app/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__08cr2u9._.js → [root-of-the-server]__0p~g4m6._.js} +2 -2
  40. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0w52fbc._.js +1 -1
  41. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0wvzyl7._.js +3 -0
  42. package/app/apps/web/.next/server/chunks/ssr/_09n75_5._.js +1 -1
  43. package/app/apps/web/.next/server/chunks/ssr/_0ph19-k._.js +12 -0
  44. package/app/apps/web/.next/server/chunks/ssr/_0u29mdd._.js +3 -0
  45. package/app/apps/web/.next/server/chunks/ssr/_0xies90._.js +1 -1
  46. package/app/apps/web/.next/server/chunks/ssr/apps_web_components_06j3c.0._.js +1 -1
  47. package/app/apps/web/.next/server/chunks/ssr/packages_parser_dist_0-jvt~a._.js +1 -1
  48. package/app/apps/web/.next/server/chunks/ssr/packages_parser_dist_analytics_0k4dgst.js +1 -1
  49. package/app/apps/web/.next/server/middleware-build-manifest.js +3 -3
  50. package/app/apps/web/.next/server/pages/500.html +1 -1
  51. package/app/apps/web/.next/server/server-reference-manifest.js +1 -1
  52. package/app/apps/web/.next/server/server-reference-manifest.json +1 -1
  53. package/app/apps/web/.next/static/chunks/03.mv4y4.w-vb.js +6 -0
  54. package/app/apps/web/.next/static/chunks/08983lz4~hffx.js +6 -0
  55. package/app/apps/web/.next/static/chunks/0fc6dk2qtokwh.js +1 -0
  56. package/app/apps/web/.next/static/chunks/0p1lpoh02zh8h.js +10 -0
  57. package/app/apps/web/.next/static/chunks/{03fw7f2obcvq~.js → 14q3xsfpkvuef.js} +2 -2
  58. package/app/apps/web/app/layout.tsx +2 -0
  59. package/app/apps/web/app/projects/[slug]/page.tsx +2 -0
  60. package/app/apps/web/app/sessions/[id]/page.tsx +27 -1
  61. package/app/apps/web/app/sessions/[id]/session-view.tsx +372 -793
  62. package/app/apps/web/app/sessions/[id]/team-tab/adapter.ts +633 -0
  63. package/app/apps/web/app/sessions/[id]/team-tab/format.ts +20 -0
  64. package/app/apps/web/app/sessions/[id]/team-tab/team-minimap.tsx +682 -0
  65. package/app/apps/web/app/sessions/[id]/team-tab/team-tab-client.tsx +78 -0
  66. package/app/apps/web/app/sessions/[id]/team-tab/team-table.tsx +796 -0
  67. package/app/apps/web/app/sessions/[id]/team-tab/turn-drawer.tsx +805 -0
  68. package/app/apps/web/app/sessions/[id]/tool-cards.tsx +461 -0
  69. package/app/apps/web/app/sessions/[id]/turn-steps.tsx +306 -0
  70. package/app/apps/web/app/sessions/sessions-grid.tsx +36 -5
  71. package/app/apps/web/components/live-sessions-widget.tsx +20 -6
  72. package/app/apps/web/components/team-badge.tsx +90 -0
  73. package/app/apps/web/package.json +1 -1
  74. package/app/apps/web/tsconfig.tsbuildinfo +1 -1
  75. package/dist/index.js +71 -13
  76. package/package.json +1 -1
  77. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0b..h_8._.js +0 -3
  78. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0cdd~8n._.js +0 -3
  79. package/app/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0~a~v00._.js +0 -3
  80. package/app/apps/web/.next/server/chunks/ssr/_0zv~jv-._.js +0 -12
  81. package/app/apps/web/.next/server/chunks/ssr/_12dtryh._.js +0 -12
  82. package/app/apps/web/.next/static/chunks/07oyxajlp3dvb.js +0 -6
  83. package/app/apps/web/.next/static/chunks/0a24elrqoqqep.js +0 -10
  84. package/app/apps/web/.next/static/chunks/0i4zodv86~~~i.js +0 -1
  85. package/app/apps/web/.next/static/chunks/0rbm60-qyshdm.js +0 -6
  86. /package/app/apps/web/.next/static/{2rEsiJssG30QsuNT0Lp9S → 0fPDRTdMQ-Y62h6fHNJXL}/_buildManifest.js +0 -0
  87. /package/app/apps/web/.next/static/{2rEsiJssG30QsuNT0Lp9S → 0fPDRTdMQ-Y62h6fHNJXL}/_clientMiddlewareManifest.js +0 -0
  88. /package/app/apps/web/.next/static/{2rEsiJssG30QsuNT0Lp9S → 0fPDRTdMQ-Y62h6fHNJXL}/_ssgManifest.js +0 -0
@@ -39,6 +39,25 @@ import { formatGap, formatOffset, formatRelative, formatTokens, shortId } from "
39
39
  import { LiveBadge } from "@/components/live-badge";
40
40
  import { AskClaudeButton, AskClaudeDrawer } from "@/components/ask-claude";
41
41
  import { TailMode } from "@/components/tail-mode";
42
+ import type { TimelineData } from "./team-tab/adapter";
43
+ import { TeamTabClient } from "./team-tab/team-tab-client";
44
+ import { TeamMinimap } from "./team-tab/team-minimap";
45
+ import {
46
+ ROLE_THEMES,
47
+ MAX_INLINE_STEPS,
48
+ rowPreview,
49
+ formatToolSummary,
50
+ shortenToolName,
51
+ TurnStepsList,
52
+ } from "./turn-steps";
53
+ import {
54
+ ToolUseCard,
55
+ ToolCardShell,
56
+ CodeBlock,
57
+ DiffView,
58
+ PathLabel,
59
+ type ToolUseInput,
60
+ } from "./tool-cards";
42
61
 
43
62
  /* ------------------------------------------------------------------ */
44
63
  /* Constants + theming */
@@ -52,63 +71,6 @@ const IDLE_THRESHOLD_MS = 2000;
52
71
  * them below the sticky area instead of hidden underneath. */
53
72
  const STICKY_HEADER_HEIGHT = 310;
54
73
 
55
- type RoleTheme = {
56
- label: string;
57
- bg: string;
58
- fg: string;
59
- mini: string;
60
- };
61
-
62
- /**
63
- * Palette modeled on Claude Managed Agents' Sessions view — muted,
64
- * desaturated tones that read cleanly against the cream background.
65
- * `bg`/`fg` drive the in-row pill; `mini` drives the timeline block fill.
66
- */
67
- const ROLE_THEMES: Record<PresentationRowKind, RoleTheme> = {
68
- user: {
69
- label: "User",
70
- bg: "rgba(201, 112, 112, 0.18)",
71
- fg: "#8B3A3A",
72
- mini: "#C97070",
73
- },
74
- agent: {
75
- label: "Agent",
76
- bg: "rgba(92, 132, 195, 0.18)",
77
- fg: "#2E4A7A",
78
- mini: "#5C84C3",
79
- },
80
- "tool-group": {
81
- label: "Tool",
82
- bg: "rgba(138, 133, 128, 0.16)",
83
- fg: "#44403C",
84
- mini: "#8A8580",
85
- },
86
- interrupt: {
87
- label: "Interrupt",
88
- bg: "rgba(217, 119, 6, 0.14)",
89
- fg: "#78350F",
90
- mini: "#D97706",
91
- },
92
- model: {
93
- label: "Model",
94
- bg: "rgba(168, 85, 247, 0.12)",
95
- fg: "#581C87",
96
- mini: "#A855F7",
97
- },
98
- error: {
99
- label: "Error",
100
- bg: "rgba(197, 48, 48, 0.18)",
101
- fg: "#8B1818",
102
- mini: "#C53030",
103
- },
104
- "task-notification": {
105
- label: "Task",
106
- bg: "rgba(100, 116, 139, 0.12)",
107
- fg: "#475569",
108
- mini: "#64748B",
109
- },
110
- };
111
-
112
74
  type FilterMode = "turns" | "meaningful" | "all" | PresentationRowKind;
113
75
 
114
76
  const FILTER_MODES: { value: FilterMode; label: string }[] = [
@@ -128,8 +90,59 @@ const FILTER_MODES: { value: FilterMode; label: string }[] = [
128
90
  /** Drawer width in px — reserved on the transcript's right edge when open. */
129
91
  const DRAWER_WIDTH = 460;
130
92
 
131
- export function SessionView({ session }: { session: SessionDetail }) {
132
- const [tab, setTab] = useState<"transcript" | "debug">("transcript");
93
+ type TabId = "transcript" | "team" | "debug";
94
+ const VALID_TABS: TabId[] = ["transcript", "team", "debug"];
95
+
96
+ export function SessionView({
97
+ session,
98
+ team,
99
+ teamLead,
100
+ }: {
101
+ session: SessionDetail;
102
+ team?: (TimelineData & { teamName: string }) | null;
103
+ teamLead?: { leadSessionId: string; teamName: string; agentName: string } | null;
104
+ }) {
105
+ const readHash = (): TabId => {
106
+ if (typeof window === "undefined") return "transcript";
107
+ const h = window.location.hash.replace("#", "");
108
+ if (VALID_TABS.includes(h as TabId)) return h as TabId;
109
+ return "transcript";
110
+ };
111
+ const [tab, setTabRaw] = useState<TabId>(readHash);
112
+ const setTab = (t: TabId) => {
113
+ setTabRaw(t);
114
+ window.history.replaceState(null, "", `#${t}`);
115
+ };
116
+ // Re-read hash on client mount and whenever the session changes (Next.js
117
+ // reuses this component across /sessions/[id] navigations, so useState
118
+ // doesn't reinitialize — the old tab would stick without this).
119
+ useEffect(() => {
120
+ setTabRaw(readHash());
121
+ }, [session.sessionId]);
122
+ useEffect(() => {
123
+ const onHash = () => {
124
+ const h = window.location.hash.replace("#", "");
125
+ if (VALID_TABS.includes(h as TabId)) setTabRaw(h as TabId);
126
+ };
127
+ window.addEventListener("hashchange", onHash);
128
+ return () => window.removeEventListener("hashchange", onHash);
129
+ }, []);
130
+ // Team tab — playhead (set by TeamTable as the user scrolls) and seek
131
+ // target (set by TeamMinimap clicks). Hoisted to session-view so the
132
+ // sticky-header TeamMinimap and the body's TeamTable share the same
133
+ // state without duplicating the minimap.
134
+ const [teamPlayheadMs, setTeamPlayheadMs] = useState<number | null>(null);
135
+ const [teamSeekTarget, setTeamSeekTarget] = useState<{
136
+ tsMs: number;
137
+ trackId?: string;
138
+ } | null>(null);
139
+ // Member track ids currently in the table's horizontal viewport, published
140
+ // by TeamTable on scroll. The sticky TeamMinimap uses this to mirror the
141
+ // table's current agents in its default (collapsed) lane set.
142
+ const [teamVisibleTrackIds, setTeamVisibleTrackIds] = useState<string[]>([]);
143
+ // When the user explicitly clicks "Show all" in the minimap we display
144
+ // every lane regardless of table scroll position.
145
+ const [teamExpanded, setTeamExpanded] = useState(false);
133
146
  const [filter, setFilter] = useState<FilterMode>("turns");
134
147
  const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
135
148
  const [selectedSubagentId, setSelectedSubagentId] = useState<string | null>(null);
@@ -164,11 +177,28 @@ export function SessionView({ session }: { session: SessionDetail }) {
164
177
  * screen real-estate once you're deep in reading. Hysteresis (60
165
178
  * vs 80) prevents flicker at the boundary. */
166
179
  const [collapsed, setCollapsed] = useState(false);
180
+ // When the user explicitly clicks the toggle, suppress auto-collapse
181
+ // for a short period so the scroll listener doesn't immediately undo it.
182
+ const manualPinRef = useRef(0);
183
+ const toggleCollapsed = () => {
184
+ setCollapsed((v) => !v);
185
+ manualPinRef.current = Date.now();
186
+ };
167
187
  useEffect(() => {
168
188
  const el = headerRef.current;
169
189
  if (!el) return;
170
- const main = el.closest("main") as HTMLElement | null;
171
- if (!main) return;
190
+ const mainEl = el.closest("main") as HTMLElement | null;
191
+ if (!mainEl) return;
192
+ // Team tab has its own fixed-height scroll container, so listening
193
+ // to `main` alone misses all vertical scroll when the inner table
194
+ // is what's moving. Pick the team table's scroll element as the
195
+ // scroll target when on team tab (it has data-team-scroll). Falls
196
+ // back to the main element for every other tab.
197
+ const teamScroll =
198
+ tab === "team"
199
+ ? (document.querySelector("[data-team-scroll]") as HTMLElement | null)
200
+ : null;
201
+ const main = teamScroll ?? mainEl;
172
202
  // Track scroll direction to avoid jitter. Collapse only when
173
203
  // scrolling DOWN past a threshold; expand only at scrollTop===0.
174
204
  // This eliminates the loop where collapse changes height → scroll
@@ -179,12 +209,15 @@ export function SessionView({ session }: { session: SessionDetail }) {
179
209
  const update = () => {
180
210
  raf = 0;
181
211
  if (locked) return;
212
+ // Respect manual pin — user clicked the toggle, don't override
213
+ // their choice for 2 seconds so the next scroll doesn't fight it.
214
+ if (Date.now() - manualPinRef.current < 2000) return;
182
215
  const y = main.scrollTop;
183
216
  const dir = y - lastY; // positive = scrolling down
184
217
  lastY = y;
185
218
  setCollapsed((prev) => {
186
- // Expand: only when user scrolled all the way to the top.
187
- if (prev && y <= 0) {
219
+ // Expand: when user scrolls UP (iOS-style reveal).
220
+ if (prev && dir < 0) {
188
221
  locked = true;
189
222
  setTimeout(() => { locked = false; }, 300);
190
223
  return false;
@@ -207,7 +240,7 @@ export function SessionView({ session }: { session: SessionDetail }) {
207
240
  main.removeEventListener("scroll", onScroll);
208
241
  if (raf) cancelAnimationFrame(raf);
209
242
  };
210
- }, []);
243
+ }, [tab]);
211
244
 
212
245
  // Run after every render that changed scrollIntent — the DOM is now
213
246
  // guaranteed up-to-date (including any drawer grid reflow).
@@ -227,6 +260,22 @@ export function SessionView({ session }: { session: SessionDetail }) {
227
260
  const { events, durationMs, totalUsage, model, eventCount, projectName } = session;
228
261
  const airTimeMs = session.airTimeMs ?? durationMs;
229
262
 
263
+ /** Inbound `<teammate-message>` events are cross-session team traffic
264
+ * wrapped in a synthetic user event. On the LEAD's transcript these are
265
+ * protocol noise (idle notifications, task assignments) — hide them and
266
+ * point the user to the Team tab. On a MEMBER's transcript the teammate
267
+ * messages ARE the task instructions from the lead, so we keep them
268
+ * visible — hiding them would strip all context from the member's work. */
269
+ const isLead = session.isTeamLead;
270
+ const teammateCount = useMemo(
271
+ () => (isLead ? events.filter((e) => e.teammateMessage).length : 0),
272
+ [events, isLead],
273
+ );
274
+ const visibleEvents = useMemo(
275
+ () => (teammateCount === 0 ? events : events.filter((e) => !e.teammateMessage)),
276
+ [events, teammateCount],
277
+ );
278
+
230
279
  // A session is "live" if its last event was within 45 seconds.
231
280
  const isSessionLive = (() => {
232
281
  if (!session.lastTimestamp) return false;
@@ -238,7 +287,7 @@ export function SessionView({ session }: { session: SessionDetail }) {
238
287
  const prMarkers = useMemo(() => detectPrMarkers(session), [session]);
239
288
 
240
289
  /** Build the full presentation stream once. */
241
- const allRows = useMemo(() => buildPresentation(events), [events]);
290
+ const allRows = useMemo(() => buildPresentation(visibleEvents), [visibleEvents]);
242
291
 
243
292
  /** Collapse the presentation stream into conversational turns (user →
244
293
  * agent loop → next user). Used by the "turns" filter mode. */
@@ -269,7 +318,7 @@ export function SessionView({ session }: { session: SessionDetail }) {
269
318
  return flattenMegaRows(megaRows, expandedTurns);
270
319
  }
271
320
  if (filter === "all")
272
- return allRowsAsRawRows(events).map((r) => ({
321
+ return allRowsAsRawRows(visibleEvents).map((r) => ({
273
322
  kind: "presentation",
274
323
  row: r,
275
324
  }));
@@ -281,10 +330,12 @@ export function SessionView({ session }: { session: SessionDetail }) {
281
330
  return r.kind === filter;
282
331
  })
283
332
  .map((r) => ({ kind: "presentation", row: r }));
284
- }, [filter, megaRows, expandedTurns, allRows, events]);
333
+ }, [filter, megaRows, expandedTurns, allRows, visibleEvents]);
285
334
 
286
335
  const selectedEvent =
287
- selectedIndex !== null ? (events.find((e) => e.index === selectedIndex) ?? null) : null;
336
+ selectedIndex !== null
337
+ ? (visibleEvents.find((e) => e.index === selectedIndex) ?? null)
338
+ : null;
288
339
  const selectedRow =
289
340
  selectedIndex !== null
290
341
  ? (allRows.find((r) => rowPrimaryIndex(r) === selectedIndex) ?? null)
@@ -331,9 +382,104 @@ export function SessionView({ session }: { session: SessionDetail }) {
331
382
  padding: "18px 40px 0",
332
383
  }}
333
384
  >
334
- {/* Collapsible top block breadcrumb + title + meta-stats + tabs.
335
- Hidden once the user scrolls past ~80px so the mini-map alone
336
- stays pinned and the transcript gets more vertical space. */}
385
+ {/* Always-visible compact bar: breadcrumb + tabs + toggle stays
386
+ accessible even when the header body is collapsed on scroll. */}
387
+ <div
388
+ style={{
389
+ display: "flex",
390
+ alignItems: "center",
391
+ gap: 12,
392
+ marginBottom: collapsed ? 6 : 6,
393
+ }}
394
+ >
395
+ <div
396
+ style={{
397
+ fontSize: 12,
398
+ color: "var(--af-text-tertiary)",
399
+ whiteSpace: "nowrap",
400
+ }}
401
+ >
402
+ <Link
403
+ href="/sessions"
404
+ style={{
405
+ color: "var(--af-text-tertiary)",
406
+ textDecoration: "none",
407
+ display: "inline-flex",
408
+ alignItems: "center",
409
+ gap: 4,
410
+ }}
411
+ >
412
+ <ArrowLeft size={12} /> Sessions
413
+ </Link>
414
+ <span style={{ margin: "0 8px" }}>/</span>
415
+ <span style={{ fontFamily: "var(--font-mono)" }}>sesn_{shortId(session.id)}</span>
416
+ {teamLead && (
417
+ <>
418
+ <span style={{ margin: "0 8px" }}>·</span>
419
+ <Link
420
+ href={`/sessions/${teamLead.leadSessionId}#team`}
421
+ style={{
422
+ color: "var(--af-accent)",
423
+ textDecoration: "none",
424
+ display: "inline-flex",
425
+ alignItems: "center",
426
+ gap: 4,
427
+ fontWeight: 500,
428
+ }}
429
+ title={`View team lead for ${teamLead.teamName}`}
430
+ >
431
+ {teamLead.agentName} in {teamLead.teamName} ↗
432
+ </Link>
433
+ </>
434
+ )}
435
+ </div>
436
+ <div className="af-tabs" style={{ flexShrink: 0, marginLeft: "auto" }}>
437
+ <button
438
+ className={`af-tab-btn ${tab === "transcript" ? "active" : ""}`}
439
+ onClick={() => setTab("transcript")}
440
+ >
441
+ Transcript
442
+ </button>
443
+ {team && (
444
+ <button
445
+ className={`af-tab-btn ${tab === "team" ? "active" : ""}`}
446
+ onClick={() => setTab("team")}
447
+ >
448
+ Team
449
+ </button>
450
+ )}
451
+ <button
452
+ className={`af-tab-btn ${tab === "debug" ? "active" : ""}`}
453
+ onClick={() => setTab("debug")}
454
+ >
455
+ Debug
456
+ </button>
457
+ </div>
458
+ <button
459
+ onClick={toggleCollapsed}
460
+ title={collapsed ? "Show session details" : "Hide session details"}
461
+ style={{
462
+ background: "var(--af-surface-hover)",
463
+ border: "1px solid var(--af-border-subtle)",
464
+ borderRadius: 4,
465
+ width: 24,
466
+ height: 24,
467
+ display: "flex",
468
+ alignItems: "center",
469
+ justifyContent: "center",
470
+ cursor: "pointer",
471
+ color: "var(--af-text-secondary)",
472
+ flexShrink: 0,
473
+ transition: "transform 0.2s ease",
474
+ transform: collapsed ? "rotate(180deg)" : "rotate(0deg)",
475
+ }}
476
+ >
477
+ <ChevronUp size={14} />
478
+ </button>
479
+ </div>
480
+
481
+ {/* Collapsible block — title + meta-stats + filter toolbar.
482
+ Hidden on scroll-down so the minimap gets more space. */}
337
483
  <div
338
484
  style={{
339
485
  maxHeight: collapsed ? 0 : 500,
@@ -345,29 +491,6 @@ export function SessionView({ session }: { session: SessionDetail }) {
345
491
  pointerEvents: collapsed ? "none" : "auto",
346
492
  }}
347
493
  >
348
- {/* Breadcrumb */}
349
- <div
350
- style={{
351
- fontSize: 12,
352
- color: "var(--af-text-tertiary)",
353
- marginBottom: 6,
354
- }}
355
- >
356
- <Link
357
- href="/sessions"
358
- style={{
359
- color: "var(--af-text-tertiary)",
360
- textDecoration: "none",
361
- display: "inline-flex",
362
- alignItems: "center",
363
- gap: 4,
364
- }}
365
- >
366
- <ArrowLeft size={12} /> Sessions
367
- </Link>
368
- <span style={{ margin: "0 8px" }}>/</span>
369
- <span style={{ fontFamily: "var(--font-mono)" }}>sesn_{shortId(session.id)}</span>
370
- </div>
371
494
 
372
495
  {/* Single-line compact header — title + inline dot-separated stats */}
373
496
  <div
@@ -453,7 +576,7 @@ export function SessionView({ session }: { session: SessionDetail }) {
453
576
  </span>
454
577
  </div>
455
578
 
456
- {/* Tabs + toolbar */}
579
+ {/* Toolbar (filter, copy, ask-claude) */}
457
580
  <div
458
581
  className="flex items-center"
459
582
  style={{
@@ -461,29 +584,6 @@ export function SessionView({ session }: { session: SessionDetail }) {
461
584
  paddingBottom: 8,
462
585
  }}
463
586
  >
464
- <div className="af-tabs">
465
- <button
466
- className={`af-tab-btn ${tab === "transcript" ? "active" : ""}`}
467
- onClick={() => setTab("transcript")}
468
- >
469
- Transcript
470
- </button>
471
- <button
472
- className={`af-tab-btn ${tab === "debug" ? "active" : ""}`}
473
- onClick={() => setTab("debug")}
474
- >
475
- Debug
476
- </button>
477
- </div>
478
-
479
- <div
480
- style={{
481
- width: 1,
482
- height: 20,
483
- background: "var(--af-border-subtle)",
484
- }}
485
- />
486
-
487
587
  <select
488
588
  value={filter}
489
589
  onChange={(e) =>
@@ -508,7 +608,7 @@ export function SessionView({ session }: { session: SessionDetail }) {
508
608
  {filter === "turns"
509
609
  ? `${megaRows.filter((m) => m.kind === "turn").length} turns · ${allRows.length} actions`
510
610
  : filter === "meaningful"
511
- ? `${allRows.length} rows (of ${events.length} raw events)`
611
+ ? `${allRows.length} rows (of ${visibleEvents.length} raw events)`
512
612
  : `${displayRows.length} rows`}
513
613
  </span>
514
614
 
@@ -553,23 +653,37 @@ export function SessionView({ session }: { session: SessionDetail }) {
553
653
  transition: "padding-bottom 0.2s ease",
554
654
  }}
555
655
  >
556
- <Minimap
557
- displayRows={displayRows}
558
- durationMs={durationMs ?? 0}
559
- selectedIndex={selectedIndex}
560
- onSelect={scrollToIndex}
561
- headerOffset={headerH}
562
- subagents={session.subagents}
563
- collapsed={collapsed}
564
- prMarkers={prMarkers}
565
- selectedSubagentId={selectedSubagentId}
566
- onSelectSubagent={(id) => {
567
- setSelectedSubagentId(id);
568
- // Close the event drawer so we don't have two drawers fighting
569
- // for the same right-side real estate.
570
- if (id) setSelectedIndex(null);
571
- }}
572
- />
656
+ {tab === "team" && team ? (
657
+ <TeamMinimap
658
+ data={team}
659
+ playheadMs={teamPlayheadMs}
660
+ onSeek={(tsMs, trackId) => {
661
+ setTeamSeekTarget({ tsMs, trackId });
662
+ if (teamExpanded) setTeamExpanded(false);
663
+ }}
664
+ expanded={teamExpanded}
665
+ onToggleExpanded={() => setTeamExpanded((v) => !v)}
666
+ visibleTrackIds={teamVisibleTrackIds}
667
+ />
668
+ ) : (
669
+ <Minimap
670
+ displayRows={displayRows}
671
+ durationMs={durationMs ?? 0}
672
+ selectedIndex={selectedIndex}
673
+ onSelect={scrollToIndex}
674
+ headerOffset={headerH}
675
+ subagents={session.subagents}
676
+ collapsed={collapsed}
677
+ prMarkers={prMarkers}
678
+ selectedSubagentId={selectedSubagentId}
679
+ onSelectSubagent={(id) => {
680
+ setSelectedSubagentId(id);
681
+ // Close the event drawer so we don't have two drawers fighting
682
+ // for the same right-side real estate.
683
+ if (id) setSelectedIndex(null);
684
+ }}
685
+ />
686
+ )}
573
687
  </div>
574
688
  </div>
575
689
 
@@ -584,16 +698,59 @@ export function SessionView({ session }: { session: SessionDetail }) {
584
698
  transition: "padding-right 0.15s ease",
585
699
  }}
586
700
  >
587
- {tab === "transcript" ? (
588
- <TranscriptList
589
- displayRows={displayRows}
590
- rowRefs={rowRefs}
591
- selectedIndex={selectedIndex}
592
- onSelect={setSelectedIndex}
593
- onToggleTurn={toggleTurn}
594
- stickyOffset={headerH + 16}
595
- isSessionLive={isSessionLive}
701
+ {tab === "team" && team ? (
702
+ <TeamTabClient
703
+ initial={team}
704
+ teamName={team.teamName}
705
+ playheadMs={teamPlayheadMs}
706
+ onPlayheadChange={setTeamPlayheadMs}
707
+ onVisibleTrackIdsChange={setTeamVisibleTrackIds}
708
+ seekTarget={teamSeekTarget}
596
709
  />
710
+ ) : tab === "transcript" ? (
711
+ <>
712
+ {teammateCount > 0 && (
713
+ <div
714
+ style={{
715
+ fontSize: 11,
716
+ color: "var(--af-text-tertiary, #888)",
717
+ background: "var(--af-surface-subtle, rgba(255,255,255,0.04))",
718
+ border: "1px solid var(--af-border-subtle, rgba(255,255,255,0.08))",
719
+ borderRadius: 6,
720
+ padding: "6px 10px",
721
+ marginBottom: 8,
722
+ }}
723
+ >
724
+ {teammateCount} inbound team message
725
+ {teammateCount === 1 ? "" : "s"} hidden —{" "}
726
+ <button
727
+ onClick={() => setTab("team")}
728
+ style={{
729
+ background: "none",
730
+ border: "none",
731
+ color: "var(--af-accent)",
732
+ cursor: "pointer",
733
+ padding: 0,
734
+ font: "inherit",
735
+ textDecoration: "underline",
736
+ }}
737
+ >
738
+ open the Team tab
739
+ </button>{" "}
740
+ to see them.
741
+ </div>
742
+ )}
743
+ <TranscriptList
744
+ displayRows={displayRows}
745
+ rowRefs={rowRefs}
746
+ selectedIndex={selectedIndex}
747
+ onSelect={setSelectedIndex}
748
+ onToggleTurn={toggleTurn}
749
+ stickyOffset={headerH + 16}
750
+ isSessionLive={isSessionLive}
751
+ team={team}
752
+ />
753
+ </>
597
754
  ) : (
598
755
  <DebugList events={events} />
599
756
  )}
@@ -742,53 +899,6 @@ function flattenMegaRows(megaRows: MegaRow[], expanded: Set<number>): DisplayRow
742
899
  return out;
743
900
  }
744
901
 
745
- function rowPreview(r: PresentationRow): string {
746
- switch (r.kind) {
747
- case "user":
748
- return r.displayPreview ?? r.event.preview;
749
- case "agent":
750
- return r.event.preview;
751
- case "tool-group":
752
- return formatToolSummary(r.toolNames);
753
- case "interrupt":
754
- return "Interrupted";
755
- case "model":
756
- return tokenSummaryLine(r.event.usage);
757
- case "error":
758
- return r.message;
759
- case "task-notification": {
760
- const icon =
761
- r.status === "success"
762
- ? "✓"
763
- : r.status === "failed"
764
- ? "✗"
765
- : r.status === "running"
766
- ? "…"
767
- : "•";
768
- return `${icon} ${r.summary}`;
769
- }
770
- }
771
- }
772
-
773
- /** Render a compact "Bash ×3 · Read ×2 · Grep · Edit" style summary.
774
- * Truncates after 4 unique tools with "+N more". */
775
- function formatToolSummary(toolNames: { name: string; count: number }[]): string {
776
- const MAX = 4;
777
- const shown = toolNames.slice(0, MAX);
778
- const overflow = toolNames.length - MAX;
779
- const parts = shown.map((t) => {
780
- const display = shortenToolName(t.name);
781
- return t.count > 1 ? `${display} ×${t.count}` : display;
782
- });
783
- if (overflow > 0) parts.push(`+${overflow} more`);
784
- return parts.join(" · ");
785
- }
786
-
787
- function tokenSummaryLine(u: SessionEvent["usage"]): string {
788
- if (!u) return "0 input · 0 output · 0 cache read · 0 cache write";
789
- return `${u.input} input · ${u.output} output · ${u.cacheRead} cache read · ${u.cacheWrite} cache write`;
790
- }
791
-
792
902
  /** Fallback for "All events" filter mode — wraps each raw event in a
793
903
  * synthetic PresentationRow so the transcript component can render
794
904
  * everything (attachments, meta, thinking, tool_result) without
@@ -2203,6 +2313,7 @@ function TranscriptList({
2203
2313
  onToggleTurn,
2204
2314
  stickyOffset,
2205
2315
  isSessionLive,
2316
+ team,
2206
2317
  }: {
2207
2318
  displayRows: DisplayRow[];
2208
2319
  rowRefs: React.MutableRefObject<Record<number, HTMLDivElement | null>>;
@@ -2211,6 +2322,7 @@ function TranscriptList({
2211
2322
  onToggleTurn: (firstPrimaryIndex: number) => void;
2212
2323
  stickyOffset: number;
2213
2324
  isSessionLive?: boolean;
2325
+ team?: (TimelineData & { teamName: string }) | null;
2214
2326
  }) {
2215
2327
  // Find the last collapsed turn index so we can mark it as in-progress
2216
2328
  // when the session is live.
@@ -2238,6 +2350,7 @@ function TranscriptList({
2238
2350
  stickyOffset={stickyOffset}
2239
2351
  onClick={() => onToggleTurn(idx)}
2240
2352
  inProgress={i === lastCollapsedTurnIdx}
2353
+ team={team}
2241
2354
  refCb={(el) => {
2242
2355
  rowRefs.current[idx] = el;
2243
2356
  }}
@@ -2326,17 +2439,13 @@ function IdleDivider({ gapMs }: { gapMs: number }) {
2326
2439
  /* 3. Final agent preview (answer / conclusion) */
2327
2440
  /* ------------------------------------------------------------------ */
2328
2441
 
2329
- /** Max number of steps shown inline in a collapsed turn. Larger turns are
2330
- * truncated with a "+N more" line; the user can click to expand the turn
2331
- * to see everything. */
2332
- const MAX_INLINE_STEPS = 12;
2333
-
2334
2442
  function CollapsedTurnRow({
2335
2443
  turn,
2336
2444
  onClick,
2337
2445
  refCb,
2338
2446
  stickyOffset,
2339
2447
  inProgress,
2448
+ team,
2340
2449
  }: {
2341
2450
  turn: TurnMegaRow;
2342
2451
  /** Fires when the user wants to fully expand this turn into the
@@ -2346,6 +2455,7 @@ function CollapsedTurnRow({
2346
2455
  refCb: (el: HTMLDivElement | null) => void;
2347
2456
  stickyOffset: number;
2348
2457
  inProgress?: boolean;
2458
+ team?: (TimelineData & { teamName: string }) | null;
2349
2459
  }) {
2350
2460
  const theme = ROLE_THEMES.agent;
2351
2461
  const s = turn.summary;
@@ -2387,7 +2497,7 @@ function CollapsedTurnRow({
2387
2497
  data-sl-toffset={turn.tOffsetMs ?? 0}
2388
2498
  style={{
2389
2499
  display: "grid",
2390
- gridTemplateColumns: "20px 74px 1fr auto auto",
2500
+ gridTemplateColumns: "20px 84px 1fr auto auto",
2391
2501
  columnGap: 12,
2392
2502
  alignItems: "start",
2393
2503
  padding: "14px 12px 0 12px",
@@ -2441,7 +2551,7 @@ function CollapsedTurnRow({
2441
2551
 
2442
2552
  {/* Stats line */}
2443
2553
  <div onClick={stop}>
2444
- <TurnStatsLine summary={s} durationMs={turn.durationMs} rows={turn.rows} />
2554
+ <TurnStatsLine summary={s} durationMs={turn.durationMs} rows={turn.rows} team={team} />
2445
2555
  </div>
2446
2556
 
2447
2557
  {/* 2. Steps list — each middle row as a compact bullet */}
@@ -2684,10 +2794,12 @@ function TurnStatsLine({
2684
2794
  summary,
2685
2795
  durationMs,
2686
2796
  rows,
2797
+ team,
2687
2798
  }: {
2688
2799
  summary: TurnSummary;
2689
2800
  durationMs?: number;
2690
2801
  rows: PresentationRow[];
2802
+ team?: (TimelineData & { teamName: string }) | null;
2691
2803
  }) {
2692
2804
  // Aggregate tool calls into activity categories.
2693
2805
  let editCount = 0;
@@ -2699,6 +2811,7 @@ function TurnStatsLine({
2699
2811
  let agentCount = 0;
2700
2812
  let otherToolCount = 0;
2701
2813
  let lastFilePath: string | undefined;
2814
+ const dispatchedNames: string[] = [];
2702
2815
 
2703
2816
  for (const r of rows) {
2704
2817
  if (r.kind !== "tool-group") continue;
@@ -2734,9 +2847,12 @@ function TurnStatsLine({
2734
2847
  case "Glob":
2735
2848
  globCount++;
2736
2849
  break;
2737
- case "Agent":
2850
+ case "Agent": {
2738
2851
  agentCount++;
2852
+ const agentName = typeof input?.name === "string" ? input.name : undefined;
2853
+ if (agentName) dispatchedNames.push(agentName);
2739
2854
  break;
2855
+ }
2740
2856
  default:
2741
2857
  otherToolCount++;
2742
2858
  break;
@@ -2778,11 +2894,58 @@ function TurnStatsLine({
2778
2894
  );
2779
2895
  }
2780
2896
  if (agentCount > 0) {
2781
- phrases.push(
2782
- <span key="agent">
2783
- dispatched <b style={{ fontWeight: 600 }}>{agentCount}</b> sub-agent{agentCount === 1 ? "" : "s"}
2784
- </span>,
2785
- );
2897
+ // When team data is available and we extracted agent names from the
2898
+ // tool inputs, render clickable member chips instead of a plain count.
2899
+ const nameToSession = team
2900
+ ? new Map(team.tracks.filter((t) => !t.isLead).map((t) => [t.label, t.id]))
2901
+ : null;
2902
+ if (dispatchedNames.length > 0 && nameToSession && nameToSession.size > 0) {
2903
+ phrases.push(
2904
+ <span key="agent" style={{ display: "inline-flex", alignItems: "center", gap: 4, flexWrap: "wrap" }}>
2905
+ dispatched{" "}
2906
+ {dispatchedNames.map((n, i) => {
2907
+ const sid = nameToSession.get(n);
2908
+ return sid ? (
2909
+ <Link
2910
+ key={i}
2911
+ href={`/sessions/${sid}`}
2912
+ style={{
2913
+ fontSize: 10,
2914
+ padding: "1px 7px",
2915
+ background: "var(--af-surface-hover)",
2916
+ border: "1px solid var(--af-border-subtle)",
2917
+ borderRadius: 10,
2918
+ color: "var(--af-text-secondary)",
2919
+ textDecoration: "none",
2920
+ fontWeight: 600,
2921
+ }}
2922
+ >
2923
+ {n}
2924
+ </Link>
2925
+ ) : (
2926
+ <span
2927
+ key={i}
2928
+ style={{
2929
+ fontSize: 10,
2930
+ padding: "1px 7px",
2931
+ background: "var(--af-surface-hover)",
2932
+ borderRadius: 10,
2933
+ color: "var(--af-text-tertiary)",
2934
+ }}
2935
+ >
2936
+ {n}
2937
+ </span>
2938
+ );
2939
+ })}
2940
+ </span>,
2941
+ );
2942
+ } else {
2943
+ phrases.push(
2944
+ <span key="agent">
2945
+ dispatched <b style={{ fontWeight: 600 }}>{agentCount}</b> sub-agent{agentCount === 1 ? "" : "s"}
2946
+ </span>,
2947
+ );
2948
+ }
2786
2949
  }
2787
2950
  if (otherToolCount > 0 && phrases.length === 0) {
2788
2951
  phrases.push(
@@ -2864,125 +3027,6 @@ function TurnStatsLine({
2864
3027
  );
2865
3028
  }
2866
3029
 
2867
- /** Bulleted list of everything that happened in the middle of a turn.
2868
- * Each row is a one-line compact entry with role + preview. Capped at
2869
- * MAX_INLINE_STEPS items with an overflow indicator. */
2870
- function TurnStepsList({ rows }: { rows: PresentationRow[] }) {
2871
- const overflow = Math.max(0, rows.length - MAX_INLINE_STEPS);
2872
- const shown = overflow > 0 ? rows.slice(-MAX_INLINE_STEPS) : rows;
2873
-
2874
- return (
2875
- <div
2876
- style={{
2877
- display: "flex",
2878
- flexDirection: "column",
2879
- gap: 2,
2880
- paddingLeft: 2,
2881
- borderLeft: "1px solid var(--af-border-subtle)",
2882
- paddingBlock: 2,
2883
- }}
2884
- >
2885
- {overflow > 0 && (
2886
- <div
2887
- style={{
2888
- fontSize: 11,
2889
- color: "var(--af-text-tertiary)",
2890
- fontStyle: "italic",
2891
- paddingLeft: 12,
2892
- paddingBottom: 2,
2893
- }}
2894
- >
2895
- {overflow} earlier step{overflow === 1 ? "" : "s"} …
2896
- </div>
2897
- )}
2898
- {shown.map((r, i) => (
2899
- <TurnStepLine key={i} row={r} />
2900
- ))}
2901
- </div>
2902
- );
2903
- }
2904
-
2905
- /** One step in the middle list. Renders a small role marker + the preview
2906
- * for that row, truncated to one line. */
2907
- function TurnStepLine({ row }: { row: PresentationRow }) {
2908
- const theme = ROLE_THEMES[row.kind];
2909
- const label = theme.label;
2910
- const preview = rowPreview(row);
2911
- return (
2912
- <div
2913
- style={{
2914
- display: "grid",
2915
- gridTemplateColumns: "46px 1fr",
2916
- columnGap: 8,
2917
- fontSize: 12,
2918
- lineHeight: 1.45,
2919
- color: row.kind === "error" ? "var(--af-danger)" : "var(--af-text-secondary)",
2920
- }}
2921
- >
2922
- <span
2923
- style={{
2924
- fontSize: 10,
2925
- fontWeight: 600,
2926
- background: theme.bg,
2927
- color: theme.fg,
2928
- padding: "1px 6px",
2929
- borderRadius: 3,
2930
- textAlign: "center",
2931
- justifySelf: "start",
2932
- maxWidth: "100%",
2933
- }}
2934
- >
2935
- {label}
2936
- </span>
2937
- <span
2938
- style={{
2939
- overflow: "hidden",
2940
- textOverflow: "ellipsis",
2941
- whiteSpace: "nowrap",
2942
- minWidth: 0,
2943
- }}
2944
- >
2945
- {row.kind === "tool-group" ? (
2946
- <span>
2947
- {row.toolNames.slice(0, 4).map((t, i) => (
2948
- <span key={t.name}>
2949
- {i > 0 && (
2950
- <span style={{ color: "var(--af-text-tertiary)", margin: "0 5px" }}>·</span>
2951
- )}
2952
- <b style={{ fontWeight: 600 }}>{shortenToolName(t.name)}</b>
2953
- {t.count > 1 && (
2954
- <span
2955
- style={{
2956
- color: "var(--af-text-tertiary)",
2957
- fontFamily: "var(--font-mono)",
2958
- marginLeft: 3,
2959
- }}
2960
- >
2961
- ×{t.count}
2962
- </span>
2963
- )}
2964
- </span>
2965
- ))}
2966
- {row.toolNames.length > 4 && (
2967
- <span
2968
- style={{
2969
- color: "var(--af-text-tertiary)",
2970
- marginLeft: 6,
2971
- fontSize: 10,
2972
- }}
2973
- >
2974
- +{row.toolNames.length - 4}
2975
- </span>
2976
- )}
2977
- </span>
2978
- ) : (
2979
- preview
2980
- )}
2981
- </span>
2982
- </div>
2983
- );
2984
- }
2985
-
2986
3030
  function ExpandedTurnHeader({ turn, onClick }: { turn: TurnMegaRow; onClick: () => void }) {
2987
3031
  const s = turn.summary;
2988
3032
  return (
@@ -3082,6 +3126,8 @@ function TranscriptRow({
3082
3126
  const event = row.kind === "tool-group" ? row.events[0] : row.event;
3083
3127
  const usage = event.usage;
3084
3128
  const hasUsage = row.kind === "agent" && usage && (usage.input > 0 || usage.output > 0);
3129
+ const isTeammateMsg = row.kind === "user" && !!row.event.teammateMessage;
3130
+ const roleLabel = isTeammateMsg ? "Team Lead" : theme.label;
3085
3131
 
3086
3132
  const preview = rowPreview(row);
3087
3133
 
@@ -3096,7 +3142,7 @@ function TranscriptRow({
3096
3142
  // Empty 20px prefix column keeps the role pill at the same x-offset
3097
3143
  // as collapsed turn rows (which have a chevron there). Ensures the
3098
3144
  // User/Agent/Tool tags align vertically in the transcript.
3099
- gridTemplateColumns: "20px 74px 1fr auto auto",
3145
+ gridTemplateColumns: "20px 84px 1fr auto auto",
3100
3146
  gap: 14,
3101
3147
  alignItems: "center",
3102
3148
  padding: "11px 12px",
@@ -3134,7 +3180,7 @@ function TranscriptRow({
3134
3180
  color: theme.fg,
3135
3181
  }}
3136
3182
  >
3137
- {theme.label}
3183
+ {roleLabel}
3138
3184
  </span>
3139
3185
 
3140
3186
  <span
@@ -4085,473 +4131,6 @@ function DrawerContent({ event, row }: { event: SessionEvent; row: PresentationR
4085
4131
  </div>
4086
4132
  );
4087
4133
  }
4088
-
4089
- /* ------------------------------------------------------------------ */
4090
- /* ToolUseCard — pretty rendering of tool_use blocks */
4091
- /* */
4092
- /* Claude Code emits tool calls with varying input schemas. Raw JSON */
4093
- /* dumps are unreadable; instead we dispatch on tool name and render */
4094
- /* a human-friendly card tailored to each common tool. */
4095
- /* ------------------------------------------------------------------ */
4096
-
4097
- type ToolUseInput = Record<string, unknown> | unknown;
4098
-
4099
- function shortenToolName(name: string): string {
4100
- // mcp__plugin_linear_linear__get_issue → linear.get_issue
4101
- const m = name.match(/^mcp__(?:plugin_)?([^_]+)_(?:\1_)?(.+)$/);
4102
- if (m) return `${m[1]}.${m[2]}`;
4103
- // mcp__claude_ai_Gmail__search_threads → gmail.search_threads
4104
- const m2 = name.match(/^mcp__claude_ai_([^_]+)__(.+)$/);
4105
- if (m2) return `${m2[1].toLowerCase()}.${m2[2]}`;
4106
- return name;
4107
- }
4108
-
4109
- /** Split an absolute path into (dir, filename) with an abbreviated dir
4110
- * (last 3 segments max) for compact display. */
4111
- function splitPath(p: string): { dir: string; file: string } {
4112
- const parts = p.split("/");
4113
- const file = parts[parts.length - 1] ?? p;
4114
- const dirParts = parts.slice(0, -1).filter(Boolean);
4115
- let dir = dirParts.join("/");
4116
- if (dirParts.length > 4) {
4117
- dir = "…/" + dirParts.slice(-3).join("/");
4118
- }
4119
- return { dir, file };
4120
- }
4121
-
4122
- function PathLabel({ path }: { path: string }) {
4123
- const { dir, file } = splitPath(path);
4124
- return (
4125
- <code
4126
- style={{
4127
- fontSize: 12,
4128
- background: "var(--af-border-subtle)",
4129
- padding: "2px 8px",
4130
- borderRadius: 4,
4131
- fontFamily: "var(--font-mono)",
4132
- color: "var(--af-text)",
4133
- }}
4134
- >
4135
- {dir && <span style={{ color: "var(--af-text-tertiary)" }}>{dir}/</span>}
4136
- <b style={{ fontWeight: 600 }}>{file}</b>
4137
- </code>
4138
- );
4139
- }
4140
-
4141
- function ToolCardShell({
4142
- icon,
4143
- label,
4144
- children,
4145
- }: {
4146
- icon: React.ReactNode;
4147
- label: React.ReactNode;
4148
- children?: React.ReactNode;
4149
- }) {
4150
- return (
4151
- <div
4152
- style={{
4153
- border: "1px solid var(--af-border-subtle)",
4154
- borderRadius: 8,
4155
- background: "var(--background)",
4156
- overflow: "hidden",
4157
- }}
4158
- >
4159
- <div
4160
- style={{
4161
- padding: "8px 12px",
4162
- display: "flex",
4163
- alignItems: "center",
4164
- gap: 8,
4165
- borderBottom: children ? "1px solid var(--af-border-subtle)" : "none",
4166
- fontSize: 12,
4167
- color: "var(--af-text-secondary)",
4168
- }}
4169
- >
4170
- <span style={{ fontSize: 13 }}>{icon}</span>
4171
- <span>{label}</span>
4172
- </div>
4173
- {children}
4174
- </div>
4175
- );
4176
- }
4177
-
4178
- function CodeBlock({ text, maxHeight = 280 }: { text: string; maxHeight?: number }) {
4179
- return (
4180
- <pre
4181
- style={{
4182
- margin: 0,
4183
- padding: "10px 12px",
4184
- fontFamily: "var(--font-mono)",
4185
- fontSize: 11.5,
4186
- lineHeight: 1.55,
4187
- color: "var(--af-text)",
4188
- whiteSpace: "pre",
4189
- overflow: "auto",
4190
- maxHeight,
4191
- }}
4192
- >
4193
- {text}
4194
- </pre>
4195
- );
4196
- }
4197
-
4198
- function DiffView({ oldText, newText }: { oldText: string; newText: string }) {
4199
- const oldLines = oldText.split("\n");
4200
- const newLines = newText.split("\n");
4201
- return (
4202
- <pre
4203
- style={{
4204
- margin: 0,
4205
- padding: "10px 12px",
4206
- fontFamily: "var(--font-mono)",
4207
- fontSize: 11.5,
4208
- lineHeight: 1.55,
4209
- whiteSpace: "pre",
4210
- overflow: "auto",
4211
- maxHeight: 320,
4212
- }}
4213
- >
4214
- {oldLines.map((line, i) => (
4215
- <div
4216
- key={`o${i}`}
4217
- style={{
4218
- background: "rgba(220, 38, 38, 0.08)",
4219
- color: "#991B1B",
4220
- padding: "0 4px",
4221
- borderLeft: "3px solid #DC2626",
4222
- }}
4223
- >
4224
- <span style={{ opacity: 0.6, userSelect: "none" }}>− </span>
4225
- {line || "\u00A0"}
4226
- </div>
4227
- ))}
4228
- {newLines.map((line, i) => (
4229
- <div
4230
- key={`n${i}`}
4231
- style={{
4232
- background: "rgba(5, 150, 105, 0.08)",
4233
- color: "#065F46",
4234
- padding: "0 4px",
4235
- borderLeft: "3px solid #059669",
4236
- }}
4237
- >
4238
- <span style={{ opacity: 0.6, userSelect: "none" }}>+ </span>
4239
- {line || "\u00A0"}
4240
- </div>
4241
- ))}
4242
- </pre>
4243
- );
4244
- }
4245
-
4246
- function ToolUseCard({ name, input }: { name: string; input: ToolUseInput }) {
4247
- const i = (input ?? {}) as Record<string, unknown>;
4248
- const str = (k: string): string | undefined =>
4249
- typeof i[k] === "string" ? (i[k] as string) : undefined;
4250
- const num = (k: string): number | undefined =>
4251
- typeof i[k] === "number" ? (i[k] as number) : undefined;
4252
-
4253
- // --- Write -------------------------------------------------------
4254
- if (name === "Write") {
4255
- const filePath = str("file_path") ?? "";
4256
- const content = str("content") ?? "";
4257
- return (
4258
- <ToolCardShell
4259
- icon="📝"
4260
- label={
4261
- <>
4262
- <b>Write</b> <PathLabel path={filePath} />
4263
- </>
4264
- }
4265
- >
4266
- <CodeBlock text={content} />
4267
- </ToolCardShell>
4268
- );
4269
- }
4270
-
4271
- // --- Edit --------------------------------------------------------
4272
- if (name === "Edit") {
4273
- const filePath = str("file_path") ?? "";
4274
- const oldStr = str("old_string") ?? "";
4275
- const newStr = str("new_string") ?? "";
4276
- const replaceAll = i.replace_all === true;
4277
- return (
4278
- <ToolCardShell
4279
- icon="✏️"
4280
- label={
4281
- <>
4282
- <b>Edit</b> <PathLabel path={filePath} />
4283
- {replaceAll && (
4284
- <span
4285
- style={{
4286
- fontSize: 10,
4287
- color: "var(--af-warning)",
4288
- background: "var(--af-warning-subtle)",
4289
- padding: "1px 6px",
4290
- borderRadius: 10,
4291
- }}
4292
- >
4293
- replace all
4294
- </span>
4295
- )}
4296
- </>
4297
- }
4298
- >
4299
- <DiffView oldText={oldStr} newText={newStr} />
4300
- </ToolCardShell>
4301
- );
4302
- }
4303
-
4304
- // --- Read --------------------------------------------------------
4305
- if (name === "Read") {
4306
- const filePath = str("file_path") ?? "";
4307
- const offset = num("offset");
4308
- const limit = num("limit");
4309
- const range =
4310
- offset !== undefined || limit !== undefined
4311
- ? ` · lines ${offset ?? 1}${limit !== undefined ? `–${(offset ?? 0) + limit}` : "…"}`
4312
- : "";
4313
- return (
4314
- <ToolCardShell
4315
- icon="📖"
4316
- label={
4317
- <>
4318
- <b>Read</b> <PathLabel path={filePath} />
4319
- <span style={{ color: "var(--af-text-tertiary)" }}>{range}</span>
4320
- </>
4321
- }
4322
- />
4323
- );
4324
- }
4325
-
4326
- // --- Bash --------------------------------------------------------
4327
- if (name === "Bash") {
4328
- const command = str("command") ?? "";
4329
- const description = str("description");
4330
- const runInBg = i.run_in_background === true;
4331
- return (
4332
- <ToolCardShell
4333
- icon="⚡"
4334
- label={
4335
- <>
4336
- <b>Bash</b>
4337
- {description && (
4338
- <span
4339
- style={{
4340
- color: "var(--af-text)",
4341
- fontStyle: "italic",
4342
- }}
4343
- >
4344
- {description}
4345
- </span>
4346
- )}
4347
- {runInBg && (
4348
- <span
4349
- style={{
4350
- fontSize: 10,
4351
- color: "var(--af-info)",
4352
- background: "var(--af-info-subtle)",
4353
- padding: "1px 6px",
4354
- borderRadius: 10,
4355
- }}
4356
- >
4357
- background
4358
- </span>
4359
- )}
4360
- </>
4361
- }
4362
- >
4363
- <CodeBlock text={command} maxHeight={220} />
4364
- </ToolCardShell>
4365
- );
4366
- }
4367
-
4368
- // --- Grep --------------------------------------------------------
4369
- if (name === "Grep") {
4370
- const pattern = str("pattern") ?? "";
4371
- const path = str("path");
4372
- const glob = str("glob");
4373
- const type = str("type");
4374
- const outputMode = str("output_mode");
4375
- return (
4376
- <ToolCardShell
4377
- icon="🔍"
4378
- label={
4379
- <>
4380
- <b>Grep</b>{" "}
4381
- <code
4382
- style={{
4383
- background: "var(--af-border-subtle)",
4384
- padding: "1px 6px",
4385
- borderRadius: 4,
4386
- fontFamily: "var(--font-mono)",
4387
- fontSize: 11,
4388
- }}
4389
- >
4390
- {pattern}
4391
- </code>
4392
- {path && (
4393
- <>
4394
- {" in "}
4395
- <PathLabel path={path} />
4396
- </>
4397
- )}
4398
- {(glob || type || outputMode) && (
4399
- <span
4400
- style={{
4401
- color: "var(--af-text-tertiary)",
4402
- fontSize: 11,
4403
- marginLeft: 6,
4404
- }}
4405
- >
4406
- {[
4407
- glob && `glob=${glob}`,
4408
- type && `type=${type}`,
4409
- outputMode && `mode=${outputMode}`,
4410
- ]
4411
- .filter(Boolean)
4412
- .join(" · ")}
4413
- </span>
4414
- )}
4415
- </>
4416
- }
4417
- />
4418
- );
4419
- }
4420
-
4421
- // --- Glob --------------------------------------------------------
4422
- if (name === "Glob") {
4423
- const pattern = str("pattern") ?? "";
4424
- const path = str("path");
4425
- return (
4426
- <ToolCardShell
4427
- icon="📁"
4428
- label={
4429
- <>
4430
- <b>Glob</b>{" "}
4431
- <code
4432
- style={{
4433
- background: "var(--af-border-subtle)",
4434
- padding: "1px 6px",
4435
- borderRadius: 4,
4436
- fontFamily: "var(--font-mono)",
4437
- fontSize: 11,
4438
- }}
4439
- >
4440
- {pattern}
4441
- </code>
4442
- {path && (
4443
- <>
4444
- {" in "}
4445
- <PathLabel path={path} />
4446
- </>
4447
- )}
4448
- </>
4449
- }
4450
- />
4451
- );
4452
- }
4453
-
4454
- // --- Skill -------------------------------------------------------
4455
- if (name === "Skill") {
4456
- const skill = str("skill") ?? "";
4457
- const args = str("args");
4458
- return (
4459
- <ToolCardShell
4460
- icon="🧩"
4461
- label={
4462
- <>
4463
- <b>/{skill}</b>
4464
- {args && <span style={{ color: "var(--af-text-secondary)" }}>{args}</span>}
4465
- </>
4466
- }
4467
- />
4468
- );
4469
- }
4470
-
4471
- // --- ToolSearch --------------------------------------------------
4472
- if (name === "ToolSearch") {
4473
- const query = str("query") ?? "";
4474
- const max = num("max_results");
4475
- return (
4476
- <ToolCardShell
4477
- icon="🔎"
4478
- label={
4479
- <>
4480
- <b>ToolSearch</b>{" "}
4481
- <code
4482
- style={{
4483
- background: "var(--af-border-subtle)",
4484
- padding: "1px 6px",
4485
- borderRadius: 4,
4486
- fontFamily: "var(--font-mono)",
4487
- fontSize: 11,
4488
- }}
4489
- >
4490
- {query}
4491
- </code>
4492
- {max !== undefined && (
4493
- <span style={{ color: "var(--af-text-tertiary)", fontSize: 11 }}>max={max}</span>
4494
- )}
4495
- </>
4496
- }
4497
- />
4498
- );
4499
- }
4500
-
4501
- // --- TodoWrite ---------------------------------------------------
4502
- if (name === "TodoWrite") {
4503
- const todos = Array.isArray(i.todos) ? (i.todos as Array<Record<string, unknown>>) : [];
4504
- return (
4505
- <ToolCardShell icon="✅" label={<b>TodoWrite · {todos.length} items</b>}>
4506
- <div style={{ padding: "10px 12px", fontSize: 12 }}>
4507
- {todos.map((t, ti) => {
4508
- const status = String(t.status ?? "pending");
4509
- const content =
4510
- typeof t.content === "string"
4511
- ? t.content
4512
- : typeof t.activeForm === "string"
4513
- ? t.activeForm
4514
- : "";
4515
- const icon = status === "completed" ? "✔" : status === "in_progress" ? "◐" : "○";
4516
- return (
4517
- <div
4518
- key={ti}
4519
- style={{
4520
- display: "flex",
4521
- gap: 8,
4522
- padding: "2px 0",
4523
- opacity: status === "completed" ? 0.6 : 1,
4524
- textDecoration: status === "completed" ? "line-through" : "none",
4525
- }}
4526
- >
4527
- <span style={{ color: "var(--af-text-tertiary)" }}>{icon}</span>
4528
- <span>{content}</span>
4529
- </div>
4530
- );
4531
- })}
4532
- </div>
4533
- </ToolCardShell>
4534
- );
4535
- }
4536
-
4537
- // --- MCP tools (linear, slack, etc.) -----------------------------
4538
- if (name.startsWith("mcp__")) {
4539
- const short = shortenToolName(name);
4540
- return (
4541
- <ToolCardShell icon="🔌" label={<b>{short}</b>}>
4542
- <CodeBlock text={JSON.stringify(input, null, 2)} maxHeight={200} />
4543
- </ToolCardShell>
4544
- );
4545
- }
4546
-
4547
- // --- Fallback ----------------------------------------------------
4548
- return (
4549
- <ToolCardShell icon="🔧" label={<b>{name}</b>}>
4550
- <CodeBlock text={JSON.stringify(input, null, 2)} maxHeight={220} />
4551
- </ToolCardShell>
4552
- );
4553
- }
4554
-
4555
4134
  function BlockView({ block }: { block: ContentBlock }) {
4556
4135
  if (block.type === "text") {
4557
4136
  return (