@dbx-tools/ui-mastra 0.1.9

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.
@@ -0,0 +1,684 @@
1
+ import {
2
+ Collapsible,
3
+ CollapsibleContent,
4
+ CollapsibleTrigger,
5
+ Spinner,
6
+ cn,
7
+ } from "@dbx-tools/ui-appkit/react";
8
+ import { genieModel } from "@dbx-tools/shared-genie";
9
+ import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
10
+ import { SqlBlock, ToolMarkdown } from "./markdown";
11
+ import type { ToolEvent, ToolProgress } from "./types";
12
+
13
+ // Consolidated tool-session pill and its Genie progress detail view:
14
+ // groups the wire events one assistant turn produced into per-sub-call
15
+ // cards (question + numbered queries + prose answers + errors).
16
+
17
+ /**
18
+ * Turn a snake/camel tool id into a Title Cased label the user can
19
+ * read. Genie tools land on this surface as flat ids
20
+ * (`ask_genie`, `get_statement`, `prepare_chart`) plus per-space
21
+ * suffixes for non-default aliases (`ask_genie_sales`).
22
+ *
23
+ * Examples:
24
+ * `ask_genie` -> `Ask Genie`
25
+ * `ask_genie_sales` -> `Ask Genie Sales`
26
+ * `myCoolTool` -> `My Cool Tool`
27
+ */
28
+ export const humanizeToolName = (toolName: string): string =>
29
+ toolName
30
+ .replace(/[._]/g, " ")
31
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
32
+ .split(/\s+/)
33
+ .filter(Boolean)
34
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
35
+ .join(" ");
36
+
37
+ /**
38
+ * Capitalize the first character of a label without touching the rest
39
+ * (preserves `EXECUTING_QUERY`-style backend status labels).
40
+ */
41
+ const capitalizeFirst = (s: string): string =>
42
+ s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
43
+
44
+ /**
45
+ * Track the freshest status label a running tool has published so the
46
+ * inline pill follows the backend (FETCHING_METADATA -> EXECUTING_QUERY)
47
+ * instead of stalling on a generic "Calling X". The wire event
48
+ * carries the raw status enum; we humanize it here with
49
+ * {@link humanizeStatus} so the pill stays in lock-step with the
50
+ * server-side label generator.
51
+ */
52
+ const runningLabelFor = (event: ToolEvent): string => {
53
+ const latest = [...(event.progress ?? [])]
54
+ .reverse()
55
+ .find((p): p is Extract<ToolProgress, { type: "status" }> => p.type === "status");
56
+ return latest
57
+ ? capitalizeFirst(genieModel.humanizeStatus(latest.status))
58
+ : `Calling ${humanizeToolName(event.toolName)}`;
59
+ };
60
+
61
+ /**
62
+ * One Genie attachment's worth of progress: the reasoning thoughts
63
+ * that landed on it, the SQL query if any, and the per-attachment
64
+ * text response Genie produced (a "text" attachment carries prose;
65
+ * a "query" attachment can co-emit prose via the same path).
66
+ * Bucketing thinking + query + text together by `attachment_id`
67
+ * keeps the rendered detail view aligned with the underlying wire
68
+ * structure (data sourcing, description, steps, SQL, and the prose
69
+ * interpretation all stay next to each other for the same query
70
+ * instead of intermixing across queries).
71
+ *
72
+ * The `key` is the wire `attachment_id` when Genie supplied one;
73
+ * for the rare anonymous attachment (Genie's "main answer" text
74
+ * sometimes arrives without an id) we fall back to a synthetic
75
+ * `__anon` bucket. That bucket only ever receives events that
76
+ * weren't tied to a query attachment in the first place, so the
77
+ * conflation is harmless.
78
+ */
79
+ type AttachmentBucket = {
80
+ key: string;
81
+ thinking: Extract<ToolProgress, { type: "thinking" }>[];
82
+ /**
83
+ * Genie emits one SQL string per query attachment. When it
84
+ * rewrites the SQL mid-turn the later event supersedes the
85
+ * earlier one (so the bucket always shows the final SQL).
86
+ */
87
+ query?: Extract<ToolProgress, { type: "query" }>;
88
+ /**
89
+ * Genie may emit one text snapshot per attachment (typed
90
+ * "text" attachments, plus prose that accompanies a query).
91
+ * Later snapshots supersede earlier ones - we render the final
92
+ * value, which matches how the SDK presents it on
93
+ * `attachment.text.content`.
94
+ */
95
+ text?: Extract<ToolProgress, { type: "text" }>;
96
+ };
97
+
98
+ /**
99
+ * One Genie sub-call's worth of progress, keyed by the assigned
100
+ * `message_id`. Every event coming back from a single
101
+ * `genieEventChat` turn shares the same `message_id` (including
102
+ * the `question` event, which `genieEventChat` defers until
103
+ * the first snapshot so it can carry that id). Grouping by
104
+ * `message_id` keeps each LLM-driven sub-call (question + all its
105
+ * attachments) visually distinct from the next sub-call's
106
+ * sub-question and attachments.
107
+ *
108
+ * `messageId` is optional only to keep the wire flexible: events
109
+ * before Genie assigns one (none expected today, since `question`
110
+ * is emitted from the first snapshot) end up in a leading anon
111
+ * group. The grouping algorithm assigns subsequent events to the
112
+ * group whose id matches their `message_id`, opening a new group
113
+ * the first time a new id appears.
114
+ */
115
+ type MessageGroup = {
116
+ messageId?: string;
117
+ /** Prompt this Genie call asked, from the `question` event. */
118
+ question?: string;
119
+ attachments: AttachmentBucket[];
120
+ /** Errors emitted under this `message_id` (turn-scoped). */
121
+ errors: Extract<ToolProgress, { type: "error" }>[];
122
+ };
123
+
124
+ /**
125
+ * Shape of the detail rows we extract from a single tool's progress
126
+ * stream. Pulled out so the pill header can show a one-line summary
127
+ * (e.g. `2 queries`) without expanding the collapsible. Charts and
128
+ * suggested follow-up questions are intentionally excluded: charts
129
+ * are resolved out-of-band via the chart cache; suggestions live at
130
+ * message scope.
131
+ *
132
+ * `thinking` entries are de-duplicated server-side already
133
+ * (`packages/genie-shared/src/event.ts` keys on
134
+ * `(thought_type, content)` per attachment) so we can render them
135
+ * verbatim without an extra dedupe pass here.
136
+ *
137
+ * `groups` preserves first-seen order so the rendered detail view
138
+ * walks Genie sub-calls in the order the LLM dispatched them.
139
+ * Within a group, `attachments` preserves first-seen order too so
140
+ * queries render in the order Genie produced them.
141
+ */
142
+ type ToolDetailSummary = {
143
+ groups: MessageGroup[];
144
+ };
145
+
146
+ const ANON_GROUP_KEY = "__anon-group";
147
+ const ANON_ATTACHMENT_KEY = "__anon";
148
+
149
+ const summarizeProgress = (progress: ToolProgress[]): ToolDetailSummary => {
150
+ const groupByMessageId = new Map<string, MessageGroup>();
151
+ const groups: MessageGroup[] = [];
152
+
153
+ // Resolve the group an event belongs to. When the event carries a
154
+ // `message_id` we trust it as the canonical key; we open a new
155
+ // group the first time a new id appears so out-of-order arrivals
156
+ // (or `question` re-emits, currently impossible but the union
157
+ // allows it) all converge. When there's no `message_id` (e.g. an
158
+ // anonymous status event before the first snapshot lands) we
159
+ // funnel into a single anon group keyed by ANON_GROUP_KEY.
160
+ const groupFor = (messageId: string | undefined): MessageGroup => {
161
+ const key = messageId ?? ANON_GROUP_KEY;
162
+ let group = groupByMessageId.get(key);
163
+ if (!group) {
164
+ group = {
165
+ ...(messageId ? { messageId } : {}),
166
+ attachments: [],
167
+ errors: [],
168
+ };
169
+ groupByMessageId.set(key, group);
170
+ groups.push(group);
171
+ }
172
+ return group;
173
+ };
174
+
175
+ // Per-group attachment bucket resolution. Attachments scoped to
176
+ // different `message_id`s can collide on `attachment_id` (Genie
177
+ // restarts numbering per message), so the bucket map is owned by
178
+ // the group, not the summary.
179
+ const bucketFor = (
180
+ group: MessageGroup,
181
+ attachmentId: string | undefined,
182
+ ): AttachmentBucket => {
183
+ const key = attachmentId ?? ANON_ATTACHMENT_KEY;
184
+ let bucket = group.attachments.find((b) => b.key === key);
185
+ if (!bucket) {
186
+ bucket = { key, thinking: [] };
187
+ group.attachments.push(bucket);
188
+ }
189
+ return bucket;
190
+ };
191
+
192
+ for (const p of progress) {
193
+ switch (p.type) {
194
+ case "question": {
195
+ // Latest question wins (today's emitter only fires once per
196
+ // turn, but the union allows future re-emits to update the
197
+ // displayed prompt without orphaning attachments).
198
+ const g = groupFor(p.message_id);
199
+ g.question = p.content;
200
+ break;
201
+ }
202
+ case "thinking": {
203
+ const g = groupFor(p.message_id);
204
+ bucketFor(g, p.attachment_id).thinking.push(p);
205
+ break;
206
+ }
207
+ case "query": {
208
+ const g = groupFor(p.message_id);
209
+ bucketFor(g, p.attachment_id).query = p;
210
+ break;
211
+ }
212
+ case "text": {
213
+ const g = groupFor(p.message_id);
214
+ bucketFor(g, p.attachment_id).text = p;
215
+ break;
216
+ }
217
+ case "error": {
218
+ // Mastra's error event is camelCase (`messageId`) where the
219
+ // wire-derived events are snake_case (`message_id`); align
220
+ // here so an errored Genie call still files under its group.
221
+ const g = groupFor(p.messageId);
222
+ g.errors.push(p);
223
+ break;
224
+ }
225
+ default:
226
+ break;
227
+ }
228
+ }
229
+ return { groups };
230
+ };
231
+
232
+ /**
233
+ * Read the question text for an `ask_genie` tool event. Prefers
234
+ * the `started` event (emitted by `ask_genie` the instant it
235
+ * runs, before any Genie round-trip) so the question appears in
236
+ * the UI immediately; falls back to the wire `question` event
237
+ * for transports that don't carry `started`. Returns `undefined`
238
+ * for non-`ask_genie` tools so callers can skip rendering the
239
+ * inline question line.
240
+ */
241
+ const askGenieQuestion = (event: ToolEvent): string | undefined => {
242
+ if (!event.toolName.startsWith("ask_genie")) return undefined;
243
+ for (const p of event.progress ?? []) {
244
+ if (p.type === "started" && p.content) return p.content;
245
+ }
246
+ for (const p of event.progress ?? []) {
247
+ if (p.type === "question" && p.content) return p.content;
248
+ }
249
+ return undefined;
250
+ };
251
+
252
+ /**
253
+ * Strip Genie's `THOUGHT_TYPE_*` prefix and turn the remaining
254
+ * upper-snake into a Title Cased label users can read at a glance.
255
+ *
256
+ * Examples:
257
+ * `THOUGHT_TYPE_DESCRIPTION` -> `Description`
258
+ * `THOUGHT_TYPE_DATA_SOURCING` -> `Data Sourcing`
259
+ * `THOUGHT_TYPE_UNDERSTANDING` -> `Understanding`
260
+ */
261
+ const humanizeThoughtType = (kind: string): string =>
262
+ kind
263
+ .replace(/^THOUGHT_TYPE_/i, "")
264
+ .toLowerCase()
265
+ .split("_")
266
+ .filter(Boolean)
267
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
268
+ .join(" ");
269
+
270
+ /**
271
+ * Genie attaches one of three payload kinds per attachment slot:
272
+ * `query` (SQL), `text` (prose answer), or `suggested_questions`.
273
+ * `isQueryAttachment` true means this bucket got a SQL query, so
274
+ * it earns a numbered "Query N" card in the UI; prose-only or
275
+ * thinking-only buckets render as plain markdown below the
276
+ * numbered queries because labelling them "Query N" misleads
277
+ * users into thinking Genie ran an extra query when it didn't.
278
+ */
279
+ const isQueryAttachment = (b: AttachmentBucket): boolean => Boolean(b.query);
280
+
281
+ /** True when a bucket has any renderable content (thinking, SQL, or prose). */
282
+ const isAttachmentRenderable = (b: AttachmentBucket): boolean =>
283
+ b.thinking.length > 0 || Boolean(b.query) || Boolean(b.text);
284
+
285
+ /** True when a group has any renderable content (question, attachments, or errors). */
286
+ const isGroupRenderable = (g: MessageGroup): boolean =>
287
+ Boolean(g.question) ||
288
+ g.attachments.some(isAttachmentRenderable) ||
289
+ g.errors.length > 0;
290
+
291
+ /**
292
+ * Body of one Genie sub-call (one `message_id` group). Pulled out
293
+ * so the same render can live either inline (when there's only one
294
+ * group) or wrapped in a Collapsible sub-pill (when there are
295
+ * multiple). Default-quiet layout: only the question stays visible
296
+ * without interaction; every other lane is a Collapsible that
297
+ * starts closed.
298
+ *
299
+ * 1. The LLM's sub-question for this Genie call, always
300
+ * visible, styled as a blockquote so it reads as provenance.
301
+ * 2. One numbered "Query N" Collapsible per query attachment
302
+ * (or just "Query" when there's one). Opens to reveal
303
+ * reasoning thoughts, an inner SQL Collapsible, and any
304
+ * prose Genie attached directly to the query.
305
+ * 3. One "Answer N" Collapsible per prose-only attachment
306
+ * (Genie's natural-language summary, follow-up questions).
307
+ * Opens to reveal thinking + the markdown body.
308
+ * 4. Errors that landed under this `message_id`, always
309
+ * visible (red text - users need to see failures, not click
310
+ * to find them).
311
+ *
312
+ * `omitQuestion` skips the question blockquote when the surrounding
313
+ * sub-pill already shows it in its trigger so we don't render the
314
+ * same text twice.
315
+ */
316
+ const MessageGroupBody = ({
317
+ group,
318
+ omitQuestion,
319
+ }: {
320
+ group: MessageGroup;
321
+ omitQuestion?: boolean;
322
+ }) => {
323
+ const renderableAttachments = group.attachments.filter(isAttachmentRenderable);
324
+ // Split renderable attachments into the two visual lanes: numbered
325
+ // query cards vs flat prose. The partition preserves first-seen
326
+ // order within each lane so queries still render in the order
327
+ // Genie produced them and the prose still reads in dispatch order.
328
+ const queryBuckets = renderableAttachments.filter(isQueryAttachment);
329
+ const proseBuckets = renderableAttachments.filter((b) => !isQueryAttachment(b));
330
+ return (
331
+ <div className="flex flex-col gap-1.5">
332
+ {!omitQuestion && group.question && (
333
+ <div className="rounded border-l-2 border-primary/40 bg-background/40 px-2 py-1 text-[11px] italic leading-snug text-muted-foreground">
334
+ {group.question}
335
+ </div>
336
+ )}
337
+ {queryBuckets.map((bucket, i) => (
338
+ // Each query bucket is a single Collapsible (default
339
+ // closed) so the expanded group reads as just the
340
+ // question + a short stack of "Query N" / "Answer" rows.
341
+ // Click any row to drill into its thinking + SQL + text.
342
+ // SQL itself stays a nested Collapsible (default closed)
343
+ // for the same reason: code is the heaviest content here,
344
+ // and most readers only want a glance.
345
+ <Collapsible
346
+ key={bucket.key}
347
+ className="rounded border border-border/60 bg-background/40"
348
+ >
349
+ <CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] uppercase tracking-wide text-muted-foreground hover:text-foreground">
350
+ <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
351
+ <span>{queryBuckets.length > 1 ? `Query ${i + 1}` : "Query"}</span>
352
+ {bucket.query?.title ? (
353
+ <span className="min-w-0 flex-1 truncate normal-case text-muted-foreground/70">
354
+ {bucket.query.title}
355
+ </span>
356
+ ) : null}
357
+ </CollapsibleTrigger>
358
+ <CollapsibleContent>
359
+ <div className="flex flex-col gap-2 px-2 pb-2 text-xs">
360
+ {bucket.thinking.length > 0 && (
361
+ <ul className="flex flex-col gap-1.5 border-l-2 border-border/60 pl-3 text-muted-foreground">
362
+ {bucket.thinking.map((p, j) => (
363
+ <li
364
+ key={`think-${j}`}
365
+ className="whitespace-pre-wrap break-words leading-snug"
366
+ >
367
+ <span className="font-medium text-foreground/80">
368
+ {humanizeThoughtType(p.thought_type)}:
369
+ </span>{" "}
370
+ {p.text}
371
+ </li>
372
+ ))}
373
+ </ul>
374
+ )}
375
+ {bucket.query && (
376
+ <Collapsible className="rounded border border-border/60 bg-background/30">
377
+ <CollapsibleTrigger className="group flex w-full items-center gap-1 px-2 py-1 text-left text-muted-foreground hover:text-foreground">
378
+ <ChevronDownIcon className="size-3 transition-transform group-data-[state=closed]:-rotate-90" />
379
+ <span>SQL</span>
380
+ </CollapsibleTrigger>
381
+ <CollapsibleContent>
382
+ <div className="px-2 pb-2">
383
+ <SqlBlock sql={bucket.query.sql} />
384
+ </div>
385
+ {bucket.query.description && (
386
+ <div className="px-2 pb-2">
387
+ <ToolMarkdown>{bucket.query.description}</ToolMarkdown>
388
+ </div>
389
+ )}
390
+ </CollapsibleContent>
391
+ </Collapsible>
392
+ )}
393
+ {bucket.text && <ToolMarkdown>{bucket.text.text}</ToolMarkdown>}
394
+ </div>
395
+ </CollapsibleContent>
396
+ </Collapsible>
397
+ ))}
398
+ {proseBuckets.map((bucket, i) => (
399
+ // Prose-only attachments (Genie's natural-language answer
400
+ // and clarifying follow-up question attachments) get the
401
+ // same Collapsible treatment as queries. Label as "Answer"
402
+ // when there's only one prose bucket; multiple buckets
403
+ // (rare - typically interpretation + a follow-up question)
404
+ // get numbered so each row is addressable.
405
+ <Collapsible
406
+ key={bucket.key}
407
+ className="rounded border border-border/60 bg-background/40"
408
+ >
409
+ <CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] uppercase tracking-wide text-muted-foreground hover:text-foreground">
410
+ <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
411
+ <span>{proseBuckets.length > 1 ? `Answer ${i + 1}` : "Answer"}</span>
412
+ </CollapsibleTrigger>
413
+ <CollapsibleContent>
414
+ <div className="flex flex-col gap-1 px-2 pb-2">
415
+ {bucket.thinking.length > 0 && (
416
+ <ul className="flex flex-col gap-1 text-[11px] text-muted-foreground">
417
+ {bucket.thinking.map((p, j) => (
418
+ <li
419
+ key={`think-${j}`}
420
+ className="whitespace-pre-wrap break-words leading-snug"
421
+ >
422
+ <span className="font-medium text-foreground/80">
423
+ {humanizeThoughtType(p.thought_type)}:
424
+ </span>{" "}
425
+ {p.text}
426
+ </li>
427
+ ))}
428
+ </ul>
429
+ )}
430
+ {bucket.text && <ToolMarkdown>{bucket.text.text}</ToolMarkdown>}
431
+ </div>
432
+ </CollapsibleContent>
433
+ </Collapsible>
434
+ ))}
435
+ {group.errors.map((p, i) => (
436
+ <p key={`err-${i}`} className="text-xs text-destructive">
437
+ {p.error}
438
+ </p>
439
+ ))}
440
+ </div>
441
+ );
442
+ };
443
+
444
+ /**
445
+ * Expanded detail view for one tool call. Walks the message
446
+ * groups Genie produced (question + attachments + errors) and
447
+ * renders each as a Collapsible card via {@link MessageGroupBody}.
448
+ *
449
+ * `omitQuestion` strips the inline question blockquote when the
450
+ * caller already surfaces the question in the row header (the
451
+ * typical case under {@link ToolCallRow}, since the question
452
+ * is the most useful thing to keep always-visible).
453
+ *
454
+ * Charts produced by `prepare_chart` / `render_data` render at
455
+ * message scope (alongside suggested questions), not inside the
456
+ * pill.
457
+ */
458
+ const ToolProgressDetails = ({
459
+ summary,
460
+ omitQuestion,
461
+ }: {
462
+ summary: ToolDetailSummary;
463
+ omitQuestion?: boolean;
464
+ }) => {
465
+ const renderableGroups = summary.groups.filter(isGroupRenderable);
466
+ if (renderableGroups.length === 0) return null;
467
+ return (
468
+ <div className="mt-2 flex flex-col gap-2">
469
+ {renderableGroups.map((group, gi) => (
470
+ <MessageGroupBody
471
+ key={group.messageId ?? `anon-${gi}`}
472
+ group={group}
473
+ omitQuestion={omitQuestion}
474
+ />
475
+ ))}
476
+ </div>
477
+ );
478
+ };
479
+
480
+ /**
481
+ * Status icon for one tool event, shared between the inner
482
+ * {@link ToolCallRow} and the outer {@link ToolSessionPill} so
483
+ * the affordances stay in lock-step.
484
+ */
485
+ const ToolStatusIcon = ({ status }: { status: "running" | "done" | "error" }) => {
486
+ if (status === "running") return <Spinner className="size-3 text-primary" />;
487
+ if (status === "error") return <XIcon className="size-3 text-destructive" />;
488
+ return <CheckIcon className="size-3 text-muted-foreground" />;
489
+ };
490
+
491
+ /**
492
+ * True when a tool event has anything worth expanding for. The
493
+ * question text already rides on the row header (see
494
+ * {@link askGenieQuestion}) so we deliberately don't count it -
495
+ * a row whose only content is the question stays non-expandable.
496
+ */
497
+ const hasExpandableDetails = (event: ToolEvent): boolean => {
498
+ if (event.status === "running") return true;
499
+ const summary = summarizeProgress(event.progress ?? []);
500
+ return summary.groups.some(
501
+ (g) => g.attachments.some(isAttachmentRenderable) || g.errors.length > 0,
502
+ );
503
+ };
504
+
505
+ /**
506
+ * One row inside {@link ToolSessionPill}. Always-visible header
507
+ * shows the status icon, the tool's verb (`Called Ask Genie`,
508
+ * `Calling Prepare Chart`, etc.), and - for `ask_genie` rows -
509
+ * the question text the central agent passed to Genie so users
510
+ * can see each sub-question at a glance without expanding.
511
+ *
512
+ * Rows with extra wire detail (Genie SQL, thinking, prose
513
+ * answers, errors) expand to reveal the per-attachment cards
514
+ * built by {@link ToolProgressDetails}. Tools with no extra
515
+ * detail (`get_statement`, `prepare_chart`) render as a flat
516
+ * non-expandable line so the chevron doesn't lie about
517
+ * interactivity.
518
+ */
519
+ const ToolCallRow = ({ event }: { event: ToolEvent }) => {
520
+ const isRunning = event.status === "running";
521
+ const isError = event.status === "error";
522
+ const question = askGenieQuestion(event);
523
+ const summary = summarizeProgress(event.progress ?? []);
524
+ const expandable = hasExpandableDetails(event);
525
+ // Inner rows defer to the live wire status when running (e.g.
526
+ // "Executing query") so users tracking the open pill see the
527
+ // backend's freshest state - not just the static
528
+ // "Calling Ask Genie" label.
529
+ const verb = isError
530
+ ? `Failed ${humanizeToolName(event.toolName)}`
531
+ : isRunning
532
+ ? runningLabelFor(event)
533
+ : `Called ${humanizeToolName(event.toolName)}`;
534
+
535
+ const header = (
536
+ <span className="min-w-0 flex-1">
537
+ <span
538
+ className={cn(
539
+ "block truncate",
540
+ isRunning && "animate-pulse text-foreground/90",
541
+ )}
542
+ >
543
+ {verb}
544
+ </span>
545
+ {question && (
546
+ <span className="mt-0.5 block break-words italic text-foreground/80">
547
+ {question}
548
+ </span>
549
+ )}
550
+ </span>
551
+ );
552
+
553
+ if (!expandable) {
554
+ return (
555
+ <div className="flex items-start gap-2 px-2 py-1 text-xs text-muted-foreground">
556
+ {/* Fixed-width spacer keeps the icon column aligned with
557
+ * sibling expandable rows that lead with a chevron. */}
558
+ <span className="mt-0.5 size-3 shrink-0" aria-hidden />
559
+ <span className="mt-0.5 shrink-0">
560
+ <ToolStatusIcon status={event.status} />
561
+ </span>
562
+ {header}
563
+ </div>
564
+ );
565
+ }
566
+
567
+ return (
568
+ <Collapsible className="rounded border border-border/40 bg-background/30">
569
+ <CollapsibleTrigger className="group flex w-full items-start gap-2 px-2 py-1 text-left text-xs text-muted-foreground cursor-pointer hover:text-foreground">
570
+ <ChevronDownIcon className="mt-0.5 size-3 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
571
+ <span className="mt-0.5 shrink-0">
572
+ <ToolStatusIcon status={event.status} />
573
+ </span>
574
+ {header}
575
+ </CollapsibleTrigger>
576
+ <CollapsibleContent>
577
+ <div className="px-2 pb-2">
578
+ <ToolProgressDetails summary={summary} omitQuestion />
579
+ </div>
580
+ </CollapsibleContent>
581
+ </Collapsible>
582
+ );
583
+ };
584
+
585
+ /**
586
+ * Consolidated pill for every tool call on one assistant
587
+ * message. The collapsed header tracks the MOST RECENT tool call
588
+ * (its verb and tool name) so the bubble always advertises
589
+ * "what the agent is doing now"; expanding reveals one
590
+ * {@link ToolCallRow} per call in dispatch order so users can
591
+ * see every step the agent took and read the question text the
592
+ * central agent passed to each `ask_genie` call.
593
+ *
594
+ * Overall tone (border / ring / icon) follows the worst-case
595
+ * state across all rows: any running event dominates (primary
596
+ * border + ping pip), then any error (destructive border), then
597
+ * done. The header verb itself follows the LATEST row's status
598
+ * so users see fresh progress as new tool calls land mid-turn
599
+ * (rather than the pill stalling on whichever row was busiest).
600
+ */
601
+ export const ToolSessionPill = ({ events }: { events: ToolEvent[] }) => {
602
+ if (events.length === 0) return null;
603
+ const latest = events[events.length - 1]!;
604
+ const anyRunning = events.some((e) => e.status === "running");
605
+ const anyError = events.some((e) => e.status === "error");
606
+ const tone: "running" | "error" | "done" = anyRunning
607
+ ? "running"
608
+ : anyError
609
+ ? "error"
610
+ : "done";
611
+
612
+ // Outer header uses the tool name verb (not the live wire
613
+ // status) per the "show the most recent tool call" contract.
614
+ // Inner rows still expose the wire status when expanded.
615
+ const isLatestRunning = latest.status === "running";
616
+ const isLatestError = latest.status === "error";
617
+ const latestName = humanizeToolName(latest.toolName);
618
+ const verb = isLatestError
619
+ ? `Failed ${latestName}`
620
+ : isLatestRunning
621
+ ? `Calling ${latestName}`
622
+ : `Called ${latestName}`;
623
+ const countSuffix = events.length > 1 ? ` · ${events.length} calls` : "";
624
+
625
+ return (
626
+ <div
627
+ className={cn(
628
+ "rounded-md border bg-background/30 px-2 py-1.5 transition-colors",
629
+ // Loud-but-not-jarring "in flight" treatment when any row
630
+ // is running: primary border + soft ring. Failed sessions
631
+ // (no longer running) pick up the destructive border;
632
+ // settled sessions stay neutral.
633
+ tone === "running"
634
+ ? "border-primary/50 ring-1 ring-primary/15"
635
+ : tone === "error"
636
+ ? "border-destructive/40"
637
+ : "border-border/40",
638
+ )}
639
+ >
640
+ <Collapsible>
641
+ <CollapsibleTrigger className="group flex w-full items-center gap-2 text-left text-xs text-muted-foreground cursor-pointer hover:text-foreground">
642
+ <ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
643
+ <ToolStatusIcon status={tone} />
644
+ {/*
645
+ * Verb + count share one span so the `·` separator reads
646
+ * as a natural inline divider. `min-w-0` lets the truncate
647
+ * clip without forcing the row to overflow when the verb
648
+ * is long.
649
+ */}
650
+ <span
651
+ className={cn(
652
+ "min-w-0 flex-1 truncate",
653
+ tone === "running" && "animate-pulse text-foreground/90",
654
+ )}
655
+ >
656
+ {verb}
657
+ {countSuffix && (
658
+ <span className="text-muted-foreground/70">{countSuffix}</span>
659
+ )}
660
+ </span>
661
+ {/*
662
+ * Trailing "live" pip on the right edge while any tool is
663
+ * in flight. Two-layer: a static dot under an animated
664
+ * ping ring, matching the convention native chat apps use
665
+ * for active recording / streaming indicators.
666
+ */}
667
+ {tone === "running" && (
668
+ <span className="relative ml-1 flex size-2 shrink-0" aria-hidden>
669
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
670
+ <span className="relative inline-flex size-2 rounded-full bg-primary" />
671
+ </span>
672
+ )}
673
+ </CollapsibleTrigger>
674
+ <CollapsibleContent>
675
+ <div className="mt-2 flex flex-col gap-1.5">
676
+ {events.map((event) => (
677
+ <ToolCallRow key={event.id} event={event} />
678
+ ))}
679
+ </div>
680
+ </CollapsibleContent>
681
+ </Collapsible>
682
+ </div>
683
+ );
684
+ };