@sentry/junior-dashboard 0.244.0 → 0.246.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 (29) hide show
  1. package/dist/app.js +64 -7
  2. package/dist/client.js +43 -40
  3. package/dist/index.js +64 -7
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/App.tsx +3 -2
  7. package/src/client/components/SegmentedTabs.tsx +11 -9
  8. package/src/client/components/layout/PageHeader.tsx +12 -1
  9. package/src/client/conversations/ConversationAgentTree.tsx +42 -0
  10. package/src/client/conversations/ConversationChildAgents.tsx +134 -58
  11. package/src/client/conversations/ConversationComposer.tsx +68 -35
  12. package/src/client/conversations/ConversationDetailsDrawer.tsx +29 -9
  13. package/src/client/conversations/ConversationHeader.tsx +13 -1
  14. package/src/client/conversations/ConversationMemories.tsx +10 -5
  15. package/src/client/conversations/ConversationPage.tsx +35 -3
  16. package/src/client/conversations/SubagentTranscriptDrawer.tsx +30 -23
  17. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -11
  18. package/src/client/conversations/queries.ts +68 -3
  19. package/src/client/documentTitle.ts +28 -0
  20. package/src/client/format.ts +15 -0
  21. package/src/client/pages/PersonalTokensPage.tsx +2 -0
  22. package/src/client/pages/SettingsPage.tsx +2 -0
  23. package/src/client/pages/automations/AutomationEditPage.tsx +8 -0
  24. package/src/client/pages/automations/AutomationExecutionsPage.tsx +1 -0
  25. package/src/client/pages/automations/AutomationVersionsPage.tsx +1 -0
  26. package/src/client/pages/automations/AutomationsPage.tsx +7 -0
  27. package/src/client/pages/code/RepositoryPage.tsx +5 -0
  28. package/src/client/pages/dev/SegmentedFixtures.tsx +1 -1
  29. package/src/mock-reporting/fixtures.ts +68 -2
@@ -19,11 +19,12 @@ export function segmentedTabClass(selected: boolean): string {
19
19
  );
20
20
  }
21
21
 
