@tangle-network/ui 11.0.0 → 11.2.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/{artifact-pane-DvJyPWV4.d.ts → artifact-pane-DpuWcAiF.d.ts} +10 -1
  3. package/dist/chat.d.ts +8 -2
  4. package/dist/chat.js +5 -5
  5. package/dist/{chunk-52Y3FMFI.js → chunk-3M7BPSSW.js} +1 -1
  6. package/dist/chunk-4ORVXSMU.js +507 -0
  7. package/dist/{chunk-LELGOLFV.js → chunk-GGDYWMFF.js} +15 -7
  8. package/dist/{chunk-CCM2KRQH.js → chunk-HW4ROISY.js} +1 -1
  9. package/dist/{chunk-KBOD47AW.js → chunk-J3YZIQNI.js} +2 -2
  10. package/dist/{chunk-CSAIKY36.js → chunk-LTEKI7YJ.js} +12 -7
  11. package/dist/{chunk-KHO7DYXH.js → chunk-OTZWQJ5W.js} +107 -72
  12. package/dist/{chunk-FJBTCTZM.js → chunk-P5F43A65.js} +1 -1
  13. package/dist/{chunk-SZ44QDA6.js → chunk-RADUPP44.js} +12 -5
  14. package/dist/{chunk-HHYUPAOJ.js → chunk-SRAKCVPO.js} +49 -81
  15. package/dist/{document-editor-pane-DyDEX_Zm.d.ts → document-editor-pane-C3cYEDWZ.d.ts} +2 -2
  16. package/dist/{document-editor-pane-WCTA3ZOE.js → document-editor-pane-OQNGM4RJ.js} +3 -3
  17. package/dist/editor.d.ts +3 -3
  18. package/dist/editor.js +3 -3
  19. package/dist/files.d.ts +5 -3
  20. package/dist/files.js +3 -3
  21. package/dist/hooks.js +4 -4
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +9 -9
  24. package/dist/markdown.js +1 -1
  25. package/dist/openui.js +2 -2
  26. package/dist/primitives.d.ts +1 -1
  27. package/dist/primitives.js +1 -1
  28. package/dist/run.d.ts +4 -3
  29. package/dist/run.js +5 -5
  30. package/dist/sdk-hooks.js +4 -4
  31. package/package.json +1 -1
  32. package/src/chat/agent-timeline.stories.tsx +11 -0
  33. package/src/chat/agent-timeline.test.tsx +34 -0
  34. package/src/chat/agent-timeline.tsx +65 -12
  35. package/src/chat/chat-container.tsx +5 -0
  36. package/src/editor/document-editor-pane.stories.tsx +74 -0
  37. package/src/editor/document-editor-pane.tsx +10 -3
  38. package/src/files/file-artifact-pane.tsx +14 -2
  39. package/src/files/file-preview.stories.tsx +22 -1
  40. package/src/files/file-preview.tsx +2 -2
  41. package/src/markdown/markdown.stories.tsx +55 -1
  42. package/src/markdown/markdown.test.tsx +23 -0
  43. package/src/markdown/markdown.tsx +1 -1
  44. package/src/primitives/artifact-pane.tsx +52 -26
  45. package/src/run/expandable-content.test.tsx +52 -0
  46. package/src/run/expandable-content.tsx +73 -0
  47. package/src/run/expanded-tool-detail.tsx +17 -12
  48. package/src/run/inline-thinking-item.stories.tsx +30 -1
  49. package/src/run/inline-thinking-item.tsx +34 -70
  50. package/src/run/inline-tool-item.tsx +30 -116
  51. package/src/run/run-row-shell.test.tsx +38 -0
  52. package/src/run/run-row-shell.tsx +191 -0
  53. package/dist/chunk-INMWNFAS.js +0 -369
