@tangle-network/ui 8.0.0 → 9.0.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,23 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 9.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 87252cf: Flip the transcript convergence: RunGroup adopts AgentTimeline's look, not the reverse. 8.1 made AgentTimeline fold tool activity into RunGroup's single filled box (`AssistantRunShell`); that boxed all steps into one card and lost the timeline's separated, distinct rows. Reverted.
8
+
9
+ - **`RunGroup`** now renders as separated steps on a timeline spine (connector line + accent dots, one row per tool/reasoning/text part) with a quiet collapsible header (chevron · label · summary · status) — no wrapping `bg-card` box, and consecutive tools are no longer joined into one block. It reads like `AgentTimeline`, plus collapse.
10
+ - **`AgentTimeline`** is restored to its prior flat, separated rendering (no tool-run folding). The `collapsibleToolRuns` / `defaultToolRunsOpen` props added in 8.1 are removed.
11
+ - **`AssistantRunShell`** (added in 8.1) is removed — the boxed shell is gone.
12
+
13
+ BREAKING: `AssistantRunShell` / `AssistantRunShellProps` are no longer exported, and `AgentTimeline` drops the `collapsibleToolRuns` / `defaultToolRunsOpen` props.
14
+
15
+ ## 8.1.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 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.
20
+
3
21
  ## 8.0.0
4
22
 
5
23
  ### Major Changes
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-LHOGIUGY.js";
9
9
  import "./chunk-AZWDI2JG.js";
10
- import "./chunk-QIRVZMQY.js";
10
+ import "./chunk-2TRMNB6L.js";
11
11
  import "./chunk-RKQDBRTC.js";
12
12
  import "./chunk-ULDNFLIM.js";
13
13
  import "./chunk-AAUNOHVL.js";
@@ -127,6 +127,27 @@ import {
127
127
  Sparkles
128
128
  } from "lucide-react";
129
129
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
130
+ function SpineRow({
131
+ accentClassName,
132
+ isLast,
133
+ children
134
+ }) {
135
+ return /* @__PURE__ */ jsxs2("div", { className: "grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3", children: [
136
+ /* @__PURE__ */ jsxs2("div", { className: "relative flex justify-center", children: [
137
+ !isLast && /* @__PURE__ */ jsx2("span", { className: "absolute top-3.5 bottom-[-0.75rem] left-1/2 w-px -translate-x-1/2 bg-[var(--border-subtle)]" }),
138
+ /* @__PURE__ */ jsx2(
139
+ "span",
140
+ {
141
+ className: cn(
142
+ "relative mt-1.5 h-[var(--timeline-dot-size,0.5rem)] w-[var(--timeline-dot-size,0.5rem)] rounded-full ring-4 ring-[var(--bg-root)]",
143
+ accentClassName
144
+ )
145
+ }
146
+ )
147
+ ] }),
148
+ /* @__PURE__ */ jsx2("div", { className: "min-w-0 pb-3", children })
149
+ ] });
150
+ }
130
151
  var DEFAULT_BRANDING = {
131
152
  label: "Agent",
132
153
  accentClass: "text-primary",
@@ -268,16 +289,6 @@ function renderSummary(run) {
268
289
  }
269
290
  return parts.join(", ");
270
291
  }
271
- function getToolGroupPosition(currentIndex, parts) {
272
- const previous = parts[currentIndex - 1]?.part;
273
- const next = parts[currentIndex + 1]?.part;
274
- const previousIsTool = previous?.type === "tool";
275
- const nextIsTool = next?.type === "tool";
276
- if (previousIsTool && nextIsTool) return "middle";
277
- if (previousIsTool) return "last";
278
- if (nextIsTool) return "first";
279
- return "single";
280
- }
281
292
  var RunGroup = memo2(
282
293
  ({
283
294
  run,
@@ -365,20 +376,28 @@ var RunGroup = memo2(
365
376
  return null;
366
377
  }) });
