@sentry/junior-dashboard 0.244.0 → 0.245.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.
@@ -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>
@@ -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
+ }
@@ -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 (
@@ -26,6 +26,7 @@ import {
26
26
  import { ConversationComposer } from "./ConversationComposer";
27
27
  import { ConversationHeader } from "./ConversationHeader";
28
28
  import { ConversationForkLinks } from "./ConversationForkLinks";
29
+ import { ConversationAgentTree } from "./ConversationAgentTree";
29
30
  import { ConversationHeaderMeta } from "./ConversationHeaderMeta";
30
31
  import {
31
32
  ConversationAnnotations,
@@ -38,6 +39,7 @@ import {
38
39
  import { PendingMailboxStack } from "./PendingMailboxStack";
39
40
  import {
40
41
  buildConversations,
42
+ conversationDetailTitle,
41
43
  conversationDisplayTitle,
42
44
  conversationFromDetail,
43
45
  visualStatusForConversation,
@@ -103,6 +105,7 @@ export function ConversationPage(props: {
103
105
  <ParticipantAvatarStack participants={participants} size="detail" />
104
106
  ) : null;
105
107
  const conversationDetail = detail.data;
108
+ const agentTree = detail.data?.agentTree;
106
109
  useEffect(() => {
107
110
  if (!conversation) return;
108
111
  onRead?.(conversation.id, conversation.lastSeenAt);
@@ -177,7 +180,8 @@ export function ConversationPage(props: {
177
180
  }) => {
178
181
  setSubagentTarget({
179
182
  conversationId: part.childConversationId,
180
- part,
183
+ label: part.title ?? part.agentName,
184
+ status: part.status,
181
185
  });
182
186
  },
183
187
  [],
@@ -192,6 +196,9 @@ export function ConversationPage(props: {
192
196
  scroll={
193
197
  <section className="min-w-0">
194
198
  <ConversationHeader
199
+ agentTree={
200
+ agentTree ? <ConversationAgentTree nodes={agentTree} /> : null
201
+ }
195
202
  conversationId={conversationId}
196
203
  forks={
197
204
  detail.data?.forkedFromConversationId ||
@@ -273,6 +280,7 @@ export function ConversationPage(props: {
273
280
  }
274
281
  />
275
282
  }
283
+ onOpenSubagent={setSubagentTarget}
276
284
  onSearchChange={setSearch}
277
285
  onViewChange={onViewChange}
278
286
  privacy={
@@ -292,7 +300,10 @@ export function ConversationPage(props: {
292
300
  />
293
301
  ) : null
294
302
  }
295
- title={conversationDisplayTitle(conversation)}
303
+ title={
304
+ conversationDetailTitle(detail.data) ??
305
+ conversationDisplayTitle(conversation)
306
+ }
296
307
  view={view}
297
308
  />
298
309
 
@@ -341,7 +352,9 @@ export function ConversationPage(props: {
341
352
  </section>
342
353
  }
343
354
  dock={
344
- view === "rich" && detail.data?.isParticipant ? (
355
+ // A child Conversation takes input only from its parent agent, and it
356
+ // runs without interactive auth, so it has no approval to show.
357
+ view === "rich" && detail.data?.isParticipant && !agentTree ? (
345
358
  <ConversationReplyFooter
346
359
  conversationId={conversationId}
347
360
  // Only pass committed ids for mailbox de-dupe. The full transcript is
@@ -4,11 +4,12 @@ import { Link } from "react-router";
4
4
 
5
5
  import { useConversationData } from "./queries";
6
6
  import { conversationPath } from "./conversationRoutes";
7
- import { formatMessageTimestamp } from "../format";
7
+ import { conversationDetailTitle, formatMessageTimestamp } from "../format";
8
8
  import { buildConversationMarkdown } from "../markdownExport";
9
- import type { TranscriptViewSubagentPart } from "../types";
10
9
  import { Drawer } from "../components/Drawer";
11
10
  import { SearchInput } from "../components/SearchInput";
11
+ import { StatusChip } from "../components/StatusChip";
12
+ import { subagentStatusTone } from "./ConversationChildAgents";
12
13
  import { CopyMarkdownButton } from "./CopyMarkdownButton";
13
14
  import {
14
15
  TranscriptSearchToggle,
@@ -19,9 +20,13 @@ import { TranscriptLoading } from "./TranscriptLoading";
19
20
  import type { TranscriptViewMode } from "./transcriptRenderModel";
20
21
  import { transcriptEmptyClass } from "./transcriptStyles";
21
22
 
23
+ /** One child Conversation to show, with what its parent already knows. */
22
24
  export interface SubagentTranscriptTarget {
23
25
  conversationId: string;
24
- part: TranscriptViewSubagentPart;
26
+ /** Task title or agent name, shown before the child loads. */
27
+ label?: string;
28
+ /** Child status from the parent, which knows how the child ended. */
29
+ status?: string;
25
30
  }
26
31
 
27
32
  /** Show a child conversation through the ordinary authorized detail API. */
@@ -51,13 +56,8 @@ function SubagentTranscriptDrawerContent(props: {
51
56
 
52
57
  const detail = query.data;
53
58
  const label =
54
- props.target.part.title ||
55
- detail?.displayTitle ||
56
- props.target.part.subagentKind;
57
- const meta = [
58
- statusLabel(props.target.part, detail?.status),
59
- detail ? formatMessageTimestamp(Date.parse(detail.startedAt)) : undefined,
60
- ].filter(isString);
59
+ props.target.label ?? conversationDetailTitle(detail) ?? "Subagent";
60
+ const status = statusLabel(props.target.status, detail?.status);
61
61
  const searchOpenVisible = searchOpen || search.length > 0;
62
62
 
63
63
  const titleId = "subagent-transcript-drawer-title";
@@ -122,9 +122,20 @@ function SubagentTranscriptDrawerContent(props: {
122
122
  <ExternalLink aria-hidden="true" size={12} strokeWidth={2.25} />
123
123
  </Link>
124
124
  </div>
125
- <div className="mt-1 break-words font-mono text-xs leading-snug text-dashboard-text-muted">
126
- {meta.join(" · ")}
127
- </div>
125
+ {status || detail ? (
126
+ <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-snug text-dashboard-text-muted">
127
+ {status ? (
128
+ <StatusChip size="compact" tone={subagentStatusTone(status)}>
129
+ {status}
130
+ </StatusChip>
131
+ ) : null}
132
+ {detail ? (
133
+ <time className="font-mono" dateTime={detail.startedAt}>
134
+ {formatMessageTimestamp(Date.parse(detail.startedAt))}
135
+ </time>
136
+ ) : null}
137
+ </div>
138
+ ) : null}
128
139
  {searchOpenVisible ? (
129
140
  <div className="mt-3 min-w-0">
130
141
  <SearchInput
@@ -169,13 +180,13 @@ function SubagentTranscriptDrawerContent(props: {
169
180
  }
170
181
 
171
182
  function statusLabel(
172
- part: TranscriptViewSubagentPart,
173
- detailStatus?: string,
174
- ): string {
175
- if (part.status !== "running" && part.status !== "completed") {
176
- return part.status;
183
+ status: string | undefined,
184
+ detailStatus: string | undefined,
185
+ ): string | undefined {
186
+ if (status && status !== "running" && status !== "completed") {
187
+ return status;
177
188
  }
178
- return detailStatus ?? part.status;
189
+ return detailStatus ?? status;
179
190
  }
180
191
 
181
192
  function DrawerEmptyState(props: {
@@ -193,7 +204,3 @@ function DrawerEmptyState(props: {
193
204
  </div>
194
205
  );
195
206
  }
196
-
197
- function isString(value: string | undefined): value is string {
198
- return typeof value === "string" && value.length > 0;
199
- }
@@ -660,6 +660,21 @@ function compareTimeDesc(a: string | undefined, b: string | undefined): number {
660
660
  return (parseTime(b) ?? 0) - (parseTime(a) ?? 0);
661
661
  }
662
662
 
663
+ /**
664
+ * Return the title of a loaded Conversation. A child agent Conversation uses
665
+ * its agent label from the agent tree, because it has no title of its own.
666
+ */
667
+ export function conversationDetailTitle(
668
+ detail:
669
+ | Pick<ConversationTranscript, "agentTree" | "displayTitle">
670
+ | undefined,
671
+ ): string | undefined {
672
+ return (
673
+ detail?.agentTree?.at(-1)?.label ??
674
+ (detail?.displayTitle?.trim() || undefined)
675
+ );
676
+ }
677
+
663
678
  /** Return the display title prepared by the reporting API. */
664
679
  export function conversationDisplayTitle(
665
680
  conversation: Conversation | undefined,