@tangle-network/ui 8.0.0 → 8.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 8.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 79b55f5: Converge the two transcripts on one collapsible run. New `AssistantRunShell` primitive (the header · summary · status pill · chevron · Radix collapse extracted from `RunGroup`) is now used by both `RunGroup` and `AgentTimeline`, so there is one implementation of "an assistant run" instead of two divergent ones. `AgentTimeline` folds consecutive tool / tool-group items into that shell (`collapsibleToolRuns`, default on; `defaultToolRunsOpen`, default open) so a burst of tool activity reads as one toggleable step on the timeline spine instead of a long ladder of rows — matching `RunGroup`. Additive: `AgentTimeline`'s `items[]` API is unchanged and folding happens internally; consumers building their own item arrays keep working.
8
+
3
9
  ## 8.0.0
4
10
 
5
11
  ### Major Changes
package/dist/chat.d.ts CHANGED
@@ -157,12 +157,21 @@ interface AgentTimelineProps {
157
157
  isThinking?: boolean;
158
158
  emptyState?: ReactNode;
159
159
  className?: string;
160
+ /**
161
+ * Fold consecutive tool / tool-group items into one collapsible run shell
162
+ * (the same `AssistantRunShell` `RunGroup` uses), so a burst of tool activity
163
+ * reads as a single toggleable step instead of a long ladder of rows.
164
+ * Default true; pass false for the flat one-row-per-tool timeline.
165
+ */
166
+ collapsibleToolRuns?: boolean;
167
+ /** Start collapsed tool runs open (true) or collapsed (false). Default open. */
168
+ defaultToolRunsOpen?: boolean;
160
169
  }
161
170
  /**
162
171
  * AgentTimeline — unified mixed-content timeline for agent-backed sandbox
163
172
  * sessions. Renders messages, tool steps, status cards, and artifact handoffs in
164
173
  * a single execution narrative.
165
174
  */
166
- declare function AgentTimeline({ items, isThinking, emptyState, className, }: AgentTimelineProps): react_jsx_runtime.JSX.Element | null;
175
+ declare function AgentTimeline({ items, isThinking, emptyState, className, collapsibleToolRuns, defaultToolRunsOpen, }: AgentTimelineProps): react_jsx_runtime.JSX.Element | null;
167
176
 
168
177
  export { AgentTimeline, type AgentTimelineArtifactItem, type AgentTimelineCustomItem, type AgentTimelineItem, type AgentTimelineMessageItem, type AgentTimelineProps, type AgentTimelineStatusItem, type AgentTimelineTone, type AgentTimelineToolGroupItem, type AgentTimelineToolItem, ChatContainer, type ChatContainerProps, ChatMessage, type ChatMessageProps, MessageList, type MessageListProps, type MessageRole, ThinkingIndicator, type ThinkingIndicatorProps, UserMessage, type UserMessageProps };
package/dist/chat.js CHANGED
@@ -5,9 +5,9 @@ import {
5
5
  MessageList,
6
6
  ThinkingIndicator,
7
7
  UserMessage
8
- } from "./chunk-UOLL2YHG.js";
8
+ } from "./chunk-QUAU6ZNC.js";
9
9
  import "./chunk-AZWDI2JG.js";
10
- import "./chunk-QIRVZMQY.js";
10
+ import "./chunk-C3BIVG72.js";
11
11
  import "./chunk-RKQDBRTC.js";
12
12
  import "./chunk-ULDNFLIM.js";
13
13
  import "./chunk-AAUNOHVL.js";
@@ -108,14 +108,68 @@ var InlineThinkingItem = memo(
108
108
  );
109
109
  InlineThinkingItem.displayName = "InlineThinkingItem";
110
110
 
111
+ // src/run/assistant-run-shell.tsx
112
+ import * as Collapsible2 from "@radix-ui/react-collapsible";
113
+ import { ChevronDown, ChevronRight as ChevronRight2, Loader2, Sparkles } from "lucide-react";
114
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
115
+ function AssistantRunShell({
116
+ label,
117
+ summary,
118
+ collapsedPreview,
119
+ badges,
120
+ isStreaming,
121
+ collapsed,
122
+ onToggle,
123
+ headerActions,
124
+ children,
125
+ className
126
+ }) {
127
+ return /* @__PURE__ */ jsx2(Collapsible2.Root, { open: !collapsed, onOpenChange: () => onToggle(), children: /* @__PURE__ */ jsxs2(
128
+ "div",
129
+ {
130
+ className: cn(
131
+ "rounded-[28px] border border-[var(--border-subtle)] bg-[var(--bg-card)] shadow-none",
132
+ className
133
+ ),
134
+ children: [
135
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-start gap-3 px-3 py-2.5", children: [
136
+ /* @__PURE__ */ jsx2(Collapsible2.Trigger, { asChild: true, children: /* @__PURE__ */ jsx2(
137
+ "button",
138
+ {
139
+ type: "button",
140
+ className: "w-full rounded-[20px] bg-transparent px-0 py-0 text-left transition-colors hover:bg-transparent",
141
+ children: /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2", children: [
142
+ /* @__PURE__ */ jsx2("span", { className: "font-semibold text-foreground text-sm", children: label }),
143
+ summary ? /* @__PURE__ */ jsx2("span", { className: "text-[11px] text-muted-foreground", children: summary }) : null,
144
+ collapsed && collapsedPreview ? /* @__PURE__ */ jsx2("span", { className: "min-w-0 truncate text-[11px] text-foreground/70", children: collapsedPreview }) : null,
145
+ /* @__PURE__ */ jsxs2("div", { className: "ml-auto flex shrink-0 items-center gap-1.5", children: [
146
+ badges,
147
+ isStreaming ? /* @__PURE__ */ jsxs2("span", { className: "inline-flex items-center gap-1 rounded-full border border-[var(--border-accent)] bg-[var(--accent-surface-soft)] px-2 py-px text-[10px] font-semibold uppercase text-[var(--accent-text)]", children: [
148
+ /* @__PURE__ */ jsx2(Loader2, { className: "h-2.5 w-2.5 animate-spin" }),
149
+ "Running"
150
+ ] }) : /* @__PURE__ */ jsxs2("span", { className: "inline-flex items-center gap-1 rounded-full border border-border px-2 py-px text-[10px] font-semibold uppercase text-muted-foreground", children: [
151
+ /* @__PURE__ */ jsx2(Sparkles, { className: "h-2.5 w-2.5" }),
152
+ "Done"
153
+ ] }),
154
+ collapsed ? /* @__PURE__ */ jsx2(ChevronRight2, { className: "h-3.5 w-3.5 text-muted-foreground" }) : /* @__PURE__ */ jsx2(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })
155
+ ] })
156
+ ] })
157
+ }
158
+ ) }),
159
+ headerActions ? /* @__PURE__ */ jsx2("div", { className: "flex shrink-0 flex-wrap items-center justify-end gap-1.5 pt-1", children: headerActions }) : null
160
+ ] }),
161
+ collapsed && collapsedPreview ? /* @__PURE__ */ jsx2("div", { className: "line-clamp-2 px-4 pb-4 text-sm leading-6 text-muted-foreground", children: collapsedPreview }) : null,
162
+ /* @__PURE__ */ jsx2(Collapsible2.Content, { className: "overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp", children: /* @__PURE__ */ jsx2("div", { className: "border-t border-[var(--border-subtle)] px-4 pb-4 pt-3", children }) })
163
+ ]
164
+ }
165
+ ) });
166
+ }
167
+
111
168
  // src/run/run-group.tsx
112
169
  import { memo as memo2, useMemo } from "react";