@@ -0,0 +1,52 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ExpandableContent } from "./expandable-content";
5
+
6
+ describe("ExpandableContent", () => {
7
+ let scrollHeightSpy: ReturnType<typeof vi.spyOn> | undefined;
8
+
9
+ afterEach(() => {
10
+ scrollHeightSpy?.mockRestore();
11
+ scrollHeightSpy = undefined;
12
+ });
13
+
14
+ it("renders no toggle when content fits within the cap", () => {
15
+ render(
16
+ <ExpandableContent collapsedMaxPx={180}>
17
+ <div>short</div>
18
+ </ExpandableContent>,
19
+ );
20
+ expect(
21
+ screen.queryByRole("button", { name: /show more/i }),
22
+ ).not.toBeInTheDocument();
23
+ });
24
+
25
+ describe("when content overflows", () => {
26
+ beforeEach(() => {
27
+ scrollHeightSpy = vi
28
+ .spyOn(HTMLElement.prototype, "scrollHeight", "get")
29
+ .mockReturnValue(999);
30
+ });
31
+
32
+ it("clamps with a Show more toggle that expands and collapses", async () => {
33
+ const user = userEvent.setup();
34
+ render(
35
+ <ExpandableContent collapsedMaxPx={180}>
36
+ <div>tall content</div>
37
+ </ExpandableContent>,
38
+ );
39
+
40
+ const showMore = screen.getByRole("button", { name: /show more/i });
41
+ await user.click(showMore);
42
+ expect(
43
+ screen.getByRole("button", { name: /show less/i }),
44
+ ).toBeInTheDocument();
45
+
46
+ await user.click(screen.getByRole("button", { name: /show less/i }));
47
+ expect(
48
+ screen.getByRole("button", { name: /show more/i }),
49
+ ).toBeInTheDocument();
50
+ });
51
+ });
52
+ });
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ export interface ExpandableContentProps {
5
+ children: ReactNode;
6
+ /** Height (px) the content is clamped to while collapsed. */
7
+ collapsedMaxPx?: number;
8
+ /**
9
+ * Gradient from-color for the bottom fade, so it blends with the surface
10
+ * behind the content (e.g. `from-muted`, `from-card`, `from-[var(--surface-danger-bg)]`).
11
+ */
12
+ fadeClassName?: string;
13
+ className?: string;
14
+ }
15
+
16
+ /**
17
+ * Clamps tall content to a fixed height with a bottom fade and a
18
+ * "Show more / Show less" toggle. Content shorter than the cap renders
19
+ * as-is with no toggle.
20
+ */
21
+ export function ExpandableContent({
22
+ children,
23
+ collapsedMaxPx = 180,
24
+ fadeClassName = "from-muted",
25
+ className,
26
+ }: ExpandableContentProps) {
27
+ const [expanded, setExpanded] = useState(false);
28
+ const [overflowing, setOverflowing] = useState(false);
29
+ const contentRef = useRef<HTMLDivElement>(null);
30
+
31
+ useEffect(() => {
32
+ const el = contentRef.current;
33
+ if (!el) return;
34
+ const measure = () =>
35
+ setOverflowing(el.scrollHeight > collapsedMaxPx + 4);
36
+ measure();
37
+ if (typeof ResizeObserver === "undefined") return;
38
+ const observer = new ResizeObserver(measure);
39
+ observer.observe(el);
40
+ return () => observer.disconnect();
41
+ }, [collapsedMaxPx]);
42
+
43
+ const clamped = overflowing && !expanded;
44
+
45
+ return (
46
+ <div className={className}>
47
+ <div
48
+ ref={contentRef}
49
+ className="relative overflow-hidden"
50
+ style={clamped ? { maxHeight: collapsedMaxPx } : undefined}
51
+ >
52
+ {children}
53
+ {clamped ? (
54
+ <div
55
+ className={cn(
56
+ "pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t to-transparent",
57
+ fadeClassName,
58
+ )}
59
+ />
60
+ ) : null}
61
+ </div>
62
+ {overflowing ? (
63
+ <button
64
+ type="button"
65
+ onClick={() => setExpanded((value) => !value)}
66
+ className="mt-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
67
+ >
68
+ {expanded ? "Show less" : "Show more"}
69
+ </button>
70
+ ) : null}
71
+ </div>
72
+ );
73
+ }
@@ -17,6 +17,7 @@ import { WebSearchPreview } from "../tool-previews/web-search-preview";
17
17
  import { QuestionPreview } from "../tool-previews/question-preview";
18
18
  import { DiffPreview } from "../tool-previews/diff-preview";
19
19
  import { cn } from "../lib/utils";
20
+ import { ExpandableContent } from "./expandable-content";
20
21
 