22
- /** Switch between related panels with equal-width tabs and keyboard navigation. */
22
+ /** Switch between related panels. Tabs share the row equally on wide screens. */
23
23
  export function SegmentedTabs<const Value extends string>(props: {
24
24
  children: ReactNode;
25
25
  label: string;
26
- items: readonly { label: string; value: Value }[];
26
+ /** `count` is the number of entries in the panel. Zero and unknown counts are hidden. */
27
+ items: readonly { count?: number; label: string; value: Value }[];
27
28
  onChange(value: NoInfer<Value>): void;
28
29
  value: NoInfer<Value>;
29
30
  }) {
@@ -33,11 +34,7 @@ export function SegmentedTabs<const Value extends string>(props: {
33
34
  <div className="grid min-w-0 gap-4">
34
35
  <div
35
36
  aria-label={props.label}
36
- className={cn(
37
- "grid auto-cols-fr grid-flow-col",
38
- segmentedTabsFrameClass,
39
- segmentedTabsInsetClass,
40
- )}
37
+ className={cn("flex", segmentedTabsFrameClass, segmentedTabsInsetClass)}
41
38
  role="tablist"
42
39
  >
43
40
  {props.items.map((item, index) => (
@@ -45,7 +42,7 @@ export function SegmentedTabs<const Value extends string>(props: {
45
42
  aria-controls={`${id}-panel`}
46
43
  aria-selected={props.value === item.value}
47
44
  className={cn(
48
- "min-w-0",
45
+ "inline-flex min-w-0 flex-auto items-center justify-center gap-1.5 sm:flex-1",
49
46
  segmentedTabClass(props.value === item.value),
50
47
  )}
51
48
  id={`${id}-${item.value}`}
@@ -80,7 +77,12 @@ export function SegmentedTabs<const Value extends string>(props: {
80
77
  tabIndex={props.value === item.value ? 0 : -1}
81
78
  type="button"
82
79
  >
83
- {item.label}
80
+ <span className="truncate">{item.label}</span>
81
+ {item.count ? (
82
+ <span className="min-w-5 shrink-0 rounded-full bg-dashboard-fill-strong px-1.5 text-center text-xs font-semibold leading-5 tabular-nums">
83
+ {item.count.toLocaleString("en-US")}
84
+ </span>
85
+ ) : null}
84
86
  </button>
85
87
  ))}
86
88
  </div>
@@ -4,16 +4,27 @@ import {
4
4
  TimeRangeSelector,
5
5
  type TimeRangeDays,
6
6
  } from "../controls/TimeRangeSelector";
7
+ import { useDocumentTitle } from "../../documentTitle";
7
8
 
8
- /** Render a spacious analytics page heading with optional controls. */
9
+ /**
10
+ * Render a spacious analytics page heading with optional controls.
11
+ *
12
+ * Also names the browser tab after the page. Pass `documentTitle` when the tab
13
+ * needs more context than the visible heading.
14
+ */
9
15
  export function PageHeader(props: {
10
16
  actions?: ReactNode;
11
17
  description: ReactNode;
18
+ documentTitle?: string;
12
19
  onRangeChange?(value: TimeRangeDays): void;
13
20
  range?: TimeRangeDays;
14
21
  rangeOptions?: TimeRangeDays[];
15
22
  title: ReactNode;
16
23
  }) {
24
+ useDocumentTitle(
25
+ props.documentTitle ??
26
+ (typeof props.title === "string" ? props.title : undefined),
27
+ );
17
28
  const rangeActions =
18
29
  props.range !== undefined && props.onRangeChange ? (
19
30
  <TimeRangeSelector
@@ -0,0 +1,42 @@
1
+ import { Bot, ChevronRight } from "lucide-react";
2
+ import { Link } from "react-router";
3
+ import type { ConversationAgentTreeNode } from "@sentry/junior/api/schema";
4
+
5
+ import { conversationPath } from "./conversationRoutes";
6
+
7
+ /**
8
+ * Show the parents of a child Conversation, root first. The page title already
9
+ * names this agent, so the last node is not shown. Root Conversations have no
10
+ * tree.
11
+ */
12
+ export function ConversationAgentTree(props: {
13
+ nodes: readonly ConversationAgentTreeNode[];
14
+ }) {
15
+ return (
16
+ <nav
17
+ aria-label="Agent tree"
18
+ className="flex min-w-0 items-center gap-1.5 text-xs leading-snug text-dashboard-text-muted"
19
+ >
20
+ <Bot aria-hidden="true" className="shrink-0 text-cyan-300" size={12} />
21
+ <ol className="m-0 flex min-w-0 list-none items-center gap-1 p-0">
22
+ {props.nodes.slice(0, -1).map((node, index) => (
23
+ <li
24
+ className="inline-flex min-w-0 items-center gap-1"
25
+ key={node.conversationId}
26
+ >
27
+ {index > 0 ? (
28
+ <ChevronRight aria-hidden="true" className="shrink-0" size={12} />
29
+ ) : null}
30
+ <Link
31
+ className="min-w-0 max-w-[16rem] truncate font-medium text-dashboard-text no-underline underline-offset-2 hover:underline"
32
+ title={node.label}
33
+ to={conversationPath(node.conversationId)}
34
+ >
35
+ {node.label}
36
+ </Link>
37
+ </li>
38
+ ))}
39
+ </ol>
40
+ </nav>
41
+ );
42
+ }
@@ -1,42 +1,65 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
- import { Bot } from "lucide-react";
3
- import { Link } from "react-router";
2
+ import { Bot, CircleAlert, LockKeyhole } from "lucide-react";
4
3
  import {
5
4
  conversationChildAgentsReportSchema,
6
5
  type ConversationChildAgent,
7
6
  } from "@sentry/junior/api/schema";
8
7
 
9
8
  import { StatusChip, type StatusChipTone } from "../components/StatusChip";
10
- import { formatMessageTimestamp } from "../format";
9
+ import {
10
+ formatConversationCostTotal,
11
+ formatRelativeTime,
12
+ formatRuntime,
13
+ formatTime,
14
+ formatUsageTotal,
15
+ } from "../format";
11
16
  import { fetchDashboardJson } from "../http";
12
- import { conversationPath } from "./conversationRoutes";
17
+ import { cn } from "../styles";
18
+ import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
13
19
 
14
- const statusTone: Record<ConversationChildAgent["status"], StatusChipTone> = {
20
+ const statusTones: Partial<Record<string, StatusChipTone>> = {
21
+ active: "info",
15
22
  blocked: "warning",
16
23
  completed: "success",
24
+ error: "danger",
17
25
  failed: "danger",
18
26
  paused: "info",
19
- pending: "neutral",
20
27
  running: "info",
21
- stopped: "neutral",
22
28
  };
23
29
 
24
- /** Load and show the child agents of one Conversation with runtime status. */
25
- export function ConversationChildAgents(props: {
26
- conversationId: string;
27
- /** Called before a child link leaves the current Conversation. */
28
- onNavigate(): void;
29
- }) {
30
- const query = useQuery({
30
+ /** Pick the status tone for a child agent, its transcript row, or its detail. */
31
+ export function subagentStatusTone(status: string): StatusChipTone {
32
+ return statusTones[status] ?? "neutral";
33
+ }
34
+
35
+ const errorToneClass: Partial<Record<StatusChipTone, string>> = {
36
+ danger: "text-rose-200/85",
37
+ warning: "text-amber-200/85",
38
+ };
39
+
40
+ /** Load the child agents of one Conversation with runtime status. */
41
+ export function useConversationChildAgents(conversationId: string) {
42
+ return useQuery({
31
43
  queryFn: ({ signal }) =>
32
44
  fetchDashboardJson(
33
45
  conversationChildAgentsReportSchema,
34
- `/api/conversations/${encodeURIComponent(props.conversationId)}/agents`,
46
+ `/api/conversations/${encodeURIComponent(conversationId)}/agents`,
35
47
  signal,
36
48
  ),
37
- queryKey: ["conversation", props.conversationId, "agents"],
49
+ queryKey: ["conversation", conversationId, "agents"],
38
50
  retry: false,
39
51
  });
52
+ }
53
+
54
+ /**
55
+ * Show the child agents of one Conversation with runtime status. A row opens
56
+ * that child's transcript.
57
+ */
58
+ export function ConversationChildAgents(props: {
59
+ conversationId: string;
60
+ onOpenAgent(target: SubagentTranscriptTarget): void;
61
+ }) {
62
+ const query = useConversationChildAgents(props.conversationId);
40
63
 
41
64
  if (query.isPending) {
42
65
  return (
@@ -62,51 +85,104 @@ export function ConversationChildAgents(props: {
62
85
  }
63
86
 
64
87
  return (
65
- <ol aria-label="Subagents" className="m-0 grid list-none gap-3 p-0">
88
+ <ol aria-label="Subagents" className="m-0 grid list-none gap-2 p-0">
66
89
  {query.data.agents.map((agent) => (
67
- <li
68
- className="grid gap-2 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface px-3 py-3"
69
- key={agent.conversationId}
70
- >
71
- <div className="flex min-w-0 flex-wrap items-center gap-2">
72
- <Link
73
- className="min-w-0 truncate font-mono text-xs font-semibold text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
74
- onClick={props.onNavigate}
75
- to={conversationPath(agent.conversationId)}
76
- >
77
- {agent.redacted ? "Private subagent" : (agent.name ?? "agent")}
78
- </Link>
79
- <StatusChip
80
- className="ml-auto"
81
- size="compact"
82
- tone={statusTone[agent.status]}
83
- >
84
- {agent.status}
85
- </StatusChip>
86
- </div>
87
- {agent.title ? (
88
- <p className="m-0 break-words text-sm font-semibold text-dashboard-text">
89
- {agent.title}
90
- </p>
91
- ) : null}
92
- {agent.task ? (
93
- <p className="m-0 line-clamp-4 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
94
- {agent.task}
95
- </p>
96
- ) : null}
97
- {agent.error ? (
98
- <p className="m-0 whitespace-pre-wrap break-words text-xs leading-relaxed text-dashboard-text-muted">
99
- {agent.error}
100
- </p>
101
- ) : null}
102
- <time
103
- className="font-mono text-xs text-dashboard-text-muted"
104
- dateTime={agent.updatedAt}
105
- >
106
- {formatMessageTimestamp(Date.parse(agent.updatedAt), true)}
107
- </time>
90
+ <li key={agent.conversationId}>
91
+ <ChildAgentRow agent={agent} onOpen={props.onOpenAgent} />
108
92
  </li>
109
93
  ))}
110
94
  </ol>
111
95
  );
112
96
  }
97
+
98
+ /**
99
+ * Show who ran, what it was asked to do, and what it cost. The row text is the
100
+ * accessible name, so status, title, task, and error stay audible.
101
+ */
102
+ function ChildAgentRow(props: {
103
+ agent: ConversationChildAgent;
104
+ onOpen(target: SubagentTranscriptTarget): void;
105
+ }) {
106
+ const { agent } = props;
107
+ const tone = subagentStatusTone(agent.status);
108
+ const task = agent.task?.trim();
109
+ const headline = agent.title ?? task;
110
+ // A title is often a short form of the task. Skip the task when it adds nothing.
111
+ const detail = agent.title && task && task !== agent.title ? task : undefined;
112
+ const runtime = formatRuntime(agent.cumulativeDurationMs);
113
+ const stats = [
114
+ formatUsageTotal(agent.cumulativeUsage),
115
+ formatConversationCostTotal(agent.cumulativeUsage, agent.auxiliaryCosts),
116
+ runtime ? `${runtime} runtime` : "",
117
+ ].filter(Boolean);
118
+
119
+ return (
120
+ <button
121
+ className="grid w-full min-w-0 cursor-pointer gap-1.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface px-3 py-2.5 text-left text-inherit transition-colors duration-200 hover:border-dashboard-border-emphasis hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus"
122
+ onClick={() =>
123
+ props.onOpen({
124
+ conversationId: agent.conversationId,
125
+ ...(agent.redacted
126
+ ? undefined
127
+ : { label: agent.title ?? agent.name }),
128
+ status: agent.status,
129
+ })
130
+ }
131
+ type="button"
132
+ >
133
+ <span className="flex min-w-0 items-center gap-2 text-xs">
134
+ {agent.redacted ? (
135
+ <span className="flex min-w-0 items-center gap-1.5 text-dashboard-text-muted">
136
+ <LockKeyhole aria-hidden="true" className="size-3 shrink-0" />
137
+ Private subagent
138
+ </span>
139
+ ) : (
140
+ <span className="min-w-0 truncate font-mono text-dashboard-text-muted">
141
+ {agent.name ?? "subagent"}
142
+ </span>
143
+ )}
144
+ <StatusChip className="ml-auto shrink-0" size="compact" tone={tone}>
145
+ {agent.status}
146
+ </StatusChip>
147
+ <time
148
+ className="shrink-0 text-dashboard-text-faint"
149
+ dateTime={agent.updatedAt}
150
+ title={`Updated ${formatTime(agent.updatedAt, {
151
+ dateStyle: "medium",
152
+ timeStyle: "short",
153
+ })}`}
154
+ >
155
+ {formatRelativeTime(agent.updatedAt)}
156
+ </time>
157
+ </span>
158
+ {headline ? (
159
+ <span className="line-clamp-2 break-words text-sm font-semibold leading-snug text-dashboard-text">
160
+ {headline}
161
+ </span>
162
+ ) : null}
163
+ {detail ? (
164
+ <span className="line-clamp-2 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text-muted">
165
+ {detail}
166
+ </span>
167
+ ) : null}
168
+ {agent.error ? (
169
+ <span
170
+ className={cn(
171
+ "flex min-w-0 items-start gap-1.5 text-xs leading-relaxed",
172
+ errorToneClass[tone] ?? "text-dashboard-text-muted",
173
+ )}
174
+ >
175
+ <CircleAlert aria-hidden="true" className="mt-0.5 size-3 shrink-0" />
176
+ <span className="line-clamp-3 min-w-0 whitespace-pre-wrap break-words">
177
+ {agent.error}
178
+ </span>
179
+ </span>
180
+ ) : null}
181
+ {stats.length > 0 ? (
182
+ <span className="block font-mono text-xs tabular-nums text-dashboard-text-muted">
183
+ {stats.join(" · ")}
184
+ </span>
185
+ ) : null}
186
+ </button>
187
+ );
188
+ }
@@ -10,7 +10,7 @@ import {
10
10
  type KeyboardEvent,
11
11
  type ReactNode,
12
12
  } from "react";
13
- import { ImagePlus, Send } from "lucide-react";
13
+ import { ImagePlus, Send, Square } from "lucide-react";
14
14
 
15
15
  import { Button } from "../components/Button";
16
16
  import { useDashboardOnline } from "../connection";
@@ -78,6 +78,12 @@ type ConversationComposerProps = {
78
78
  images?: InputImage[],
79
79
  ): Promise<void>;
80
80
  onSubmitStart?: () => void;
81
+ /**
82
+ * Stop the active Turn. Present only while a Turn can be stopped. Resolves
83
+ * with queued text that the stop removed, which returns to the draft.
84
+ */
85
+ onStop?: () => Promise<string | undefined>;
86
+ stopPending?: boolean;
81
87
  };
82
88
 
83
89
  /** Render the dashboard message composer for a new or existing conversation. */
@@ -115,6 +121,7 @@ export const ConversationComposer = memo(function ConversationComposer(
115
121
  });
116
122
  // Blocks same-tick double fire. For create restore, also blocks until settle.
117
123
  const submittingRef = useRef(false);
124
+ const stoppingRef = useRef(false);
118
125
  // Monotonic token so a late failed create never restores over a newer submit.
119
126
  const submitTokenRef = useRef(0);
120
127
  const sendLocked =
@@ -304,6 +311,30 @@ export const ConversationComposer = memo(function ConversationComposer(
304
311
  }
305
312
  };
306
313
 
314
+ // Put queued text back ahead of anything the reader already typed.
315
+ const stopTurn = async () => {
316
+ if (!props.onStop || stoppingRef.current) return;
317
+ stoppingRef.current = true;
318
+ let restored: string | undefined;
319
+ try {
320
+ restored = await props.onStop();
321
+ } catch {
322
+ // The parent owns the stop error message.
323
+ return;
324
+ } finally {
325
+ stoppingRef.current = false;
326
+ }
327
+ if (!restored) return;
328
+ const current = draftRef.current.text;
329
+ const next: ConversationDraft = {
330
+ ...draftRef.current,
331
+ text: current.trim() ? `${restored}\n${current}` : restored,
332
+ };
333
+ replaceMessage(next);
334
+ storeDraft(storageKey, next);
335
+ textareaRef.current?.focus({ preventScroll: true });
336
+ };
337
+
307
338
  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
308
339
  if (
309
340
  event.key === "Enter" &&
@@ -317,20 +348,12 @@ export const ConversationComposer = memo(function ConversationComposer(
317
348
 
318
349
  return (
319
350
  <div className="grid min-w-0 gap-1.5">
320
- {!online || props.error || imageError ? (
351
+ {props.error || imageError ? (
321
352
  <div
322
353
  aria-live="polite"
323
- className={
324
- !online
325
- ? "min-w-0 font-mono text-xs leading-relaxed text-amber-100/80"
326
- : "min-w-0 font-mono text-xs leading-relaxed text-red-300/80"
327
- }
354
+ className="min-w-0 font-mono text-xs leading-relaxed text-red-300/80"
328
355
  >
329
- {!online
330
- ? images.length
331
- ? "Connect to send. Image drafts stay here until you leave."
332
- : "Connect to send. Your draft is saved."
333
- : (imageError ?? props.error)}
356
+ {imageError ?? props.error}
334
357
  </div>
335
358
  ) : null}
336
359
  <form
@@ -451,29 +474,39 @@ export const ConversationComposer = memo(function ConversationComposer(
451
474
  </div>
452
475
  )}
453
476
  </div>
454
- <Button
455
- aria-label={sendLocked ? "Sending message" : props.submitLabel}
456
- className="rounded-lg !border-0 !bg-cyan-100 font-sans !text-dashboard-text-inverse hover:!bg-cyan-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-cyan-300/55 disabled:!bg-dashboard-fill-hover disabled:!text-dashboard-text-muted"
457
- disabled={
458
- (!canSend && !images.length) ||
459
- !online ||
460
- sendLocked ||
461
- readingImages
462
- }
463
- title={
464
- !online
465
- ? "Connect to send"
466
- : sendLocked
467
- ? "Sending message"
468
- : props.submitLabel
469
- }
470
- type="submit"
471
- >
472
- <Send aria-hidden="true" size={14} />
473
- <span className="hidden md:inline">
474
- {sendLocked ? "Sending…" : props.submitLabel}
475
- </span>
476
- </Button>
477
+ <div className="flex shrink-0 items-center gap-2">
478
+ {props.onStop ? (
479
+ <Button
480
+ aria-label={props.stopPending ? "Stopping" : "Stop"}
481
+ className="rounded-lg font-sans"
482
+ disabled={props.stopPending || !online}
483
+ onClick={() => void stopTurn()}
484
+ title="Stop Junior"
485
+ >
486
+ <Square aria-hidden="true" fill="currentColor" size={12} />
487
+ <span className="hidden md:inline">
488
+ {props.stopPending ? "Stopping…" : "Stop"}
489
+ </span>
490
+ </Button>
491
+ ) : null}
492
+ <Button
493
+ aria-label={sendLocked ? "Sending message" : props.submitLabel}
494
+ className="rounded-lg !border-0 !bg-cyan-100 font-sans !text-dashboard-text-inverse hover:!bg-cyan-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-cyan-300/55 disabled:!bg-dashboard-fill-hover disabled:!text-dashboard-text-muted"
495
+ disabled={
496
+ (!canSend && !images.length) ||
497
+ !online ||
498
+ sendLocked ||
499
+ readingImages
500
+ }
501
+ title={sendLocked ? "Sending message" : props.submitLabel}
502
+ type="submit"
503
+ >
504
+ <Send aria-hidden="true" size={14} />
505
+ <span className="hidden md:inline">
506
+ {sendLocked ? "Sending…" : props.submitLabel}
507
+ </span>
508
+ </Button>
509
+ </div>
477
510
  </div>
478
511
  </form>
479
512
  </div>
@@ -5,15 +5,18 @@ import { formatRelativeTime, formatTime } from "../format";
5
5
  import { ButtonLink } from "../components/Button";
6
6
  import { Drawer } from "../components/Drawer";
7
7
  import { SegmentedTabs } from "../components/SegmentedTabs";
8
- import { ConversationChildAgents } from "./ConversationChildAgents";
9
- import { ConversationMemories } from "./ConversationMemories";
8
+ import {
9
+ ConversationChildAgents,
10
+ useConversationChildAgents,
11
+ } from "./ConversationChildAgents";
12
+ import {
13
+ ConversationMemories,
14
+ useConversationMemories,
15
+ } from "./ConversationMemories";
10
16
  import { ShareConversationButton } from "./ShareConversationButton";
17
+ import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
11
18
 
12
- const tabs = [
13
- { label: "Details", value: "details" },
14
- { label: "Memories", value: "memories" },
15
- { label: "Subagents", value: "agents" },
16
- ] as const;
19
+ type DetailsTab = "agents" | "details" | "memories";
17
20
 
18
21
  /** Put conversation context before usage and diagnostic links. */
19
22
  export function ConversationDetailsDrawer(props: {
@@ -23,12 +26,26 @@ export function ConversationDetailsDrawer(props: {
23
26
  identity: ReactNode;
24
27
  lastActivityAt?: string;
25
28
  onClose(): void;
29
+ /** Open one child agent transcript. The details drawer closes first. */
30
+ onOpenSubagent(target: SubagentTranscriptTarget): void;
26
31
  sentryConversationUrl?: string;
27
32
  privacy: ReactNode;
28
33
  stats: ReactNode;
29
34
  title: string;
30
35
  }) {
31
- const [tab, setTab] = useState<(typeof tabs)[number]["value"]>("details");
36
+ const [tab, setTab] = useState<DetailsTab>("details");
37
+ // Load both lists with the drawer so each tab can show its count.
38
+ const memories = useConversationMemories(props.conversationId);
39
+ const agents = useConversationChildAgents(props.conversationId);
40
+ const tabs = [
41
+ { label: "Details", value: "details" },
42
+ {
43
+ count: memories.data?.memories.length,
44
+ label: "Memories",
45
+ value: "memories",
46
+ },
47
+ { count: agents.data?.agents.length, label: "Subagents", value: "agents" },
48
+ ] as const;
32
49
  const titleId = "conversation-details-drawer-title";
33
50
  const sections = [
34
51
  { content: props.brief, title: "Summary" },
@@ -70,7 +87,10 @@ export function ConversationDetailsDrawer(props: {
70
87
  ) : tab === "agents" ? (
71
88
  <ConversationChildAgents
72
89
  conversationId={props.conversationId}
73
- onNavigate={props.onClose}
90
+ onOpenAgent={(target) => {
91
+ props.onClose();
92
+ props.onOpenSubagent(target);
93
+ }}
74
94
  />
75
95
  ) : sections.length > 0 ? (
76
96
  <div className="grid min-w-0 gap-5">
@@ -24,9 +24,12 @@ import {
24
24
  } from "./ConversationHeaderActions";
25
25
  import { ConversationDetailsDrawer } from "./ConversationDetailsDrawer";
26
26
  import type { TranscriptViewMode } from "./transcriptRenderModel";
27
+ import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
27
28
 
28
29
  /** Show the conversation title, search, and details. */
29
30
  export function ConversationHeader(props: {
31
+ /** Parent chain of a child Conversation. Root Conversations omit it. */
32
+ agentTree?: ReactNode;
30
33
  copyAction?: ReactNode;
31
34
  annotations: ReactNode;
32
35
  archive: ConversationArchiveAction;
@@ -40,6 +43,7 @@ export function ConversationHeader(props: {
40
43
  sentryConversationUrl?: string;
41
44
  live: boolean;
42
45
  meta?: ReactNode;
46
+ onOpenSubagent(target: SubagentTranscriptTarget): void;
43
47
  onSearchChange(value: string): void;
44
48
  onViewChange(value: TranscriptViewMode): void;
45
49
  privacy: ReactNode;
@@ -154,7 +158,8 @@ export function ConversationHeader(props: {
154
158
  searchOpenVisible ||
155
159
  props.archive.error ||
156
160
  Boolean(props.linkedWork) ||
157
- Boolean(props.forks);
161
+ Boolean(props.forks) ||
162
+ Boolean(props.agentTree);
158
163
 
159
164
  const liveIndicator = props.live ? (
160
165
  <span
@@ -176,6 +181,9 @@ export function ConversationHeader(props: {
176
181
  >
177
182
  <div className="hidden min-w-0 items-start justify-between gap-4 px-7 py-5 md:flex">
178
183
  <div className="min-w-0">
184
+ {props.agentTree ? (
185
+ <div className="mb-1.5 min-w-0">{props.agentTree}</div>
186
+ ) : null}
179
187
  <div className="flex min-w-0 items-center gap-2">
180
188
  <h2 className="m-0 line-clamp-1 min-w-0 font-display text-xl font-medium leading-tight tracking-tight">
181
189
  {props.title}
@@ -204,6 +212,9 @@ export function ConversationHeader(props: {
204
212
  view={props.view}
205
213
  />
206
214
  </div>
215
+ {props.agentTree ? (
216
+ <div className="px-4 py-2 md:hidden">{props.agentTree}</div>
217
+ ) : null}
207
218
  {props.forks ? (
208
219
  <div className="px-4 py-2 md:hidden">{props.forks}</div>
209
220
  ) : null}
@@ -251,6 +262,7 @@ export function ConversationHeader(props: {
251
262
  lastActivityAt={props.lastActivityAt}
252
263
  sentryConversationUrl={props.sentryConversationUrl}
253
264
  onClose={() => setDetailsOpen(false)}
265
+ onOpenSubagent={props.onOpenSubagent}
254
266
  privacy={props.privacy}
255
267
  stats={props.stats}
256
268
  title={props.title}
@@ -21,18 +21,23 @@ const conversationMemoryListSchema = z
21
21
  })
22
22
  .strict();
23
23
 
24
- /** Load and show the memories captured from one Conversation. */
25
- export function ConversationMemories(props: { conversationId: string }) {
26
- const query = useQuery({
24
+ /** Load the memories captured from one Conversation. */
25
+ export function useConversationMemories(conversationId: string) {
26
+ return useQuery({
27
27
  queryFn: ({ signal }) =>
28
28
  fetchDashboardJson(
29
29
  conversationMemoryListSchema,
30
- `/api/plugins/memory/conversations/${encodeURIComponent(props.conversationId)}/memories`,
30
+ `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
31
31
  signal,
32
32
  ),
33
- queryKey: ["conversation", props.conversationId, "memories"],
33
+ queryKey: ["conversation", conversationId, "memories"],
34
34
  retry: false,
35
35
  });
36
+ }
37
+
38
+ /** Show the memories captured from one Conversation. */
39
+ export function ConversationMemories(props: { conversationId: string }) {
40
+ const query = useConversationMemories(props.conversationId);
36
41
 
37
42
  if (query.isPending) {
38
43
  return (