113
- import * as Collapsible2 from "@radix-ui/react-collapsible";
114
170
  import {
115
171
  Bot,
116
- Loader2,
117
- ChevronDown,
118
- ChevronRight as ChevronRight2,
172
+ Loader2 as Loader22,
119
173
  Terminal,
120
174
  FileEdit,
121
175
  FileSearch,
@@ -123,10 +177,9 @@ import {
123
177
  PencilLine,
124
178
  Globe,
125
179
  ClipboardList,
126
- Settings,
127
- Sparkles
180
+ Settings
128
181
  } from "lucide-react";
129
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
182
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
130
183
  var DEFAULT_BRANDING = {
131
184
  label: "Agent",
132
185
  accentClass: "text-primary",
@@ -142,15 +195,15 @@ function AssistantShell({
142
195
  isStreaming,
143
196
  children
144
197
  }) {
145
- return /* @__PURE__ */ jsxs2("div", { className: "flex flex-col gap-1", children: [
146
- /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2 px-1 font-medium text-muted-foreground text-xs", children: [
147
- /* @__PURE__ */ jsx2("span", { children: branding.label }),
148
- isStreaming ? /* @__PURE__ */ jsxs2("span", { className: "inline-flex items-center gap-1.5", children: [
149
- /* @__PURE__ */ jsx2(Loader2, { className: "h-3 w-3 animate-spin" }),
198
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-1", children: [
199
+ /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2 px-1 font-medium text-muted-foreground text-xs", children: [
200
+ /* @__PURE__ */ jsx3("span", { children: branding.label }),
201
+ isStreaming ? /* @__PURE__ */ jsxs3("span", { className: "inline-flex items-center gap-1.5", children: [
202
+ /* @__PURE__ */ jsx3(Loader22, { className: "h-3 w-3 animate-spin" }),
150
203
  "Thinking"
151
204
  ] }) : null
152
205
  ] }),
153
- /* @__PURE__ */ jsx2("div", { className: ASSISTANT_SHELL, children })
206
+ /* @__PURE__ */ jsx3("div", { className: ASSISTANT_SHELL, children })
154
207
  ] });
155
208
  }
156
209
  var CATEGORY_ICON_MAP = {
@@ -242,14 +295,14 @@ function CategoryBadges({ categories }) {
242
295
  [categories]
243
296
  );
244
297
  if (sorted.length === 0) return null;
245
- return /* @__PURE__ */ jsx2("div", { className: "flex items-center gap-1", children: sorted.map((cat) => {
298
+ return /* @__PURE__ */ jsx3("div", { className: "flex items-center gap-1", children: sorted.map((cat) => {
246
299
  const Icon = CATEGORY_ICON_MAP[cat] ?? Settings;
247
- return /* @__PURE__ */ jsx2(
300
+ return /* @__PURE__ */ jsx3(
248
301
  "span",
249
302
  {
250
303
  title: cat,
251
304
  className: "flex h-5 w-5 items-center justify-center rounded border border-border text-muted-foreground",
252
- children: /* @__PURE__ */ jsx2(Icon, { className: "h-3 w-3" })
305
+ children: /* @__PURE__ */ jsx3(Icon, { className: "h-3 w-3" })
253
306
  },
254
307
  cat
255
308
  );
@@ -310,10 +363,10 @@ var RunGroup = memo2(
310
363
  if (!isStreaming) {
311
364
  return null;
312
365
  }
313
- return /* @__PURE__ */ jsx2(AssistantShell, { branding, isStreaming: true, children: /* @__PURE__ */ jsx2("div", { className: "flex items-center gap-2 px-0.5 py-0.5 text-sm text-[var(--text-muted)]", children: /* @__PURE__ */ jsxs2("span", { className: "flex gap-[5px]", children: [
314
- /* @__PURE__ */ jsx2("span", { className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "0ms" } }),
315
- /* @__PURE__ */ jsx2("span", { className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "150ms" } }),
316
- /* @__PURE__ */ jsx2("span", { className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "300ms" } })
366
+ return /* @__PURE__ */ jsx3(AssistantShell, { branding, isStreaming: true, children: /* @__PURE__ */ jsx3("div", { className: "flex items-center gap-2 px-0.5 py-0.5 text-sm text-[var(--text-muted)]", children: /* @__PURE__ */ jsxs3("span", { className: "flex gap-[5px]", children: [
367
+ /* @__PURE__ */ jsx3("span", { className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "0ms" } }),
368
+ /* @__PURE__ */ jsx3("span", { className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "150ms" } }),
369
+ /* @__PURE__ */ jsx3("span", { className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]", style: { animationDelay: "300ms" } })
317
370
  ] }) }) });
318
371
  }
319
372
  const showTraceChrome = allParts.some(({ part }) => {
@@ -326,32 +379,32 @@ var RunGroup = memo2(
326
379
  return false;
327
380
  });
328
381
  if (!showTraceChrome) {
329
- return /* @__PURE__ */ jsx2(AssistantShell, { branding, isStreaming, children: allParts.map(({ part, msgId, index }) => {
382
+ return /* @__PURE__ */ jsx3(AssistantShell, { branding, isStreaming, children: allParts.map(({ part, msgId, index }) => {
330
383
  const key = `${msgId}-${index}`;
331
384
  if (part.type === "tool" && isOpenUITool(part)) {
332
385
  const toolPart = part;
333
386
  const schema = extractOpenUISchema(toolPart.state.output);
334
387
  const summary = getOpenUISummary(toolPart.state.output);
335
388
  if (toolPart.state.status === "completed" && schema) {
336
- return /* @__PURE__ */ jsxs2(
389
+ return /* @__PURE__ */ jsxs3(
337
390
  "div",
338
391
  {
339
392
  className: "overflow-hidden rounded-[22px] border border-[var(--border-subtle)] bg-[var(--bg-root)]",
340
393
  children: [
341
- summary ? /* @__PURE__ */ jsx2("div", { className: "border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground", children: summary }) : null,
342
- /* @__PURE__ */ jsx2("div", { className: "p-4", children: /* @__PURE__ */ jsx2(OpenUIArtifactRenderer, { schema }) })
394
+ summary ? /* @__PURE__ */ jsx3("div", { className: "border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground", children: summary }) : null,
395
+ /* @__PURE__ */ jsx3("div", { className: "p-4", children: /* @__PURE__ */ jsx3(OpenUIArtifactRenderer, { schema }) })
343
396
  ]
344
397
  },
345
398
  key
346
399
  );
347
400
  }
348
401
  if (toolPart.state.status === "running") {
349
- return /* @__PURE__ */ jsxs2(
402
+ return /* @__PURE__ */ jsxs3(
350
403
  "div",
351
404
  {
352
405
  className: "flex items-center gap-2 rounded-[18px] border border-[var(--border-subtle)] bg-[var(--bg-root)] px-4 py-3 text-sm text-muted-foreground",
353
406
  children: [
354
- /* @__PURE__ */ jsx2(Loader2, { className: "h-4 w-4 animate-spin text-primary" }),
407
+ /* @__PURE__ */ jsx3(Loader22, { className: "h-4 w-4 animate-spin text-primary" }),
355
408
  "Building view\u2026"
356
409
  ]
357
410
  },
@@ -360,102 +413,85 @@ var RunGroup = memo2(
360
413
  }
361
414
  }
362
415
  if (part.type === "text" && !part.synthetic && part.text.trim()) {
363
- return /* @__PURE__ */ jsx2("div", { className: "px-0.5", children: /* @__PURE__ */ jsx2(Markdown, { className: "tangle-prose text-[15px] leading-7 text-[var(--text-primary)]", children: part.text }) }, key);
416
+ return /* @__PURE__ */ jsx3("div", { className: "px-0.5", children: /* @__PURE__ */ jsx3(Markdown, { className: "tangle-prose text-[15px] leading-7 text-[var(--text-primary)]", children: part.text }) }, key);
364
417
  }
365
418
  return null;
366
419
  }) });
367
420
  }
368
- return /* @__PURE__ */ jsx2(Collapsible2.Root, { open: !collapsed, onOpenChange: () => onToggle(), children: /* @__PURE__ */ jsxs2("div", { className: "rounded-[28px] border border-[var(--border-subtle)] bg-[var(--bg-card)] shadow-none", children: [
369
- /* @__PURE__ */ jsxs2("div", { className: "flex items-start gap-3 px-3 py-2.5", children: [
370
- /* @__PURE__ */ jsx2(Collapsible2.Trigger, { asChild: true, children: /* @__PURE__ */ jsx2(
371
- "button",
372
- {
373
- className: cn(
374
- "w-full rounded-[20px] px-0 py-0 text-left transition-colors",
375
- "bg-transparent hover:bg-transparent"
376
- ),
377
- children: /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2", children: [
378
- /* @__PURE__ */ jsx2("span", { className: cn("font-semibold text-sm", branding.textClass), children: branding.label }),
379
- renderSummary(run) ? /* @__PURE__ */ jsx2("span", { className: "text-[11px] text-muted-foreground", children: renderSummary(run) }) : null,
380
- collapsed && run.summaryText ? /* @__PURE__ */ jsx2("span", { className: "min-w-0 truncate text-[11px] text-foreground/70", children: run.summaryText }) : null,
381
- /* @__PURE__ */ jsxs2("div", { className: "ml-auto flex shrink-0 items-center gap-1.5", children: [
382
- /* @__PURE__ */ jsx2(CategoryBadges, { categories: stats.toolCategories }),
383
- isStreaming ? /* @__PURE__ */ jsxs2("span", { className: "inline-flex items-center gap-1 rounded-full border border-[var(--border-accent)] bg-[var(--accent-surface-soft)] px-2 py-px text-[10px] font-semibold uppercase text-[var(--accent-text)]", children: [
384
- /* @__PURE__ */ jsx2(Loader2, { className: "h-2.5 w-2.5 animate-spin" }),
385
- "Running"
386
- ] }) : /* @__PURE__ */ jsxs2("span", { className: "inline-flex items-center gap-1 rounded-full border border-border px-2 py-px text-[10px] font-semibold uppercase text-muted-foreground", children: [
387
- /* @__PURE__ */ jsx2(Sparkles, { className: "h-2.5 w-2.5" }),
388
- "Done"
389
- ] }),
390
- !collapsed ? /* @__PURE__ */ jsx2(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" }) : /* @__PURE__ */ jsx2(ChevronRight2, { className: "h-3.5 w-3.5 text-muted-foreground" })
391
- ] })
392
- ] })
393
- }
394
- ) }),
395
- headerActions ? /* @__PURE__ */ jsx2("div", { className: "flex shrink-0 flex-wrap items-center justify-end gap-1.5 pt-1", children: headerActions }) : null
396
- ] }),
397
- collapsed && run.summaryText && /* @__PURE__ */ jsx2("div", { className: "px-4 pb-4 text-sm leading-6 text-muted-foreground line-clamp-2", children: run.summaryText }),
398
- /* @__PURE__ */ jsx2(Collapsible2.Content, { className: "overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp", children: /* @__PURE__ */ jsx2("div", { className: cn("border-t border-[var(--border-subtle)] px-4 pb-4 pt-3"), children: allParts.map(({ part, msgId, index }, partIndex) => {
399
- const key = `${msgId}-${index}`;
400
- const prev = allParts[partIndex - 1]?.part;
401
- const connectedTool = part.type === "tool" && prev?.type === "tool" && !isOpenUITool(part) && !isOpenUITool(prev);
402
- const gapClass = partIndex === 0 ? "" : connectedTool ? "mt-px" : "mt-3";
403
- let node = null;
404
- if (part.type === "tool") {
405
- if (isOpenUITool(part)) {
406
- const toolPart = part;
407
- const schema = extractOpenUISchema(toolPart.state.output);
408
- const summary = getOpenUISummary(toolPart.state.output);
409
- if (toolPart.state.status === "completed" && schema) {
410
- node = /* @__PURE__ */ jsxs2("div", { className: "overflow-hidden rounded-[24px] border border-[var(--border-subtle)] bg-[var(--bg-card)]", children: [
411
- summary ? /* @__PURE__ */ jsxs2("div", { className: "border-b border-[var(--border-subtle)] px-4 py-3", children: [
412
- /* @__PURE__ */ jsx2("div", { className: "text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground", children: "View" }),
413
- /* @__PURE__ */ jsx2("div", { className: "mt-1 text-sm leading-6 text-foreground", children: summary })
414
- ] }) : null,
415
- /* @__PURE__ */ jsx2("div", { className: "p-4", children: /* @__PURE__ */ jsx2(OpenUIArtifactRenderer, { schema }) })
416
- ] });
417
- } else if (toolPart.state.status === "running") {
418
- node = /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-3 rounded-[20px] border border-[var(--border-subtle)] bg-[var(--bg-card)] px-4 py-3 text-sm text-muted-foreground", children: [
419
- /* @__PURE__ */ jsx2(Loader2, { className: "h-4 w-4 animate-spin text-primary" }),
420
- "Building view\u2026"
421
- ] });
421
+ return /* @__PURE__ */ jsx3(
422
+ AssistantRunShell,
423
+ {
424
+ label: branding.label,
425
+ summary: renderSummary(run) || void 0,
426
+ collapsedPreview: run.summaryText ?? void 0,
427
+ badges: /* @__PURE__ */ jsx3(CategoryBadges, { categories: stats.toolCategories }),
428
+ isStreaming,
429
+ collapsed,
430
+ onToggle,
431
+ headerActions,
432
+ children: allParts.map(({ part, msgId, index }, partIndex) => {
433
+ const key = `${msgId}-${index}`;
434
+ const prev = allParts[partIndex - 1]?.part;
435
+ const connectedTool = part.type === "tool" && prev?.type === "tool" && !isOpenUITool(part) && !isOpenUITool(prev);
436
+ const gapClass = partIndex === 0 ? "" : connectedTool ? "mt-px" : "mt-3";
437
+ let node = null;
438
+ if (part.type === "tool") {
439
+ if (isOpenUITool(part)) {
440
+ const toolPart = part;
441
+ const schema = extractOpenUISchema(toolPart.state.output);
442
+ const summary = getOpenUISummary(toolPart.state.output);
443
+ if (toolPart.state.status === "completed" && schema) {
444
+ node = /* @__PURE__ */ jsxs3("div", { className: "overflow-hidden rounded-[24px] border border-[var(--border-subtle)] bg-[var(--bg-card)]", children: [
445
+ summary ? /* @__PURE__ */ jsxs3("div", { className: "border-b border-[var(--border-subtle)] px-4 py-3", children: [
446
+ /* @__PURE__ */ jsx3("div", { className: "text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground", children: "View" }),
447
+ /* @__PURE__ */ jsx3("div", { className: "mt-1 text-sm leading-6 text-foreground", children: summary })
448
+ ] }) : null,
449
+ /* @__PURE__ */ jsx3("div", { className: "p-4", children: /* @__PURE__ */ jsx3(OpenUIArtifactRenderer, { schema }) })
450
+ ] });
451
+ } else if (toolPart.state.status === "running") {
452
+ node = /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-3 rounded-[20px] border border-[var(--border-subtle)] bg-[var(--bg-card)] px-4 py-3 text-sm text-muted-foreground", children: [
453
+ /* @__PURE__ */ jsx3(Loader22, { className: "h-4 w-4 animate-spin text-primary" }),
454
+ "Building view\u2026"
455
+ ] });
456
+ }
422
457
  }
423
- }
424
- if (node === null) {
425
- node = /* @__PURE__ */ jsx2(
426
- InlineToolItem,
458
+ if (node === null) {
459
+ node = /* @__PURE__ */ jsx3(
460
+ InlineToolItem,
461
+ {
462
+ part,
463
+ renderToolDetail,
464
+ groupPosition: getToolGroupPosition(partIndex, allParts),
465
+ actions: renderToolActions?.(part, {
466
+ run,
467
+ messageId: msgId,
468
+ partIndex: index
469
+ })
470
+ }
471
+ );
472
+ }
473
+ } else if (part.type === "reasoning") {
474
+ node = /* @__PURE__ */ jsx3(
475
+ InlineThinkingItem,
427
476
  {
428
477
  part,
429
- renderToolDetail,
430
- groupPosition: getToolGroupPosition(partIndex, allParts),
431
- actions: renderToolActions?.(part, {
432
- run,
433
- messageId: msgId,
434
- partIndex: index
435
- })
478
+ defaultOpen: isStreaming
436
479
  }
437
480
  );
481
+ } else if (part.type === "text" && !part.synthetic && part.text.trim()) {
482
+ node = /* @__PURE__ */ jsx3("div", { className: "px-1 py-1", children: /* @__PURE__ */ jsx3(Markdown, { className: "tangle-prose text-[15px] leading-7", children: part.text }) });
438
483
  }
439
- } else if (part.type === "reasoning") {
440
- node = /* @__PURE__ */ jsx2(
441
- InlineThinkingItem,
442
- {
443
- part,
444
- defaultOpen: isStreaming
445
- }
446
- );
447
- } else if (part.type === "text" && !part.synthetic && part.text.trim()) {
448
- node = /* @__PURE__ */ jsx2("div", { className: "px-1 py-1", children: /* @__PURE__ */ jsx2(Markdown, { className: "tangle-prose text-[15px] leading-7", children: part.text }) });
449
- }
450
- if (!node) return null;
451
- return /* @__PURE__ */ jsx2("div", { className: gapClass, children: node }, key);
452
- }) }) })
453
- ] }) });
484
+ if (!node) return null;
485
+ return /* @__PURE__ */ jsx3("div", { className: gapClass, children: node }, key);
486
+ })
487
+ }
488
+ );
454
489
  }
455
490
  );
456
491
  RunGroup.displayName = "RunGroup";
457
492
 
458
493
  export {
459
494
  InlineThinkingItem,
495
+ AssistantRunShell,
460
496
  RunGroup
461
497
  };
@@ -4,9 +4,10 @@ import {
4
4
  useRunGroups
5
5
  } from "./chunk-AZWDI2JG.js";
6
6
  import {
7
+ AssistantRunShell,
7
8
  InlineThinkingItem,
8
9
  RunGroup
9
- } from "./chunk-QIRVZMQY.js";
10
+ } from "./chunk-C3BIVG72.js";
10
11
  import {
11
12
  ToolCallGroup,
12
13
  ToolCallStep
@@ -98,6 +99,7 @@ var MessageList = memo2(
98
99
  MessageList.displayName = "MessageList";
99
100
 
100
101
  // src/chat/agent-timeline.tsx
102
+ import { useState as useState2 } from "react";
101
103
  import {
102
104
  AlertTriangle,
103
105
  CheckCircle2,
@@ -130,6 +132,48 @@ function ThinkingIndicator({ className }) {
130
132
 
131
133
  // src/chat/agent-timeline.tsx
132
134
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
135
+ function foldToolRuns(items) {
136
+ const nodes = [];
137
+ let run = [];
138
+ const flush = () => {
139
+ if (run.length === 0) return;
140
+ if (run.length === 1) {
141
+ nodes.push(run[0]);
142
+ } else {
143
+ nodes.push({ id: `tool-run-${run[0].id}`, kind: "tool_run", items: run });
144
+ }
145
+ run = [];
146
+ };
147
+ for (const item of items) {
148
+ if (item.kind === "tool" || item.kind === "tool_group") {
149
+ run.push(item);
150
+ } else {
151
+ flush();
152
+ nodes.push(item);
153
+ }
154
+ }
155
+ flush();
156
+ return nodes;
157
+ }
158
+ function countTools(group) {
159
+ return group.items.reduce(
160
+ (n, item) => n + (item.kind === "tool_group" ? item.calls.length : 1),
161
+ 0
162
+ );
163
+ }
164
+ function ToolCallRow({ call }) {
165
+ return /* @__PURE__ */ jsx4(
166
+ ToolCallStep,
167
+ {
168
+ type: call.type,
169
+ label: call.label,
170
+ status: call.status,
171
+ detail: call.detail,
172
+ output: call.output,
173
+ duration: call.duration
174
+ }
175
+ );
176
+ }
133
177
  var TONE_STYLES = {
134
178
  default: {
135
179
  dot: "bg-[var(--border-hover)]",
@@ -233,72 +277,76 @@ function AgentTimeline({
233
277
  items,
234
278
  isThinking,
235
279
  emptyState,
236
- className
280
+ className,
281
+ collapsibleToolRuns = true,
282
+ defaultToolRunsOpen = true
237
283
  }) {
284
+ const [collapsedRuns, setCollapsedRuns] = useState2({});
285
+ const toggleRun = (id) => setCollapsedRuns((prev) => ({
286
+ ...prev,
287
+ [id]: prev[id] === void 0 ? defaultToolRunsOpen : !prev[id]
288
+ }));
238
289
  if (items.length === 0 && !isThinking) {
239
290
  return emptyState ? /* @__PURE__ */ jsx4("div", { className: cn("flex h-full items-center justify-center p-4", className), children: emptyState }) : null;
240
291
  }
241
292
  const renderedItems = isThinking ? [...items, { id: "__thinking__", kind: "custom", content: /* @__PURE__ */ jsx4(ThinkingIndicator, {}) }] : items;
242
- const timelineItems = renderedItems.filter((item) => !(item.kind === "message" && item.role === "user"));
243
- return /* @__PURE__ */ jsx4("div", { className: cn("mx-auto w-full max-w-5xl px-4 py-4", className), children: renderedItems.map((item, index) => {
244
- if (item.kind === "message" && item.role === "user") {
245
- return /* @__PURE__ */ jsx4(UserMessage2, { item }, item.id);
246
- }
247
- const timelineIndex = timelineItems.indexOf(item);
248
- const isLast = timelineIndex === timelineItems.length - 1;
249
- if (item.kind === "message") {
250
- return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--brand-glow)]", children: /* @__PURE__ */ jsx4(AssistantMessage, { item }) }, item.id);
293
+ const nodes = collapsibleToolRuns ? foldToolRuns(renderedItems) : renderedItems;
294
+ const timelineNodes = nodes.filter(
295
+ (node) => !(node.kind === "message" && node.role === "user")
296
+ );
297
+ return /* @__PURE__ */ jsx4("div", { className: cn("mx-auto w-full max-w-5xl px-4 py-4", className), children: nodes.map((node) => {
298
+ if (node.kind === "message" && node.role === "user") {
299
+ return /* @__PURE__ */ jsx4(UserMessage2, { item: node }, node.id);
251
300
  }
252
- if (item.kind === "tool") {
301
+ const isLast = timelineNodes.indexOf(node) === timelineNodes.length - 1;
302
+ if (node.kind === "tool_run") {
303
+ const collapsed = collapsedRuns[node.id] ?? !defaultToolRunsOpen;
304
+ const total = countTools(node);
253
305
  return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--border-hover)]", children: /* @__PURE__ */ jsx4(
254
- ToolCallStep,
306
+ AssistantRunShell,
255
307
  {
256
- type: item.call.type,
257
- label: item.call.label,
258
- status: item.call.status,
259
- detail: item.call.detail,
260
- output: item.call.output,
261
- duration: item.call.duration
308
+ label: "Tools",
309
+ summary: `${total} ${total === 1 ? "tool" : "tools"}`,
310
+ collapsed,
311
+ onToggle: () => toggleRun(node.id),
312
+ children: /* @__PURE__ */ jsx4("div", { className: "space-y-px", children: node.items.map(
313
+ (item) => item.kind === "tool_group" ? /* @__PURE__ */ jsx4(ToolCallGroup, { title: item.title, children: item.calls.map((call) => /* @__PURE__ */ jsx4(ToolCallRow, { call }, call.id)) }, item.id) : /* @__PURE__ */ jsx4(ToolCallRow, { call: item.call }, item.id)
314
+ ) })
262
315
  }
263
- ) }, item.id);
316
+ ) }, node.id);
264
317
  }
265
- if (item.kind === "tool_group") {
266
- return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--border-hover)]", children: /* @__PURE__ */ jsx4(ToolCallGroup, { title: item.title, children: item.calls.map((call) => /* @__PURE__ */ jsx4(
267
- ToolCallStep,
268
- {
269
- type: call.type,
270
- label: call.label,
271
- status: call.status,
272
- detail: call.detail,
273
- output: call.output,
274
- duration: call.duration
275
- },
276
- call.id
277
- )) }) }, item.id);
318
+ if (node.kind === "message") {
319
+ return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--brand-glow)]", children: /* @__PURE__ */ jsx4(AssistantMessage, { item: node }) }, node.id);
320
+ }
321
+ if (node.kind === "tool") {
322
+ return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--border-hover)]", children: /* @__PURE__ */ jsx4(ToolCallRow, { call: node.call }) }, node.id);
323
+ }
324
+ if (node.kind === "tool_group") {
325
+ return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--border-hover)]", children: /* @__PURE__ */ jsx4(ToolCallGroup, { title: node.title, children: node.calls.map((call) => /* @__PURE__ */ jsx4(ToolCallRow, { call }, call.id)) }) }, node.id);
278
326
  }
279
- if (item.kind === "status") {
327
+ if (node.kind === "status") {
280
328
  return /* @__PURE__ */ jsx4(
281
329
  AgentTimelineRow,
282
330
  {
283
331
  isLast,
284
- accentClassName: TONE_STYLES[item.tone ?? "default"].dot,
285
- children: /* @__PURE__ */ jsx4(StatusCard, { item })
332
+ accentClassName: TONE_STYLES[node.tone ?? "default"].dot,
333
+ children: /* @__PURE__ */ jsx4(StatusCard, { item: node })
286
334
  },
287
- item.id
335
+ node.id
288
336
  );
289
337
  }
290
- if (item.kind === "artifact") {
338
+ if (node.kind === "artifact") {
291
339
  return /* @__PURE__ */ jsx4(
292
340
  AgentTimelineRow,
293
341
  {
294
342
  isLast,
295
- accentClassName: TONE_STYLES[item.tone ?? "default"].dot,
296
- children: /* @__PURE__ */ jsx4(ArtifactCard, { item })
343
+ accentClassName: TONE_STYLES[node.tone ?? "default"].dot,
344
+ children: /* @__PURE__ */ jsx4(ArtifactCard, { item: node })
297
345
  },
298
- item.id
346
+ node.id
299
347
  );
300
348
  }
301
- return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--border-hover)]", children: item.content }, item.id);
349
+ return /* @__PURE__ */ jsx4(AgentTimelineRow, { isLast, accentClassName: "bg-[var(--border-hover)]", children: node.content }, node.id);
302
350
  }) });
303
351
  }
304
352
 
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { Avatar, AvatarFallback, AvatarImage, Badge, BadgeProps, Card, CardConte
3
3
  export { Logo, LogoProps, TangleKnot } from '@tangle-network/brand';
4
4
  export { A as ArtifactPane, a as ArtifactPaneProps } from './artifact-pane-DvJyPWV4.js';
5
5
  export { AgentTimeline, AgentTimelineArtifactItem, AgentTimelineCustomItem, AgentTimelineItem, AgentTimelineMessageItem, AgentTimelineProps, AgentTimelineStatusItem, AgentTimelineTone, AgentTimelineToolGroupItem, AgentTimelineToolItem, ChatContainer, ChatContainerProps, ChatMessage, ChatMessageProps, MessageList, MessageListProps, MessageRole, ThinkingIndicator, ThinkingIndicatorProps, UserMessage, UserMessageProps } from './chat.js';
6
- export { ExpandedToolDetail, ExpandedToolDetailProps, InlineThinkingItem, InlineThinkingItemProps, InlineToolItem, InlineToolItemProps, LiveDuration, RunGroup, RunGroupProps } from './run.js';
6
+ export { AssistantRunShell, AssistantRunShellProps, ExpandedToolDetail, ExpandedToolDetailProps, InlineThinkingItem, InlineThinkingItemProps, InlineToolItem, InlineToolItemProps, LiveDuration, RunGroup, RunGroupProps } from './run.js';
7
7
  export { F as FeedSegment, T as ToolCallData, a as ToolCallFeed, b as ToolCallFeedProps, c as ToolCallStatus, d as ToolCallType, p as parseToolEvent } from './tool-call-feed-D9iofJgW.js';
8
8
  export { OpenUIAction, OpenUIActionsNode, OpenUIArtifactRenderer, OpenUIArtifactRendererProps, OpenUIBadgeNode, OpenUICardNode, OpenUICodeNode, OpenUIComponentNode, OpenUIGridNode, OpenUIHeadingNode, OpenUIKeyValueNode, OpenUIMarkdownNode, OpenUIPrimitive, OpenUISeparatorNode, OpenUIStackNode, OpenUIStatNode, OpenUITableNode, OpenUITextNode } from './openui.js';
9
9
  export { FileArtifactPane, FileArtifactPaneProps, FileFormat, FileNode, FilePreview, FilePreviewProps, FileTabData, FileTabs, FileTabsProps, FileTree, FileTreeProps, FileTreeVisibilityOptions, RichFileTree, RichFileTreeGitEntry, RichFileTreeGitStatus, RichFileTreeProps, RichFileTreeThemeVars, detectFileFormat, fileExtension, filterFileTree, getCodeLanguage, getFormatLabel, getSyntaxLanguage } from './files.js';
package/dist/index.js CHANGED
@@ -137,21 +137,22 @@ import {
137
137
  MessageList,
138
138
  ThinkingIndicator,
139
139
  UserMessage
140
- } from "./chunk-UOLL2YHG.js";
140
+ } from "./chunk-QUAU6ZNC.js";
141
141
  import {
142
142
  useAutoScroll,
143
143
  useRunCollapseState,
144
144
  useRunGroups
145
145
  } from "./chunk-AZWDI2JG.js";
146
- import "./chunk-47XH56SV.js";
146
+ import "./chunk-LQS34IGP.js";
147
147
  import {
148
148
  ToolCallFeed,
149
149
  parseToolEvent
150
150
  } from "./chunk-IWQZXL6A.js";
151
151
  import {
152
+ AssistantRunShell,
152
153
  InlineThinkingItem,
153
154
  RunGroup
154
- } from "./chunk-QIRVZMQY.js";
155
+ } from "./chunk-C3BIVG72.js";
155
156
  import {
156
157
  ExpandedToolDetail,
157
158
  InlineToolItem,
@@ -338,6 +339,7 @@ function RedactedDocument({
338
339
  export {
339
340
  AgentTimeline,
340
341
  ArtifactPane,
342
+ AssistantRunShell,
341
343
  AuthHeader,
342
344
  Avatar,
343
345
  AvatarFallback,
package/dist/run.d.ts CHANGED
@@ -28,6 +28,34 @@ interface RunGroupProps {
28
28
  */
29
29
  declare const RunGroup: React.MemoExoticComponent<({ run, partMap, collapsed, onToggle, branding, renderToolDetail, headerActions, renderToolActions, }: RunGroupProps) => react_jsx_runtime.JSX.Element | null>;
30
30
 
31
+ interface AssistantRunShellProps {
32
+ /** Header label, e.g. the agent name or "Tools". */
33
+ label: string;
34
+ /** Terse stat line beside the label, e.g. "3 tools, 2s thinking". */
35
+ summary?: string;
36
+ /** One-line preview shown next to the label AND below the header when collapsed. */
37
+ collapsedPreview?: string;
38
+ /** Small trailing glyphs before the status pill (e.g. category badges). */
39
+ badges?: ReactNode;
40
+ /** Drives the status pill and header spinner. */
41
+ isStreaming?: boolean;
42
+ collapsed: boolean;
43
+ onToggle: () => void;
44
+ /** Actions rendered outside the collapse trigger, right of the header. */
45
+ headerActions?: ReactNode;
46
+ children: ReactNode;
47
+ className?: string;
48
+ }
49
+ /**
50
+ * The collapsible "assistant run" container shared by `RunGroup` (session-model
51
+ * driven) and `AgentTimeline` (declarative item list). Owns the header
52
+ * (label · summary · badges · status pill · chevron), the collapsed preview, and
53
+ * the Radix collapse — so both transcripts fold agent activity the same way and
54
+ * there is one implementation of a run, not two. It renders only chrome; callers
55
+ * pass the run body (tool rows, reasoning, text) as `children`.
56
+ */
57
+ declare function AssistantRunShell({ label, summary, collapsedPreview, badges, isStreaming, collapsed, onToggle, headerActions, children, className, }: AssistantRunShellProps): react_jsx_runtime.JSX.Element;
58
+
31
59
  interface InlineToolItemProps {
32
60
  part: ToolPart;
33
61
  renderToolDetail?: CustomToolRenderer;
@@ -70,4 +98,4 @@ declare function LiveDuration({ startTime }: {
70
98
  startTime: number;
71
99
  }): react_jsx_runtime.JSX.Element;
72
100
 
73
- export { ExpandedToolDetail, type ExpandedToolDetailProps, InlineThinkingItem, type InlineThinkingItemProps, InlineToolItem, type InlineToolItemProps, LiveDuration, RunGroup, type RunGroupProps };
101
+ export { AssistantRunShell, type AssistantRunShellProps, ExpandedToolDetail, type ExpandedToolDetailProps, InlineThinkingItem, type InlineThinkingItemProps, InlineToolItem, type InlineToolItemProps, LiveDuration, RunGroup, type RunGroupProps };
package/dist/run.js CHANGED
@@ -1,12 +1,13 @@
1
- import "./chunk-47XH56SV.js";
1
+ import "./chunk-LQS34IGP.js";
2
2
  import {
3
3
  ToolCallFeed,
4
4
  parseToolEvent
5
5
  } from "./chunk-IWQZXL6A.js";
6
6
  import {
7
+ AssistantRunShell,
7
8
  InlineThinkingItem,
8
9
  RunGroup
9
- } from "./chunk-QIRVZMQY.js";
10
+ } from "./chunk-C3BIVG72.js";
10
11
  import {
11
12
  ExpandedToolDetail,
12
13
  InlineToolItem,
@@ -22,6 +23,7 @@ import "./chunk-FJBTCTZM.js";
22
23
  import "./chunk-WUQDUBJG.js";
23
24
  import "./chunk-RQHJBTEU.js";
24
25
  export {
26
+ AssistantRunShell,
25
27
  ExpandedToolDetail,
26
28
  InlineThinkingItem,
27
29
  InlineToolItem,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "8.0.0",
3
+ "version": "8.1.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -132,7 +132,7 @@
132
132
  "react": "^18 || ^19",
133
133
  "react-dom": "^18 || ^19",
134
134
  "react-router": "^7",
135
- "@tangle-network/brand": "^0.8.0"
135
+ "@tangle-network/brand": "^0.8.1"
136
136
  },
137
137
  "peerDependenciesMeta": {
138
138
  "@nanostores/react": {
@@ -1,4 +1,4 @@
1
- import { type KeyboardEvent, type ReactNode } from "react";
1
+ import { type KeyboardEvent, type ReactNode, useState } from "react";
2
2
  import {
3
3
  AlertTriangle,
4
4
  CheckCircle2,
@@ -12,6 +12,7 @@ import { Markdown } from "../markdown/markdown";
12
12
  import { ThinkingIndicator } from "./thinking-indicator";
13
13
  import { type ToolCallData } from "../run/tool-call-feed";
14
14
  import { ToolCallGroup, ToolCallStep } from "../run/tool-call-step";
15
+ import { AssistantRunShell } from "../run/assistant-run-shell";
15
16
 
16
17
  export type AgentTimelineTone = "default" | "info" | "success" | "warning" | "error";
17
18
 
@@ -78,6 +79,71 @@ export interface AgentTimelineProps {
78
79
  isThinking?: boolean;
79
80
  emptyState?: ReactNode;
80
81
  className?: string;
82
+ /**
83
+ * Fold consecutive tool / tool-group items into one collapsible run shell
84
+ * (the same `AssistantRunShell` `RunGroup` uses), so a burst of tool activity
85
+ * reads as a single toggleable step instead of a long ladder of rows.
86
+ * Default true; pass false for the flat one-row-per-tool timeline.
87
+ */
88
+ collapsibleToolRuns?: boolean;
89
+ /** Start collapsed tool runs open (true) or collapsed (false). Default open. */
90
+ defaultToolRunsOpen?: boolean;
91
+ }
92
+
93
+ /** A run of consecutive tool / tool-group items folded into one shell. */
94
+ interface ToolRunGroup {
95
+ id: string;
96
+ kind: "tool_run";
97
+ items: (AgentTimelineToolItem | AgentTimelineToolGroupItem)[];
98
+ }
99
+
100
+ type TimelineNode = AgentTimelineItem | ToolRunGroup;
101
+
102
+ function foldToolRuns(items: AgentTimelineItem[]): TimelineNode[] {
103
+ const nodes: TimelineNode[] = [];
104
+ let run: (AgentTimelineToolItem | AgentTimelineToolGroupItem)[] = [];
105
+
106
+ const flush = () => {
107
+ if (run.length === 0) return;
108
+ // A single tool stays a plain row; two or more fold into a collapsible run.
109
+ if (run.length === 1) {
110
+ nodes.push(run[0]);
111
+ } else {
112
+ nodes.push({ id: `tool-run-${run[0].id}`, kind: "tool_run", items: run });
113
+ }
114
+ run = [];
115
+ };
116
+
117
+ for (const item of items) {
118
+ if (item.kind === "tool" || item.kind === "tool_group") {
119
+ run.push(item);
120
+ } else {
121
+ flush();
122
+ nodes.push(item);
123
+ }
124
+ }
125
+ flush();
126
+ return nodes;
127
+ }
128
+
129
+ function countTools(group: ToolRunGroup): number {
130
+ return group.items.reduce(
131
+ (n, item) => n + (item.kind === "tool_group" ? item.calls.length : 1),
132
+ 0,
133
+ );
134
+ }
135
+
136
+ function ToolCallRow({ call }: { call: ToolCallData }) {
137
+ return (
138
+ <ToolCallStep
139
+ type={call.type}
140
+ label={call.label}
141
+ status={call.status}
142
+ detail={call.detail}
143
+ output={call.output}
144
+ duration={call.duration}
145
+ />
146
+ );
81
147
  }
82
148
 
83
149
  const TONE_STYLES: Record<AgentTimelineTone, { dot: string; card: string; text: string; icon: typeof Info }> = {
@@ -253,7 +319,17 @@ export function AgentTimeline({
253
319
  isThinking,
254
320
  emptyState,
255
321
  className,
322
+ collapsibleToolRuns = true,
323
+ defaultToolRunsOpen = true,
256
324
  }: AgentTimelineProps) {
325
+ // Collapse state for folded tool runs, keyed by run id. Absent → default.
326
+ const [collapsedRuns, setCollapsedRuns] = useState<Record<string, boolean>>({});
327
+ const toggleRun = (id: string) =>
328
+ setCollapsedRuns((prev) => ({
329
+ ...prev,
330
+ [id]: prev[id] === undefined ? defaultToolRunsOpen : !prev[id],
331
+ }));
332
+
257
333
  if (items.length === 0 && !isThinking) {
258
334
  return emptyState ? (
259
335
  <div className={cn("flex h-full items-center justify-center p-4", className)}>
@@ -266,92 +342,110 @@ export function AgentTimeline({
266
342
  ? [...items, { id: "__thinking__", kind: "custom", content: <ThinkingIndicator /> }]
267
343
  : items;
268
344
 
269
- // Determine which items participate in the timeline connector (non-user-message items)
270
- // User messages are rendered outside the timeline grid
271
- const timelineItems = renderedItems.filter((item) => !(item.kind === "message" && item.role === "user"));
345
+ const nodes: TimelineNode[] = collapsibleToolRuns
346
+ ? foldToolRuns(renderedItems)
347
+ : renderedItems;
348
+
349
+ // Non-user rows participate in the connector spine.
350
+ const timelineNodes = nodes.filter(
351
+ (node) => !(node.kind === "message" && node.role === "user"),
352
+ );
272
353
 
273
354
  return (
274
355
  <div className={cn("mx-auto w-full max-w-5xl px-4 py-4", className)}>
275
- {renderedItems.map((item, index) => {
356
+ {nodes.map((node) => {
276
357
  // User messages: right-aligned bubble, no connector
277
- if (item.kind === "message" && item.role === "user") {
278
- return <UserMessage key={item.id} item={item} />;
358
+ if (node.kind === "message" && node.role === "user") {
359
+ return <UserMessage key={node.id} item={node} />;
279
360
  }
280
361
 
281
- const timelineIndex = timelineItems.indexOf(item);
282
- const isLast = timelineIndex === timelineItems.length - 1;
362
+ const isLast = timelineNodes.indexOf(node) === timelineNodes.length - 1;
363
+
364
+ if (node.kind === "tool_run") {
365
+ const collapsed = collapsedRuns[node.id] ?? !defaultToolRunsOpen;
366
+ const total = countTools(node);
367
+ return (
368
+ <AgentTimelineRow key={node.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
369
+ <AssistantRunShell
370
+ label="Tools"
371
+ summary={`${total} ${total === 1 ? "tool" : "tools"}`}
372
+ collapsed={collapsed}
373
+ onToggle={() => toggleRun(node.id)}
374
+ >
375
+ <div className="space-y-px">
376
+ {node.items.map((item) =>
377
+ item.kind === "tool_group" ? (
378
+ <ToolCallGroup key={item.id} title={item.title}>
379
+ {item.calls.map((call) => (
380
+ <ToolCallRow key={call.id} call={call} />
381
+ ))}
382
+ </ToolCallGroup>
383
+ ) : (
384
+ <ToolCallRow key={item.id} call={item.call} />
385
+ ),
386
+ )}
387
+ </div>
388
+ </AssistantRunShell>
389
+ </AgentTimelineRow>
390
+ );
391
+ }
283
392
 
284
- if (item.kind === "message") {
393
+ if (node.kind === "message") {
285
394
  return (
286
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--brand-glow)]">
287
- <AssistantMessage item={item} />
395
+ <AgentTimelineRow key={node.id} isLast={isLast} accentClassName="bg-[var(--brand-glow)]">
396
+ <AssistantMessage item={node} />
288
397
  </AgentTimelineRow>
289
398
  );
290
399
  }
291
400
 
292
- if (item.kind === "tool") {
401
+ if (node.kind === "tool") {
293
402
  return (
294
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
295
- <ToolCallStep
296
- type={item.call.type}
297
- label={item.call.label}
298
- status={item.call.status}
299
- detail={item.call.detail}
300
- output={item.call.output}
301
- duration={item.call.duration}
302
- />
403
+ <AgentTimelineRow key={node.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
404
+ <ToolCallRow call={node.call} />
303
405
  </AgentTimelineRow>
304
406
  );
305
407
  }
306
408
 
307
- if (item.kind === "tool_group") {
409
+ if (node.kind === "tool_group") {
308
410
  return (
309
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
310
- <ToolCallGroup title={item.title}>
311
- {item.calls.map((call) => (
312
- <ToolCallStep
313
- key={call.id}
314
- type={call.type}
315
- label={call.label}
316
- status={call.status}
317
- detail={call.detail}
318
- output={call.output}
319
- duration={call.duration}
320
- />
411
+ <AgentTimelineRow key={node.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
412
+ <ToolCallGroup title={node.title}>
413
+ {node.calls.map((call) => (
414
+ <ToolCallRow key={call.id} call={call} />
321
415
  ))}
322
416
  </ToolCallGroup>
323
417
  </AgentTimelineRow>
324
418
  );
325
419
  }
326
420
 
327
- if (item.kind === "status") {
421
+ if (node.kind === "status") {
328
422
  return (
329
423
  <AgentTimelineRow
330
- key={item.id}
424
+ key={node.id}
331
425
  isLast={isLast}
332
- accentClassName={TONE_STYLES[item.tone ?? "default"].dot}
426
+ accentClassName={TONE_STYLES[node.tone ?? "default"].dot}
333
427
  >
334
- <StatusCard item={item} />
428
+ <StatusCard item={node} />
335
429
  </AgentTimelineRow>
336
430
  );
337
431
  }
338
432
 
339
- if (item.kind === "artifact") {
433
+ if (node.kind === "artifact") {
340
434
  return (
341
435
  <AgentTimelineRow
342
- key={item.id}
436
+ key={node.id}
343
437
  isLast={isLast}
344
- accentClassName={TONE_STYLES[item.tone ?? "default"].dot}
438
+ accentClassName={TONE_STYLES[node.tone ?? "default"].dot}
345
439
  >
346
- <ArtifactCard item={item} />
440
+ <ArtifactCard item={node} />
347
441
  </AgentTimelineRow>
348
442
  );
349
443
  }
350
444
 
351
445
  // custom
352
446
  return (
353
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
354
- {(item as AgentTimelineCustomItem).content}
447
+ <AgentTimelineRow key={node.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
448
+ {(node as AgentTimelineCustomItem).content}
355
449
  </AgentTimelineRow>
356
450
  );
357
451
  })}
@@ -0,0 +1,115 @@
1
+ import { type ReactNode } from "react";
2
+ import * as Collapsible from "@radix-ui/react-collapsible";
3
+ import { ChevronDown, ChevronRight, Loader2, Sparkles } from "lucide-react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ export interface AssistantRunShellProps {
7
+ /** Header label, e.g. the agent name or "Tools". */
8
+ label: string;
9
+ /** Terse stat line beside the label, e.g. "3 tools, 2s thinking". */
10
+ summary?: string;
11
+ /** One-line preview shown next to the label AND below the header when collapsed. */
12
+ collapsedPreview?: string;
13
+ /** Small trailing glyphs before the status pill (e.g. category badges). */
14
+ badges?: ReactNode;
15
+ /** Drives the status pill and header spinner. */
16
+ isStreaming?: boolean;
17
+ collapsed: boolean;
18
+ onToggle: () => void;
19
+ /** Actions rendered outside the collapse trigger, right of the header. */
20
+ headerActions?: ReactNode;
21
+ children: ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * The collapsible "assistant run" container shared by `RunGroup` (session-model
27
+ * driven) and `AgentTimeline` (declarative item list). Owns the header
28
+ * (label · summary · badges · status pill · chevron), the collapsed preview, and
29
+ * the Radix collapse — so both transcripts fold agent activity the same way and
30
+ * there is one implementation of a run, not two. It renders only chrome; callers
31
+ * pass the run body (tool rows, reasoning, text) as `children`.
32
+ */
33
+ export function AssistantRunShell({
34
+ label,
35
+ summary,
36
+ collapsedPreview,
37
+ badges,
38
+ isStreaming,
39
+ collapsed,
40
+ onToggle,
41
+ headerActions,
42
+ children,
43
+ className,
44
+ }: AssistantRunShellProps) {
45
+ return (
46
+ <Collapsible.Root open={!collapsed} onOpenChange={() => onToggle()}>
47
+ <div
48
+ className={cn(
49
+ "rounded-[28px] border border-[var(--border-subtle)] bg-[var(--bg-card)] shadow-none",
50
+ className,
51
+ )}
52
+ >
53
+ <div className="flex items-start gap-3 px-3 py-2.5">
54
+ <Collapsible.Trigger asChild>
55
+ <button
56
+ type="button"
57
+ className="w-full rounded-[20px] bg-transparent px-0 py-0 text-left transition-colors hover:bg-transparent"
58
+ >
59
+ <div className="flex items-center gap-2">
60
+ <span className="font-semibold text-foreground text-sm">{label}</span>
61
+
62
+ {summary ? (
63
+ <span className="text-[11px] text-muted-foreground">{summary}</span>
64
+ ) : null}
65
+ {collapsed && collapsedPreview ? (
66
+ <span className="min-w-0 truncate text-[11px] text-foreground/70">
67
+ {collapsedPreview}
68
+ </span>
69
+ ) : null}
70
+
71
+ <div className="ml-auto flex shrink-0 items-center gap-1.5">
72
+ {badges}
73
+
74
+ {isStreaming ? (
75
+ <span className="inline-flex items-center gap-1 rounded-full border border-[var(--border-accent)] bg-[var(--accent-surface-soft)] px-2 py-px text-[10px] font-semibold uppercase text-[var(--accent-text)]">
76
+ <Loader2 className="h-2.5 w-2.5 animate-spin" />
77
+ Running
78
+ </span>
79
+ ) : (
80
+ <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-px text-[10px] font-semibold uppercase text-muted-foreground">
81
+ <Sparkles className="h-2.5 w-2.5" />
82
+ Done
83
+ </span>
84
+ )}
85
+
86
+ {collapsed ? (
87
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
88
+ ) : (
89
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
90
+ )}
91
+ </div>
92
+ </div>
93
+ </button>
94
+ </Collapsible.Trigger>
95
+
96
+ {headerActions ? (
97
+ <div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5 pt-1">
98
+ {headerActions}
99
+ </div>
100
+ ) : null}
101
+ </div>
102
+
103
+ {collapsed && collapsedPreview ? (
104
+ <div className="line-clamp-2 px-4 pb-4 text-sm leading-6 text-muted-foreground">
105
+ {collapsedPreview}
106
+ </div>
107
+ ) : null}
108
+
109
+ <Collapsible.Content className="overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp">
110
+ <div className="border-t border-[var(--border-subtle)] px-4 pb-4 pt-3">{children}</div>
111
+ </Collapsible.Content>
112
+ </div>
113
+ </Collapsible.Root>
114
+ );
115
+ }
package/src/run/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { RunGroup, type RunGroupProps } from "./run-group";
2
+ export { AssistantRunShell, type AssistantRunShellProps } from "./assistant-run-shell";
2
3
  export { InlineToolItem, type InlineToolItemProps } from "./inline-tool-item";
3
4
  export {
4
5
  InlineThinkingItem,
@@ -1,10 +1,7 @@
1
1
  import { memo, useMemo, type ComponentType, type ReactNode } from "react";
2
- import * as Collapsible from "@radix-ui/react-collapsible";
3
2
  import {
4
3
  Bot,
5
4
  Loader2,
6
- ChevronDown,
7
- ChevronRight,
8
5
  Terminal,
9
6
  FileEdit,
10
7
  FileSearch,
@@ -13,10 +10,8 @@ import {
13
10
  Globe,
14
11
  ClipboardList,
15
12
  Settings,
16
- Sparkles,
17
13
  type LucideProps,
18
14
  } from "lucide-react";
19
- import { cn } from "../lib/utils";
20
15
  import { formatDuration } from "../utils/format";
21
16
  import type { Run, ToolCategory } from "../types/run";
22
17
  import type { SessionPart, ToolPart, ReasoningPart } from "../types/parts";
@@ -24,6 +19,7 @@ import type { AgentBranding } from "../types/branding";
24
19
  import type { CustomToolRenderer } from "../types/tool-display";
25
20
  import { InlineToolItem } from "./inline-tool-item";
26
21
  import { InlineThinkingItem } from "./inline-thinking-item";
22
+ import { AssistantRunShell } from "./assistant-run-shell";
27
23
  import { Markdown } from "../markdown/markdown";
28
24
  import {
29
25
  OpenUIArtifactRenderer,
@@ -393,73 +389,16 @@ export const RunGroup = memo(
393
389
  }
394
390
 
395
391
  return (
396
- <Collapsible.Root open={!collapsed} onOpenChange={() => onToggle()}>
397
- <div className="rounded-[28px] border border-[var(--border-subtle)] bg-[var(--bg-card)] shadow-none">
398
- {/* Header */}
399
- <div className="flex items-start gap-3 px-3 py-2.5">
400
- <Collapsible.Trigger asChild>
401
- <button
402
- className={cn(
403
- "w-full rounded-[20px] px-0 py-0 text-left transition-colors",
404
- "bg-transparent hover:bg-transparent",
405
- )}
406
- >
407
- <div className="flex items-center gap-2">
408
- <span className={cn("font-semibold text-sm", branding.textClass)}>
409
- {branding.label}
410
- </span>
411
-
412
- {renderSummary(run) ? (
413
- <span className="text-[11px] text-muted-foreground">{renderSummary(run)}</span>
414
- ) : null}
415
- {collapsed && run.summaryText ? (
416
- <span className="min-w-0 truncate text-[11px] text-foreground/70">
417
- {run.summaryText}
418
- </span>
419
- ) : null}
420
-
421
- <div className="ml-auto flex shrink-0 items-center gap-1.5">
422
- <CategoryBadges categories={stats.toolCategories} />
423
-
424
- {isStreaming ? (
425
- <span className="inline-flex items-center gap-1 rounded-full border border-[var(--border-accent)] bg-[var(--accent-surface-soft)] px-2 py-px text-[10px] font-semibold uppercase text-[var(--accent-text)]">
426
- <Loader2 className="h-2.5 w-2.5 animate-spin" />
427
- Running
428
- </span>
429
- ) : (
430
- <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-px text-[10px] font-semibold uppercase text-muted-foreground">
431
- <Sparkles className="h-2.5 w-2.5" />
432
- Done
433
- </span>
434
- )}
435
-
436
- {!collapsed ? (
437
- <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
438
- ) : (
439
- <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
440
- )}
441
- </div>
442
- </div>
443
- </button>
444
- </Collapsible.Trigger>
445
-
446
- {headerActions ? (
447
- <div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5 pt-1">
448
- {headerActions}
449
- </div>
450
- ) : null}
451
- </div>
452
-
453
- {/* Summary text when collapsed */}
454
- {collapsed && run.summaryText && (
455
- <div className="px-4 pb-4 text-sm leading-6 text-muted-foreground line-clamp-2">
456
- {run.summaryText}
457
- </div>
458
- )}
459
-
460
- {/* Expanded content */}
461
- <Collapsible.Content className="overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp">
462
- <div className={cn("border-t border-[var(--border-subtle)] px-4 pb-4 pt-3")}>
392
+ <AssistantRunShell
393
+ label={branding.label}
394
+ summary={renderSummary(run) || undefined}
395
+ collapsedPreview={run.summaryText ?? undefined}
396
+ badges={<CategoryBadges categories={stats.toolCategories} />}
397
+ isStreaming={isStreaming}
398
+ collapsed={collapsed}
399
+ onToggle={onToggle}
400
+ headerActions={headerActions}
401
+ >
463
402
  {allParts.map(({ part, msgId, index }, partIndex) => {
464
403
  const key = `${msgId}-${index}`;
465
404
 
@@ -549,10 +488,7 @@ export const RunGroup = memo(
549
488
  </div>
550
489
  );
551
490
  })}
552
- </div>
553
- </Collapsible.Content>
554
- </div>
555
- </Collapsible.Root>
491
+ </AssistantRunShell>
556
492
  );
557
493
  },
558
494
  );
File without changes