21
22
  export interface ExpandedToolDetailProps {
22
23
  part: ToolPart;
@@ -146,15 +147,17 @@ export const ExpandedToolDetail = memo(({ part }: ExpandedToolDetailProps) => {
146
147
  Output
147
148
  </span>
148
149
  </div>
149
- <CodeBlock
150
- code={
151
- outputStr.length > 2000
152
- ? outputStr.slice(0, 2000) + "\n...(truncated)"
153
- : outputStr
154
- }
155
- language="json"
156
- className="rounded-none border-0"
157
- />
150
+ <ExpandableContent fadeClassName="from-card">
151
+ <CodeBlock
152
+ code={
153
+ outputStr.length > 2000
154
+ ? outputStr.slice(0, 2000) + "\n...(truncated)"
155
+ : outputStr
156
+ }
157
+ language="json"
158
+ className="rounded-none border-0"
159
+ />
160
+ </ExpandableContent>
158
161
  </div>
159
162
  )}
160
163
 
@@ -167,9 +170,11 @@ export const ExpandedToolDetail = memo(({ part }: ExpandedToolDetailProps) => {
167
170
  Error
168
171
  </span>
169
172
  </div>
170
- <pre className="bg-[var(--surface-danger-bg)] p-3 text-xs font-mono whitespace-pre-wrap break-all text-[var(--surface-danger-text)]">
171
- {error}
172
- </pre>
173
+ <ExpandableContent fadeClassName="from-[var(--surface-danger-bg)]">
174
+ <pre className="bg-[var(--surface-danger-bg)] p-3 text-xs font-mono whitespace-pre-wrap break-all text-[var(--surface-danger-text)]">
175
+ {error}
176
+ </pre>
177
+ </ExpandableContent>
173
178
  </div>
174
179
  )}
175
180
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react'
2
2
  import { InlineThinkingItem } from './inline-thinking-item'
3
- import type { ReasoningPart } from '../types/parts'
3
+ import { InlineToolItem } from './inline-tool-item'
4
+ import type { ReasoningPart, ToolPart } from '../types/parts'
4
5
 
5
6
  const NOW = Date.now()
6
7
 
@@ -122,6 +123,34 @@ export const EmptyText: Story = {
122
123
  ),
123
124
  }
124
125
 
