@tangle-network/ui 8.1.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.
@@ -1,7 +1,10 @@
1
1
  import { memo, useMemo, type ComponentType, type ReactNode } from "react";
2
+ import * as Collapsible from "@radix-ui/react-collapsible";
2
3
  import {
3
4
  Bot,
4
5
  Loader2,
6
+ ChevronDown,
7
+ ChevronRight,
5
8
  Terminal,
6
9
  FileEdit,
7
10
  FileSearch,
@@ -10,8 +13,10 @@ import {
10
13
  Globe,
11
14
  ClipboardList,
12
15
  Settings,
16
+ Sparkles,
13
17
  type LucideProps,
14
18
  } from "lucide-react";
19
+ import { cn } from "../lib/utils";
15
20
  import { formatDuration } from "../utils/format";
16
21
  import type { Run, ToolCategory } from "../types/run";
17
22
  import type { SessionPart, ToolPart, ReasoningPart } from "../types/parts";
@@ -19,8 +24,39 @@ import type { AgentBranding } from "../types/branding";
19
24
  import type { CustomToolRenderer } from "../types/tool-display";
20
25
  import { InlineToolItem } from "./inline-tool-item";
21
26
  import { InlineThinkingItem } from "./inline-thinking-item";
22
- import { AssistantRunShell } from "./assistant-run-shell";
23
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
+ }
24
60
  import {
25
61
  OpenUIArtifactRenderer,
26
62
  type OpenUIAction,
@@ -241,21 +277,6 @@ function renderSummary(run: Run) {
241
277
  return parts.join(", ");
242
278
  }
243
279
 
244
- function getToolGroupPosition(
245
- currentIndex: number,
246
- parts: Array<{ part: SessionPart; msgId: string; index: number }>,
247
- ) {
248
- const previous = parts[currentIndex - 1]?.part;
249
- const next = parts[currentIndex + 1]?.part;
250
- const previousIsTool = previous?.type === "tool";
251
- const nextIsTool = next?.type === "tool";
252
-
253
- if (previousIsTool && nextIsTool) return "middle" as const;
254
- if (previousIsTool) return "last" as const;
255
- if (nextIsTool) return "first" as const;
256
- return "single" as const;
257
- }
258
-
259
280
  // ---------------------------------------------------------------------------
260
281
  // Component
261
282
  // ---------------------------------------------------------------------------
@@ -388,107 +409,147 @@ export const RunGroup = memo(
388
409
  );
389
410
  }
390
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
+
391
424
  return (
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
- >
402
- {allParts.map(({ part, msgId, index }, partIndex) => {
403
- const key = `${msgId}-${index}`;
425
+ <Collapsible.Root open={!collapsed} onOpenChange={() => onToggle()}>
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
+ )}
439
+ <span className={cn("font-semibold text-sm", branding.textClass)}>
440
+ {branding.label}
441
+ </span>
442
+ {renderSummary(run) ? (
443
+ <span className="text-[11px] text-muted-foreground">{renderSummary(run)}</span>
444
+ ) : null}
445
+ {collapsed && run.summaryText ? (
446
+ <span className="min-w-0 truncate text-[11px] text-foreground/70">
447
+ {run.summaryText}
448
+ </span>
449
+ ) : null}
450
+ <span className="ml-auto flex shrink-0 items-center gap-1.5">
451
+ <CategoryBadges categories={stats.toolCategories} />
452
+ {isStreaming ? (
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)]">
454
+ <Loader2 className="h-2.5 w-2.5 animate-spin" />
455
+ Running
456
+ </span>
457
+ ) : (
458
+ <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">
459
+ <Sparkles className="h-2.5 w-2.5" />
460
+ Done
461
+ </span>
462
+ )}
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}
469
+ </div>
470
+ ) : null}
471
+ </div>
404
472
 
405
- // Consecutive (non-OpenUI) tool calls connect into one block —
406
- // `getToolGroupPosition` already gives them joined radii, so they
407
- // get no vertical gap; every other transition keeps a normal gap.
408
- const prev = allParts[partIndex - 1]?.part;
409
- const connectedTool =
410
- part.type === "tool" &&
411
- prev?.type === "tool" &&
412
- !isOpenUITool(part as ToolPart) &&
413
- !isOpenUITool(prev as ToolPart);
414
- const gapClass =
415
- partIndex === 0 ? "" : connectedTool ? "mt-px" : "mt-3";
416
-
417
- let node: ReactNode = null;
418
-
419
- if (part.type === "tool") {
420
- if (isOpenUITool(part as ToolPart)) {
421
- const toolPart = part as ToolPart;
422
- const schema = extractOpenUISchema(toolPart.state.output);
423
- const summary = getOpenUISummary(toolPart.state.output);
424
-
425
- if (toolPart.state.status === "completed" && schema) {
426
- node = (
427
- <div className="overflow-hidden rounded-[24px] border border-[var(--border-subtle)] bg-[var(--bg-card)]">
428
- {summary ? (
429
- <div className="border-b border-[var(--border-subtle)] px-4 py-3">
430
- <div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
431
- View
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}
477
+ </div>
478
+ ) : null}
479
+
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}
432
500
  </div>
433
- <div className="mt-1 text-sm leading-6 text-foreground">{summary}</div>
501
+ ) : null}
502
+ <div className="p-4">
503
+ <OpenUIArtifactRenderer schema={schema} />
434
504
  </div>
435
- ) : null}
436
- <div className="p-4">
437
- <OpenUIArtifactRenderer schema={schema} />
438
505
  </div>
439
- </div>
440
- );
441
- } 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) {
442
518
  node = (
443
- <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">
444
- <Loader2 className="h-4 w-4 animate-spin text-primary" />
445
- Building view…
446
- </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
+ />
447
528
  );
448
529
  }
449
- }
450
-
451
- if (node === null) {
530
+ } else if (part.type === "reasoning") {
452
531
  node = (
453
- <InlineToolItem
454
- part={part as ToolPart}
455
- renderToolDetail={renderToolDetail}
456
- groupPosition={getToolGroupPosition(partIndex, allParts)}
457
- actions={renderToolActions?.(part as ToolPart, {
458
- run,
459
- messageId: msgId,
460
- partIndex: index,
461
- })}
462
- />
532
+ <InlineThinkingItem part={part as ReasoningPart} defaultOpen={isStreaming} />
533
+ );
534
+ } else if (part.type === "text" && !part.synthetic && part.text.trim()) {
535
+ node = (
536
+ <div className="px-1 py-0.5">
537
+ <Markdown className="tangle-prose text-[15px] leading-7">{part.text}</Markdown>
538
+ </div>
463
539
  );
464
540
  }
465
- } else if (part.type === "reasoning") {
466
- node = (
467
- <InlineThinkingItem
468
- part={part as ReasoningPart}
469
- defaultOpen={isStreaming}
470
- />
471
- );
472
- } else if (
473
- part.type === "text" &&
474
- !part.synthetic &&
475
- part.text.trim()
476
- ) {
477
- node = (
478
- <div className="px-1 py-1">
479
- <Markdown className="tangle-prose text-[15px] leading-7">{part.text}</Markdown>
480
- </div>
481
- );
482
- }
483
541
 
484
- if (!node) return null;
485
- return (
486
- <div key={key} className={gapClass}>
487
- {node}
488
- </div>
489
- );
490
- })}
491
- </AssistantRunShell>
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>
551
+ </div>
552
+ </Collapsible.Root>
492
553
  );
493
554
  },
494
555
  );
@@ -1,115 +0,0 @@
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
- }
File without changes