@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.
- package/README.md +198 -0
- package/index.ts +32 -0
- package/package.json +62 -0
- package/src/react/bubbles.tsx +428 -0
- package/src/react/chat-view.tsx +574 -0
- package/src/react/data-grid.tsx +342 -0
- package/src/react/embed-slots.tsx +258 -0
- package/src/react/export-menu.tsx +71 -0
- package/src/react/feedback-controls.tsx +139 -0
- package/src/react/index.ts +42 -0
- package/src/react/markdown.tsx +387 -0
- package/src/react/mastra-chat.tsx +1376 -0
- package/src/react/suggestion-pills.tsx +46 -0
- package/src/react/suggestions.ts +109 -0
- package/src/react/thread-sidebar.tsx +315 -0
- package/src/react/tool-pill.tsx +684 -0
- package/src/react/types.ts +295 -0
- package/src/styles.css +22 -0
- package/src/support/chart-option.ts +130 -0
- package/src/support/export.ts +485 -0
- package/src/support/mastra-client.ts +883 -0
- package/src/support/mastra-stream.ts +66 -0
- package/src/support/shiki-plugin.ts +134 -0
- package/src/support/thread-sessions.ts +55 -0
- package/tsconfig.json +43 -0
|
@@ -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
|
+
};
|