126
+ const bashTool: ToolPart = {
127
+ type: 'tool',
128
+ id: 'tool-shell-demo',
129
+ tool: 'bash',
130
+ state: {
131
+ status: 'completed',
132
+ input: { command: 'npx tsc --noEmit' },
133
+ output: '✓ No type errors',
134
+ time: { start: NOW - 2040, end: NOW - 200 },
135
+ },
136
+ }
137
+
138
+ // Reasoning and tool rows adjacent — they must read as one family (shared
139
+ // badge geometry, trailing status/duration, chevron), not two designs.
140
+ export const UnifiedWithToolRow: Story = {
141
+ name: 'Unified shell (reasoning + tool)',
142
+ render: () => (
143
+ <div className="p-6 max-w-2xl space-y-2">
144
+ <InlineThinkingItem
145
+ part={thinkingComplete}
146
+ defaultOpen={false}
147
+ autoCollapse={false}
148
+ />
149
+ <InlineToolItem part={bashTool} />
150
+ </div>
151
+ ),
152
+ }
153
+
125
154
  export const AllStates: Story = {
126
155
  render: () => (
127
156
  <div className="p-6 max-w-2xl space-y-3">
@@ -1,11 +1,10 @@
1
1
  import { memo, useEffect, useRef, useState } from "react";
2
- import * as Collapsible from "@radix-ui/react-collapsible";
3
- import { Brain, ChevronRight } from "lucide-react";
4
- import { cn } from "../lib/utils";
5
- import { formatDuration, truncateText } from "../utils/format";
2
+ import { Brain } from "lucide-react";
3
+ import { truncateText } from "../utils/format";
6
4
  import type { ReasoningPart } from "../types/parts";
7
5
  import { Markdown } from "../markdown/markdown";
8
- import { LiveDuration } from "./run-item-primitives";
6
+ import { RunRowShell } from "./run-row-shell";
7
+ import { ExpandableContent } from "./expandable-content";
9
8
 
10
9
  export interface InlineThinkingItemProps {
11
10
  part: ReasoningPart;
@@ -28,7 +27,8 @@ export const InlineThinkingItem = memo(
28
27
 
29
28
  const startTime = part.time?.start;
30
29
  const endTime = part.time?.end;
31
- const durationMs = startTime && endTime ? endTime - startTime : undefined;
30
+ const durationMs =
31
+ startTime != null && endTime != null ? endTime - startTime : undefined;
32
32
  const isActive = startTime != null && endTime == null;
33
33
  const preview = part.text ? truncateText(part.text, 120) : undefined;
34
34
 
@@ -50,70 +50,34 @@ export const InlineThinkingItem = memo(
50
50
  }, [autoCollapse, durationMs, isActive]);
51
51
 
52
52
  return (
53
- <Collapsible.Root open={open} onOpenChange={setOpen}>
54
- <div
55
- className={cn(
56
- "overflow-hidden rounded-[var(--radius-lg)] border bg-card transition-colors",
57
- isActive
58
- ? "border-[var(--border-accent)]"
59
- : "border-border hover:border-[var(--border-accent)]",
60
- className,
61
- )}
62
- >
63
- <Collapsible.Trigger asChild>
64
- <button
65
- className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm cursor-pointer"
66
- >
67
- <div
68
- className={cn(
69
- "flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border",
70
- isActive
71
- ? "border-[var(--border-accent)] bg-[var(--accent-surface-soft)] text-primary"
72
- : "border-border bg-muted text-muted-foreground",
73
- )}
74
- >
75
- <Brain className={cn("h-3 w-3 shrink-0", isActive && "animate-pulse")} />
76
- </div>
77
-
78
- <p className="min-w-0 flex-1 truncate font-[var(--font-sans)] text-muted-foreground">
79
- {preview ?? (isActive ? "Thinking…" : "Reasoning")}
80
- </p>
81
-
82
- <div className="flex shrink-0 items-center gap-2">
83
- {isActive && startTime ? <LiveDuration startTime={startTime} /> : null}
84
- {!isActive && durationMs != null ? (
85
- <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
86
- {formatDuration(durationMs)}
87
- </span>
88
- ) : null}
89
- <ChevronRight
90
- className={cn(
91
- "h-3 w-3 text-muted-foreground transition-transform shrink-0",
92
- open && "rotate-90",
93
- )}
94
- />
95
- </div>
96
- </button>
97
- </Collapsible.Trigger>
98
-
99
- <Collapsible.Content className="overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp">
100
- {part.text ? (
101
- <div
102
- className={cn(
103
- "max-h-60 overflow-y-auto border-t border-border bg-muted px-3 py-3 text-sm leading-relaxed text-muted-foreground",
104
- contentClassName,
105
- )}
106
- >
107
- <Markdown>{part.text}</Markdown>
108
- </div>
109
- ) : (
110
- <div className="border-t border-border bg-muted px-3 py-2.5 text-xs text-muted-foreground">
111
- No reasoning text provided.
112
- </div>
113
- )}
114
- </Collapsible.Content>
115
- </div>
116
- </Collapsible.Root>
53
+ <RunRowShell
54
+ icon={<Brain className="h-3.5 w-3.5" />}
55
+ title={isActive ? "Thinking…" : "Reasoning"}
56
+ description={preview}
57
+ status={isActive ? "running" : "idle"}
58
+ startTime={startTime}
59
+ durationMs={durationMs}
60
+ open={open}
61
+ onOpenChange={setOpen}
62
+ className={className}
63
+ >
64
+ {part.text ? (
65
+ <ExpandableContent
66
+ fadeClassName="from-[var(--md3-surface-container)]"
67
+ className={
68
+ contentClassName ? `px-3 py-3 ${contentClassName}` : "px-3 py-3"
69
+ }
70
+ >
71
+ <div className="text-sm leading-relaxed text-foreground">
72
+ <Markdown>{part.text}</Markdown>
73
+ </div>
74
+ </ExpandableContent>
75
+ ) : (
76
+ <div className="px-3 py-2.5 text-xs text-muted-foreground">
77
+ No reasoning text provided.
78
+ </div>
79
+ )}
80
+ </RunRowShell>
117
81
  );
118
82
  },
119
83
  );
@@ -1,11 +1,5 @@
1
- import { memo, useState, type ComponentType, type ReactNode } from "react";
2
- import * as Collapsible from "@radix-ui/react-collapsible";
1
+ import { memo, type ComponentType, type ReactNode } from "react";
3
2
  import {
4
- Loader2,
5
- CheckCircle2,
6
- AlertCircle,
7
- ChevronDown,
8
- ChevronRight,
9
3
  Terminal,
10
4
  FileEdit,
11
5
  FileSearch,
@@ -23,12 +17,11 @@ import {
23
17
  getToolErrorText,
24
18
  getToolCategory,
25
19
  } from "../utils/tool-display";
26
- import { formatDuration } from "../utils/format";
27
20
  import type { ToolPart } from "../types/parts";
28
21
  import type { ToolCategory } from "../types/run";
29
22
  import type { CustomToolRenderer } from "../types/tool-display";
30
23
  import { ExpandedToolDetail } from "./expanded-tool-detail";
31
- import { LiveDuration } from "./run-item-primitives";
24
+ import { RunRowShell, type RunRowStatus } from "./run-row-shell";
32
25
 
33
26
  /** Map tool category to a lucide-react icon component. */
34
27
  const TOOL_CATEGORY_ICON_MAP: Record<
@@ -59,10 +52,18 @@ export interface InlineToolItemProps {
59
52
  description?: string;
60
53
  }
61
54
 
55
+ /** Map the tool part status onto the shared row status vocabulary. */
56
+ function toRowStatus(status: ToolPart["state"]["status"]): RunRowStatus {
57
+ if (status === "pending" || status === "running") return "running";
58
+ if (status === "error") return "error";
59
+ return "success";
60
+ }
61
+
62
62
  /**
63
- * Compact single-line tool call display (32px height).
64
- * Shows icon, title, description, duration, and status indicator.
65
- * Expands on click to show ExpandedToolDetail.
63
+ * Compact single-line tool call display. Shows a category icon, title,
64
+ * description, duration and status, and expands on click to show
65
+ * ExpandedToolDetail. Rendered through the shared RunRowShell so it stays
66
+ * consistent with the reasoning row.
66
67
  */
67
68
  export const InlineToolItem = memo(
68
69
  ({
@@ -75,123 +76,36 @@ export const InlineToolItem = memo(
75
76
  title: titleOverride,
76
77
  description: descriptionOverride,
77
78
  }: InlineToolItemProps) => {
78
- const [open, setOpen] = useState(false);
79
79
  const meta = getToolDisplayMetadata(part);
80
80
  const title = titleOverride ?? meta.title;
81
81
  const description = descriptionOverride ?? meta.description;
82
- const { status } = part.state;
83
82
  const errorText = getToolErrorText(part);
84
83
 
85
- const isRunning = status === "pending" || status === "running";
86
- const isError = status === "error";
87
- const isComplete = status === "completed";
88
-
89
- // Duration
90
84
  const startTime = part.state.time?.start;
91
85
  const endTime = part.state.time?.end;
92
86
  const durationMs =
93
- startTime && endTime ? endTime - startTime : undefined;
87
+ startTime != null && endTime != null ? endTime - startTime : undefined;
94
88
 
95
- // Determine the default icon based on tool category
96
89
  const category = getToolCategory(part.tool);
97
90
  const DefaultIcon = TOOL_CATEGORY_ICON_MAP[category] ?? Settings;
98
- const shapeClass = {
99
- single: "rounded-[var(--radius-lg)]",
100
- first: "rounded-t-[var(--radius-lg)] rounded-b-[var(--radius-sm)]",
101
- middle: "rounded-[var(--radius-sm)]",
102
- last: "rounded-t-[var(--radius-sm)] rounded-b-[var(--radius-lg)]",
103
- }[groupPosition];
104
91
 
105
92
  return (
106
- <Collapsible.Root open={open} onOpenChange={setOpen}>
107
- <div className="flex items-start gap-2">
108
- <Collapsible.Trigger asChild>
109
- <button
110
- className={cn(
111
- "w-full border text-left transition-colors",
112
- "border-[var(--border-subtle)] bg-[var(--md3-surface-container)] hover:border-border hover:bg-[var(--md3-surface-container-high)]",
113
- open && "border-border bg-[var(--md3-surface-container-high)]",
114
- shapeClass,
115
- className,
116
- )}
117
- >
118
- <div className="flex items-center gap-2.5 px-3 py-2.5">
119
- <div className={cn(
120
- "shrink-0",
121
- isRunning && "text-[var(--accent-text)]",
122
- isComplete && "text-[var(--surface-success-text)]",
123
- isError && "text-[var(--surface-danger-text)]",
124
- !isRunning && !isComplete && !isError && "text-muted-foreground",
125
- )}>
126
- {isRunning ? (
127
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
128
- ) : isComplete ? (
129
- <CheckCircle2 className="h-3.5 w-3.5" />
130
- ) : isError ? (
131
- <AlertCircle className="h-3.5 w-3.5" />
132
- ) : (
133
- <DefaultIcon className="h-3.5 w-3.5" />
134
- )}
135
- </div>
136
-
137
- <span className="truncate text-xs font-medium text-foreground">
138
- {title}
139
- </span>
140
- {description ? (
141
- <span className="hidden truncate text-xs font-mono text-muted-foreground sm:inline">
142
- {description}
143
- </span>
144
- ) : null}
145
-
146
- <div className="ml-auto flex shrink-0 items-center gap-1.5">
147
- {isRunning && startTime ? <LiveDuration startTime={startTime} /> : null}
148
- {!isRunning && durationMs != null ? (
149
- <span className="text-[10px] font-mono tabular-nums text-muted-foreground">
150
- {formatDuration(durationMs)}
151
- </span>
152
- ) : null}
153
- {isError ? (
154
- <span className="text-[10px] font-medium text-[var(--surface-danger-text)]">
155
- failed
156
- </span>
157
- ) : null}
158
- {isRunning ? (
159
- <span className="text-[10px] font-medium text-[var(--accent-text)]">
160
- running
161
- </span>
162
- ) : null}
163
- {open ? (
164
- <ChevronDown className="h-3 w-3 text-muted-foreground" />
165
- ) : (
166
- <ChevronRight className="h-3 w-3 text-muted-foreground" />
167
- )}
168
- </div>
169
- </div>
170
-
171
- {errorText && !open ? (
172
- <div className="border-t border-border px-3 py-2 text-xs text-red-200">
173
- {errorText}
174
- </div>
175
- ) : null}
176
- </button>
177
- </Collapsible.Trigger>
178
-
179
- {actions ? (
180
- <div
181
- className="flex shrink-0 flex-wrap items-center justify-end gap-1.5 pt-1"
182
- onClick={(event) => event.stopPropagation()}
183
- >
184
- {actions}
185
- </div>
186
- ) : null}
187
- </div>
188
-
189
- <Collapsible.Content className="overflow-hidden data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp">
190
- <div className={cn("mt-1.5 ml-2.5 border-l-2 border-primary/15 pl-3.5", contentClassName)}>
191
- {renderToolDetail?.(part) ?? <ExpandedToolDetail part={part} />}
192
- </div>
193
- </Collapsible.Content>
194
- </Collapsible.Root>
93
+ <RunRowShell
94
+ icon={<DefaultIcon className="h-3.5 w-3.5" />}
95
+ title={title}
96
+ description={description}
97
+ descriptionMono
98
+ status={toRowStatus(part.state.status)}
99
+ startTime={startTime}
100
+ durationMs={durationMs}
101
+ groupPosition={groupPosition}
102
+ collapsedError={errorText ?? undefined}
103
+ actions={actions}
104
+ className={className}
105
+ contentClassName={cn("p-2", contentClassName)}
106
+ >
107
+ {renderToolDetail?.(part) ?? <ExpandedToolDetail part={part} />}
108
+ </RunRowShell>
195
109
  );
196
110
  },
197
111
  );
@@ -0,0 +1,38 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { RunRowShell } from "./run-row-shell";
4
+
5
+ function renderShell(status: "running" | "success" | "error" | "idle") {
6
+ return render(
7
+ <RunRowShell
8
+ icon={<span data-testid="lead-icon">icon</span>}
9
+ title="Ran command"
10
+ status={status}
11
+ >
12
+ <div>body</div>
13
+ </RunRowShell>,
14
+ );
15
+ }
16
+
17
+ describe("RunRowShell", () => {
18
+ it("keeps the semantic lead icon visible in every status (no hijack)", () => {
19
+ for (const status of ["running", "success", "error", "idle"] as const) {
20
+ const { unmount } = renderShell(status);
21
+ expect(screen.getByTestId("lead-icon")).toBeInTheDocument();
22
+ expect(screen.getByText("Ran command")).toBeInTheDocument();
23
+ unmount();
24
+ }
25
+ });
26
+
27
+ it("shows a spinner while running", () => {
28
+ const { container } = renderShell("running");
29
+ expect(container.querySelector(".animate-spin")).not.toBeNull();
30
+ });
31
+
32
+ it("is not expandable and has no chevron when no children are provided", () => {
33
+ render(<RunRowShell icon={<span>i</span>} title="Reasoning" />);
34
+ // The trigger button is disabled when there is no expandable body.
35
+ const button = screen.getByRole("button", { name: /reasoning/i });
36
+ expect(button).toBeDisabled();
37
+ });
38
+ });