367
378
  }
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(
379
+ const rows = allParts.filter(({ part }) => {
380
+ if (part.type === "tool" || part.type === "reasoning") return true;
381
+ return part.type === "text" && !part.synthetic && part.text.trim().length > 0;
382
+ });
383
+ const dotAccent = (part) => {
384
+ if (part.type === "reasoning") return "bg-[var(--brand-glow)]";
385
+ if (part.type === "text") return "bg-primary";
386
+ return "bg-[var(--border-hover)]";
387
+ };
388
+ return /* @__PURE__ */ jsx2(Collapsible2.Root, { open: !collapsed, onOpenChange: () => onToggle(), children: /* @__PURE__ */ jsxs2("div", { className: "flex flex-col gap-1", children: [
389
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-start gap-2", children: [
390
+ /* @__PURE__ */ jsx2(Collapsible2.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs2(
371
391
  "button",
372
392
  {
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: [
393
+ type: "button",
394
+ className: "group flex min-w-0 flex-1 items-center gap-2 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-[var(--surface-container-high)]/40",
395
+ children: [
396
+ collapsed ? /* @__PURE__ */ jsx2(ChevronRight2, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }) : /* @__PURE__ */ jsx2(ChevronDown, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }),
378
397
  /* @__PURE__ */ jsx2("span", { className: cn("font-semibold text-sm", branding.textClass), children: branding.label }),
379
398
  renderSummary(run) ? /* @__PURE__ */ jsx2("span", { className: "text-[11px] text-muted-foreground", children: renderSummary(run) }) : null,
380
399
  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: [
400
+ /* @__PURE__ */ jsxs2("span", { className: "ml-auto flex shrink-0 items-center gap-1.5", children: [
382
401
  /* @__PURE__ */ jsx2(CategoryBadges, { categories: stats.toolCategories }),
383
402
  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
403
  /* @__PURE__ */ jsx2(Loader2, { className: "h-2.5 w-2.5 animate-spin" }),
@@ -386,20 +405,17 @@ var RunGroup = memo2(
386
405
  ] }) : /* @__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
406
  /* @__PURE__ */ jsx2(Sparkles, { className: "h-2.5 w-2.5" }),
388
407
  "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" })
408
+ ] })
391
409
  ] })
392
- ] })
410
+ ]
393
411
  }
394
412
  ) }),
395
413
  headerActions ? /* @__PURE__ */ jsx2("div", { className: "flex shrink-0 flex-wrap items-center justify-end gap-1.5 pt-1", children: headerActions }) : null
396
414
  ] }),
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) => {
415
+ collapsed && run.summaryText ? /* @__PURE__ */ jsx2("div", { className: "line-clamp-2 pl-6 text-sm leading-6 text-muted-foreground", children: run.summaryText }) : null,
416
+ /* @__PURE__ */ jsx2(Collapsible2.Content, { className: "overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp", children: /* @__PURE__ */ jsx2("div", { className: "pt-1.5", children: rows.map(({ part, msgId, index }, rowIndex) => {
399
417
  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";
418
+ const isLast = rowIndex === rows.length - 1;
403
419
  let node = null;
404
420
  if (part.type === "tool") {
405
421
  if (isOpenUITool(part)) {
@@ -407,15 +423,12 @@ var RunGroup = memo2(
407
423
  const schema = extractOpenUISchema(toolPart.state.output);
408
424
  const summary = getOpenUISummary(toolPart.state.output);
409
425
  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,
426
+ node = /* @__PURE__ */ jsxs2("div", { className: "overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-card)]", children: [
427
+ summary ? /* @__PURE__ */ jsx2("div", { className: "border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground", children: summary }) : null,
415
428
  /* @__PURE__ */ jsx2("div", { className: "p-4", children: /* @__PURE__ */ jsx2(OpenUIArtifactRenderer, { schema }) })
416
429
  ] });
417
430
  } 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: [
431
+ node = /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-card)] px-4 py-3 text-sm text-muted-foreground", children: [
419
432
  /* @__PURE__ */ jsx2(Loader2, { className: "h-4 w-4 animate-spin text-primary" }),
420
433
  "Building view\u2026"
421
434
  ] });
@@ -427,7 +440,6 @@ var RunGroup = memo2(
427
440
  {
428
441
  part,
429
442
  renderToolDetail,
430
- groupPosition: getToolGroupPosition(partIndex, allParts),
431
443
  actions: renderToolActions?.(part, {
432
444
  run,
433
445
  messageId: msgId,
@@ -437,18 +449,12 @@ var RunGroup = memo2(
437
449
  );
438
450
  }
439
451
  } else if (part.type === "reasoning") {
440
- node = /* @__PURE__ */ jsx2(
441
- InlineThinkingItem,
442
- {
443
- part,
444
- defaultOpen: isStreaming
445
- }
446
- );
452
+ node = /* @__PURE__ */ jsx2(InlineThinkingItem, { part, defaultOpen: isStreaming });
447
453
  } 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 }) });
454
+ node = /* @__PURE__ */ jsx2("div", { className: "px-1 py-0.5", children: /* @__PURE__ */ jsx2(Markdown, { className: "tangle-prose text-[15px] leading-7", children: part.text }) });
449
455
  }
450
456
  if (!node) return null;
451
- return /* @__PURE__ */ jsx2("div", { className: gapClass, children: node }, key);
457
+ return /* @__PURE__ */ jsx2(SpineRow, { accentClassName: dotAccent(part), isLast, children: node }, key);
452
458
  }) }) })
453
459
  ] }) });
454
460
  }
@@ -6,7 +6,7 @@ import {
6
6
  import {
7
7
  InlineThinkingItem,
8
8
  RunGroup
9
- } from "./chunk-QIRVZMQY.js";
9
+ } from "./chunk-2TRMNB6L.js";
10
10
  import {
11
11
  ToolCallGroup,
12
12
  ToolCallStep
package/dist/index.js CHANGED
@@ -137,7 +137,7 @@ import {
137
137
  MessageList,
138
138
  ThinkingIndicator,
139
139
  UserMessage
140
- } from "./chunk-UOLL2YHG.js";
140
+ } from "./chunk-LHOGIUGY.js";
141
141
  import {
142
142
  useAutoScroll,
143
143
  useRunCollapseState,
@@ -151,7 +151,7 @@ import {
151
151
  import {
152
152
  InlineThinkingItem,
153
153
  RunGroup
154
- } from "./chunk-QIRVZMQY.js";
154
+ } from "./chunk-2TRMNB6L.js";
155
155
  import {
156
156
  ExpandedToolDetail,
157
157
  InlineToolItem,
package/dist/run.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  import {
7
7
  InlineThinkingItem,
8
8
  RunGroup
9
- } from "./chunk-QIRVZMQY.js";
9
+ } from "./chunk-2TRMNB6L.js";
10
10
  import {
11
11
  ExpandedToolDetail,
12
12
  InlineToolItem,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "8.0.0",
3
+ "version": "9.0.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": {
@@ -25,6 +25,38 @@ import type { CustomToolRenderer } from "../types/tool-display";
25
25
  import { InlineToolItem } from "./inline-tool-item";
26
26
  import { InlineThinkingItem } from "./inline-thinking-item";
27
27
  import { Markdown } from "../markdown/markdown";
28
+
29
+ /**
30
+ * One row on the run's timeline spine: a connector line + accent dot in a
31
+ * narrow gutter, content to the right. Mirrors AgentTimeline's row so a run
32
+ * reads as separated, distinct steps — not one filled box.
33
+ */
34
+ function SpineRow({
35
+ accentClassName,
36
+ isLast,
37
+ children,
38
+ }: {
39
+ accentClassName: string;
40
+ isLast: boolean;
41
+ children: ReactNode;
42
+ }) {
43
+ return (
44
+ <div className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3">
45
+ <div className="relative flex justify-center">
46
+ {!isLast && (
47
+ <span className="absolute top-3.5 bottom-[-0.75rem] left-1/2 w-px -translate-x-1/2 bg-[var(--border-subtle)]" />
48
+ )}
49
+ <span
50
+ className={cn(
51
+ "relative mt-1.5 h-[var(--timeline-dot-size,0.5rem)] w-[var(--timeline-dot-size,0.5rem)] rounded-full ring-4 ring-[var(--bg-root)]",
52
+ accentClassName,
53
+ )}
54
+ />
55
+ </div>
56
+ <div className="min-w-0 pb-3">{children}</div>
57
+ </div>
58
+ );
59
+ }
28
60
  import {
29
61
  OpenUIArtifactRenderer,
30
62
  type OpenUIAction,
@@ -245,21 +277,6 @@ function renderSummary(run: Run) {
245
277
  return parts.join(", ");
246
278
  }
247
279
 
248
- function getToolGroupPosition(
249
- currentIndex: number,
250
- parts: Array<{ part: SessionPart; msgId: string; index: number }>,
251
- ) {
252
- const previous = parts[currentIndex - 1]?.part;
253
- const next = parts[currentIndex + 1]?.part;
254
- const previousIsTool = previous?.type === "tool";
255
- const nextIsTool = next?.type === "tool";
256
-
257
- if (previousIsTool && nextIsTool) return "middle" as const;
258
- if (previousIsTool) return "last" as const;
259
- if (nextIsTool) return "first" as const;
260
- return "single" as const;
261
- }
262
-
263
280
  // ---------------------------------------------------------------------------
264
281
  // Component
265
282
  // ---------------------------------------------------------------------------
@@ -392,23 +409,36 @@ export const RunGroup = memo(
392
409
  );
393
410
  }
394
411
 
412
+ // Renderable rows: skip empty/synthetic text so spine dots map to real steps.
413
+ const rows = allParts.filter(({ part }) => {
414
+ if (part.type === "tool" || part.type === "reasoning") return true;
415
+ return part.type === "text" && !part.synthetic && part.text.trim().length > 0;
416
+ });
417
+
418
+ const dotAccent = (part: SessionPart): string => {
419
+ if (part.type === "reasoning") return "bg-[var(--brand-glow)]";
420
+ if (part.type === "text") return "bg-primary";
421
+ return "bg-[var(--border-hover)]";
422
+ };
423
+
395
424
  return (
396
425
  <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">
426
+ <div className="flex flex-col gap-1">
427
+ {/* Header — a quiet row, not a filled box */}
428
+ <div className="flex items-start gap-2">
429
+ <Collapsible.Trigger asChild>
430
+ <button
431
+ type="button"
432
+ className="group flex min-w-0 flex-1 items-center gap-2 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-[var(--surface-container-high)]/40"
433
+ >
434
+ {collapsed ? (
435
+ <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
436
+ ) : (
437
+ <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
438
+ )}
408
439
  <span className={cn("font-semibold text-sm", branding.textClass)}>
409
440
  {branding.label}
410
441
  </span>
411
-
412
442
  {renderSummary(run) ? (
413
443
  <span className="text-[11px] text-muted-foreground">{renderSummary(run)}</span>
414
444
  ) : null}
@@ -417,10 +447,8 @@ export const RunGroup = memo(
417
447
  {run.summaryText}
418
448
  </span>
419
449
  ) : null}
420
-
421
- <div className="ml-auto flex shrink-0 items-center gap-1.5">
450
+ <span className="ml-auto flex shrink-0 items-center gap-1.5">
422
451
  <CategoryBadges categories={stats.toolCategories} />
423
-
424
452
  {isStreaming ? (
425
453
  <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
454
  <Loader2 className="h-2.5 w-2.5 animate-spin" />
@@ -432,125 +460,94 @@ export const RunGroup = memo(
432
460
  Done
433
461
  </span>
434
462
  )}
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>
463
+ </span>
464
+ </button>
465
+ </Collapsible.Trigger>
466
+ {headerActions ? (
467
+ <div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5 pt-1">
468
+ {headerActions}
442
469
  </div>
443
- </button>
444
- </Collapsible.Trigger>
470
+ ) : null}
471
+ </div>
445
472
 
446
- {headerActions ? (
447
- <div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5 pt-1">
448
- {headerActions}
473
+ {/* Collapsed preview */}
474
+ {collapsed && run.summaryText ? (
475
+ <div className="line-clamp-2 pl-6 text-sm leading-6 text-muted-foreground">
476
+ {run.summaryText}
449
477
  </div>
450
478
  ) : 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
479
 
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")}>
463
- {allParts.map(({ part, msgId, index }, partIndex) => {
464
- const key = `${msgId}-${index}`;
465
-
466
- // Consecutive (non-OpenUI) tool calls connect into one block —
467
- // `getToolGroupPosition` already gives them joined radii, so they
468
- // get no vertical gap; every other transition keeps a normal gap.
469
- const prev = allParts[partIndex - 1]?.part;
470
- const connectedTool =
471
- part.type === "tool" &&
472
- prev?.type === "tool" &&
473
- !isOpenUITool(part as ToolPart) &&
474
- !isOpenUITool(prev as ToolPart);
475
- const gapClass =
476
- partIndex === 0 ? "" : connectedTool ? "mt-px" : "mt-3";
477
-
478
- let node: ReactNode = null;
479
-
480
- if (part.type === "tool") {
481
- if (isOpenUITool(part as ToolPart)) {
482
- const toolPart = part as ToolPart;
483
- const schema = extractOpenUISchema(toolPart.state.output);
484
- const summary = getOpenUISummary(toolPart.state.output);
485
-
486
- if (toolPart.state.status === "completed" && schema) {
487
- node = (
488
- <div className="overflow-hidden rounded-[24px] border border-[var(--border-subtle)] bg-[var(--bg-card)]">
489
- {summary ? (
490
- <div className="border-b border-[var(--border-subtle)] px-4 py-3">
491
- <div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
492
- View
480
+ {/* Expanded separated steps on a timeline spine, no wrapping box */}
481
+ <Collapsible.Content className="overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp">
482
+ <div className="pt-1.5">
483
+ {rows.map(({ part, msgId, index }, rowIndex) => {
484
+ const key = `${msgId}-${index}`;
485
+ const isLast = rowIndex === rows.length - 1;
486
+ let node: ReactNode = null;
487
+
488
+ if (part.type === "tool") {
489
+ if (isOpenUITool(part as ToolPart)) {
490
+ const toolPart = part as ToolPart;
491
+ const schema = extractOpenUISchema(toolPart.state.output);
492
+ const summary = getOpenUISummary(toolPart.state.output);
493
+
494
+ if (toolPart.state.status === "completed" && schema) {
495
+ node = (
496
+ <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-card)]">
497
+ {summary ? (
498
+ <div className="border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground">
499
+ {summary}
493
500
  </div>
494
- <div className="mt-1 text-sm leading-6 text-foreground">{summary}</div>
501
+ ) : null}
502
+ <div className="p-4">
503
+ <OpenUIArtifactRenderer schema={schema} />
495
504
  </div>
496
- ) : null}
497
- <div className="p-4">
498
- <OpenUIArtifactRenderer schema={schema} />
499
505
  </div>
500
- </div>
501
- );
502
- } else if (toolPart.state.status === "running") {
506
+ );
507
+ } else if (toolPart.state.status === "running") {
508
+ node = (
509
+ <div className="flex items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-card)] px-4 py-3 text-sm text-muted-foreground">
510
+ <Loader2 className="h-4 w-4 animate-spin text-primary" />
511
+ Building view…
512
+ </div>
513
+ );
514
+ }
515
+ }
516
+
517
+ if (node === null) {
503
518
  node = (
504
- <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">
505
- <Loader2 className="h-4 w-4 animate-spin text-primary" />
506
- Building view…
507
- </div>
519
+ <InlineToolItem
520
+ part={part as ToolPart}
521
+ renderToolDetail={renderToolDetail}
522
+ actions={renderToolActions?.(part as ToolPart, {
523
+ run,
524
+ messageId: msgId,
525
+ partIndex: index,
526
+ })}
527
+ />
508
528
  );
509
529
  }
510
- }
511
-
512
- if (node === null) {
530
+ } else if (part.type === "reasoning") {
531
+ node = (
532
+ <InlineThinkingItem part={part as ReasoningPart} defaultOpen={isStreaming} />
533
+ );
534
+ } else if (part.type === "text" && !part.synthetic && part.text.trim()) {
513
535
  node = (
514
- <InlineToolItem
515
- part={part as ToolPart}
516
- renderToolDetail={renderToolDetail}
517
- groupPosition={getToolGroupPosition(partIndex, allParts)}
518
- actions={renderToolActions?.(part as ToolPart, {
519
- run,
520
- messageId: msgId,
521
- partIndex: index,
522
- })}
523
- />
536
+ <div className="px-1 py-0.5">
537
+ <Markdown className="tangle-prose text-[15px] leading-7">{part.text}</Markdown>
538
+ </div>
524
539
  );
525
540
  }
526
- } else if (part.type === "reasoning") {
527
- node = (
528
- <InlineThinkingItem
529
- part={part as ReasoningPart}
530
- defaultOpen={isStreaming}
531
- />
532
- );
533
- } else if (
534
- part.type === "text" &&
535
- !part.synthetic &&
536
- part.text.trim()
537
- ) {
538
- node = (
539
- <div className="px-1 py-1">
540
- <Markdown className="tangle-prose text-[15px] leading-7">{part.text}</Markdown>
541
- </div>
542
- );
543
- }
544
541
 
545
- if (!node) return null;
546
- return (
547
- <div key={key} className={gapClass}>
548
- {node}
549
- </div>
550
- );
551
- })}
552
- </div>
553
- </Collapsible.Content>
542
+ if (!node) return null;
543
+ return (
544
+ <SpineRow key={key} accentClassName={dotAccent(part)} isLast={isLast}>
545
+ {node}
546
+ </SpineRow>
547
+ );
548
+ })}
549
+ </div>
550
+ </Collapsible.Content>
554
551
  </div>
555
552
  </Collapsible.Root>
556
553
  );