@tangle-network/ui 11.5.0 → 11.7.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 (66) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +70 -2
  3. package/dist/chat.d.ts +18 -8
  4. package/dist/chat.js +6 -5
  5. package/dist/{chunk-XROQW5J7.js → chunk-4ENFLPWQ.js} +1 -1
  6. package/dist/{chunk-XPYPZMYT.js → chunk-6RIX7M27.js} +1 -1
  7. package/dist/{chunk-R5GVQDL3.js → chunk-CVX53NCC.js} +7 -5
  8. package/dist/{chunk-ULDNFLIM.js → chunk-EIUE5GV2.js} +10 -14
  9. package/dist/chunk-L57WRIND.js +59 -0
  10. package/dist/chunk-LFVF2HFB.js +19 -0
  11. package/dist/{chunk-7O22XQ4D.js → chunk-PLWRAS5Z.js} +1 -1
  12. package/dist/{chunk-NZLLDHEG.js → chunk-QGTIBEPX.js} +608 -420
  13. package/dist/{chunk-AZWDI2JG.js → chunk-UYTRNR7Z.js} +1 -1
  14. package/dist/{chunk-RKC3GXHE.js → chunk-VDCZHOOO.js} +99 -104
  15. package/dist/{chunk-J2G7KCYH.js → chunk-XT4JMYVB.js} +109 -67
  16. package/dist/{document-editor-pane-MX3FIZSE.js → document-editor-pane-A3JURPW5.js} +1 -1
  17. package/dist/{document-editor-pane-C3cYEDWZ.d.ts → document-editor-pane-IAEQn_Dl.d.ts} +8 -4
  18. package/dist/editor.d.ts +5 -4
  19. package/dist/editor.js +1 -1
  20. package/dist/files.d.ts +1 -1
  21. package/dist/files.js +1 -1
  22. package/dist/hooks.js +6 -5
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.js +16 -11
  25. package/dist/primitives.d.ts +15 -1
  26. package/dist/primitives.js +5 -1
  27. package/dist/run.d.ts +22 -17
  28. package/dist/run.js +8 -6
  29. package/dist/sdk-hooks.js +6 -5
  30. package/dist/utils.d.ts +6 -0
  31. package/dist/utils.js +1 -1
  32. package/package.json +1 -1
  33. package/src/chat/agent-timeline.stories.tsx +163 -10
  34. package/src/chat/agent-timeline.test.tsx +114 -7
  35. package/src/chat/agent-timeline.tsx +120 -158
  36. package/src/chat/chat-container.test.tsx +59 -0
  37. package/src/chat/user-message.test.tsx +33 -0
  38. package/src/chat/user-message.tsx +13 -8
  39. package/src/editor/document-editor-pane.test.tsx +58 -0
  40. package/src/editor/editor-lazy.test.tsx +102 -0
  41. package/src/editor/editor-lazy.ts +40 -0
  42. package/src/editor/editor-loading.tsx +25 -0
  43. package/src/editor/editor-peers.test.ts +250 -0
  44. package/src/editor/editor-peers.ts +272 -0
  45. package/src/editor/editor-provider.test.tsx +226 -0
  46. package/src/editor/editor-provider.tsx +301 -265
  47. package/src/editor/editor-toolbar.tsx +1 -1
  48. package/src/editor/markdown-document-editor.test.tsx +121 -0
  49. package/src/editor/markdown-document-editor.tsx +114 -82
  50. package/src/editor/tiptap-editor.test.tsx +228 -0
  51. package/src/editor/tiptap-editor.tsx +162 -130
  52. package/src/primitives/index.ts +3 -0
  53. package/src/primitives/text-shimmer.test.tsx +34 -0
  54. package/src/primitives/text-shimmer.tsx +82 -0
  55. package/src/run/inline-thinking-item.stories.tsx +2 -2
  56. package/src/run/inline-thinking-item.tsx +2 -2
  57. package/src/run/inline-tool-item.test.tsx +76 -0
  58. package/src/run/inline-tool-item.tsx +9 -7
  59. package/src/run/run-group.tsx +4 -3
  60. package/src/run/run-row-shell.test.tsx +74 -4
  61. package/src/run/run-row-shell.tsx +81 -53
  62. package/src/run/tool-call-step.tsx +9 -7
  63. package/src/utils/tool-display.test.ts +53 -0
  64. package/src/utils/tool-display.ts +16 -15
  65. package/dist/chunk-LQS34IGP.js +0 -0
  66. /package/dist/{chunk-EJ5NBW7V.js → chunk-LDLOJKPC.js} +0 -0
@@ -1,13 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import type { AnyExtension } from "@tiptap/core";
4
- import Collaboration from "@tiptap/extension-collaboration";
5
- import CollaborationCaret from "@tiptap/extension-collaboration-caret";
6
- import { type Editor, EditorContent, useEditor } from "@tiptap/react";
7
- import StarterKit from "@tiptap/starter-kit";
8
- import { useEffect, useMemo } from "react";
4
+ import type { Editor } from "@tiptap/react";
5
+ import { type ComponentType, Suspense, useEffect, useMemo } from "react";
9
6
  import { cn } from "../lib/utils";
10
7
  import { type Collaborator, useEditorContext } from "./editor-provider";
8
+ import { retryableLazyEditor } from "./editor-lazy";
9
+ import { EditorLoadingPlaceholder } from "./editor-loading";
10
+ import { type CollaborationPeers, loadCollaborationPeers } from "./editor-peers";
11
11
 
12
12
  /**
13
13
  * Props for TiptapEditor component.
@@ -57,140 +57,149 @@ function getCursorColors(color: string) {
57
57
  }
58
58
 
59
59
  /**
60
- * TiptapEditor - Collaborative markdown editor with Y.js sync.
61
- * Must be used within an EditorProvider.
60
+ * Builds the collaborative editor against loaded tiptap namespaces. The peers
61
+ * arrive as an argument so that this module reaches them through type-only
62
+ * imports, which a bundler erases.
62
63
  */
63
- export function TiptapEditor({
64
- initialContent,
65
- placeholder = "Start writing...",
66
- readOnly = false,
67
- autoFocus = false,
68
- className,
69
- contentClassName,
70
- onUpdate,
71
- onSelectionUpdate,
72
- onReady,
73
- }: TiptapEditorProps) {
74
- const { doc, provider, connectionState } = useEditorContext();
75
-
76
- // Y.js fragment for the editor content
77
- const fragment = useMemo(() => doc.getXmlFragment("prosemirror"), [doc]);
78
-
79
- // Configure Tiptap extensions
80
- const extensions = useMemo(() => {
81
- const baseExtensions: AnyExtension[] = [
82
- StarterKit.configure({
83
- // Disable history - Y.js handles undo/redo
84
- ...({ history: false } as any),
85
- // Configure code block for syntax highlighting placeholder
86
- codeBlock: {
87
- HTMLAttributes: {
88
- class: "hljs",
89
- },
90
- },
91
- }),
92
- Collaboration.configure({
93
- fragment,
94
- }),
95
- ];
96
-
97
- // Add collaboration cursor if provider is available
98
- if (provider?.awareness) {
99
- baseExtensions.push(
100
- CollaborationCaret.configure({
101
- provider,
102
- user: provider.awareness.getLocalState()?.user ?? {
103
- name: "Anonymous",
104
- color: "#808080",
105
- },
106
- render: (user: { name: string; color: string }) => {
107
- const { background, text } = getCursorColors(user.color);
64
+ export function createTiptapEditor(
65
+ peers: CollaborationPeers,
66
+ ): ComponentType<TiptapEditorProps> {
67
+ const { EditorContent, useEditor } = peers.react;
68
+ const StarterKit = peers.starterKit.default;
69
+ const Collaboration = peers.collaboration.default;
70
+ const CollaborationCaret = peers.collaborationCaret.default;
108
71
 
109
- const cursor = document.createElement("span");
110
- cursor.className = "collaboration-cursor";
111
- cursor.style.borderColor = background;
72
+ return function TiptapEditor({
73
+ initialContent,
74
+ placeholder = "Start writing...",
75
+ readOnly = false,
76
+ autoFocus = false,
77
+ className,
78
+ contentClassName,
79
+ onUpdate,
80
+ onSelectionUpdate,
81
+ onReady,
82
+ }: TiptapEditorProps) {
83
+ const { doc, provider, connectionState } = useEditorContext();
112
84
 
113
- const label = document.createElement("span");
114
- label.className = "collaboration-cursor-label";
115
- label.style.backgroundColor = background;
116
- label.style.color = text;
117
- label.textContent = user.name;
85
+ // Y.js fragment for the editor content
86
+ const fragment = useMemo(() => doc.getXmlFragment("prosemirror"), [doc]);
118
87
 
119
- cursor.appendChild(label);
120
- return cursor;
88
+ // Configure Tiptap extensions
89
+ const extensions = useMemo(() => {
90
+ const baseExtensions: AnyExtension[] = [
91
+ StarterKit.configure({
92
+ // Disable history - Y.js handles undo/redo
93
+ ...({ history: false } as any),
94
+ // Configure code block for syntax highlighting placeholder
95
+ codeBlock: {
96
+ HTMLAttributes: {
97
+ class: "hljs",
98
+ },
121
99
  },
122
100
  }),
123
- );
124
- }
125
-
126
- return baseExtensions;
127
- }, [fragment, provider]);
128
-
129
- // Initialize Tiptap editor
130
- const editor = useEditor({
131
- extensions,
132
- editable: !readOnly,
133
- autofocus: autoFocus,
134
- editorProps: {
135
- attributes: {
136
- class: cn(
137
- "prose prose-sm sm:prose-base dark:prose-invert max-w-none",
138
- "focus:outline-none",
139
- contentClassName,
140
- ),
141
- "data-placeholder": placeholder,
101
+ Collaboration.configure({
102
+ fragment,
103
+ }),
104
+ ];
105
+
106
+ // Add collaboration cursor if provider is available
107
+ if (provider?.awareness) {
108
+ baseExtensions.push(
109
+ CollaborationCaret.configure({
110
+ provider,
111
+ user: provider.awareness.getLocalState()?.user ?? {
112
+ name: "Anonymous",
113
+ color: "#808080",
114
+ },
115
+ render: (user: { name: string; color: string }) => {
116
+ const { background, text } = getCursorColors(user.color);
117
+
118
+ const cursor = document.createElement("span");
119
+ cursor.className = "collaboration-cursor";
120
+ cursor.style.borderColor = background;
121
+
122
+ const label = document.createElement("span");
123
+ label.className = "collaboration-cursor-label";
124
+ label.style.backgroundColor = background;
125
+ label.style.color = text;
126
+ label.textContent = user.name;
127
+
128
+ cursor.appendChild(label);
129
+ return cursor;
130
+ },
131
+ }),
132
+ );
133
+ }
134
+
135
+ return baseExtensions;
136
+ }, [fragment, provider]);
137
+
138
+ // Initialize Tiptap editor
139
+ const editor = useEditor({
140
+ extensions,
141
+ editable: !readOnly,
142
+ autofocus: autoFocus,
143
+ editorProps: {
144
+ attributes: {
145
+ class: cn(
146
+ "prose prose-sm sm:prose-base dark:prose-invert max-w-none",
147
+ "focus:outline-none",
148
+ contentClassName,
149
+ ),
150
+ "data-placeholder": placeholder,
151
+ },
142
152
  },
143
- },
144
- onUpdate: ({ editor: ed }) => {
145
- onUpdate?.(ed);
146
- },
147
- onSelectionUpdate: ({ editor: ed }) => {
148
- onSelectionUpdate?.(ed);
149
- },
150
- onCreate: ({ editor: ed }) => {
151
- onReady?.(ed);
152
- },
153
- });
154
-
155
- // Update editable state when readOnly changes
156
- useEffect(() => {
157
- if (editor) {
158
- editor.setEditable(!readOnly);
159
- }
160
- }, [editor, readOnly]);
161
-
162
- // Handle initial content (only for new documents)
163
- useEffect(() => {
164
- if (
165
- editor &&
166
- initialContent &&
167
- connectionState === "synced" &&
168
- editor.isEmpty
169
- ) {
170
- editor.commands.setContent(initialContent);
171
- }
172
- }, [editor, initialContent, connectionState]);
153
+ onUpdate: ({ editor: ed }) => {
154
+ onUpdate?.(ed);
155
+ },
156
+ onSelectionUpdate: ({ editor: ed }) => {
157
+ onSelectionUpdate?.(ed);
158
+ },
159
+ onCreate: ({ editor: ed }) => {
160
+ onReady?.(ed);
161
+ },
162
+ });
173
163
 
174
- return (
175
- <div
176
- className={cn(
177
- "relative min-h-[200px] w-full rounded-lg border border-border",
178
- "bg-background",
179
- className,
180
- )}
181
- >
182
- {/* Connection status indicator */}
183
- <div className="absolute top-2 right-2 z-10">
184
- <ConnectionIndicator state={connectionState} />
185
- </div>
164
+ // Update editable state when readOnly changes
165
+ useEffect(() => {
166
+ if (editor) {
167
+ editor.setEditable(!readOnly);
168
+ }
169
+ }, [editor, readOnly]);
186
170
 
187
- {/* Editor content */}
188
- <div className="p-4 pt-10">
189
- <EditorContent editor={editor} />
190
- </div>
171
+ // Handle initial content (only for new documents)
172
+ useEffect(() => {
173
+ if (
174
+ editor &&
175
+ initialContent &&
176
+ connectionState === "synced" &&
177
+ editor.isEmpty
178
+ ) {
179
+ editor.commands.setContent(initialContent);
180
+ }
181
+ }, [editor, initialContent, connectionState]);
191
182
 
192
- {/* Placeholder when empty */}
193
- <style>{`
183
+ return (
184
+ <div
185
+ className={cn(
186
+ "relative min-h-[200px] w-full rounded-lg border border-border",
187
+ "bg-background",
188
+ className,
189
+ )}
190
+ >
191
+ {/* Connection status indicator */}
192
+ <div className="absolute top-2 right-2 z-10">
193
+ <ConnectionIndicator state={connectionState} />
194
+ </div>
195
+
196
+ {/* Editor content */}
197
+ <div className="p-4 pt-10">
198
+ <EditorContent editor={editor} />
199
+ </div>
200
+
201
+ {/* Placeholder when empty */}
202
+ <style>{`
194
203
  .ProseMirror p.is-editor-empty:first-child::before {
195
204
  content: attr(data-placeholder);
196
205
  float: left;
@@ -253,7 +262,30 @@ export function TiptapEditor({
253
262
  font-size: inherit;
254
263
  }
255
264
  `}</style>
256
- </div>
265
+ </div>
266
+ );
267
+ };
268
+ }
269
+
270
+ const lazyTiptapEditor = retryableLazyEditor(async () =>
271
+ createTiptapEditor(await loadCollaborationPeers()),
272
+ );
273
+
274
+ /**
275
+ * TiptapEditor - Collaborative markdown editor with Y.js sync.
276
+ * Must be used within an EditorProvider. Loads its tiptap, Hocuspocus and
277
+ * yjs peers on first render.
278
+ */
279
+ export function TiptapEditor(props: TiptapEditorProps) {
280
+ const LazyTiptapEditor = lazyTiptapEditor();
281
+ return (
282
+ <Suspense
283
+ fallback={
284
+ <EditorLoadingPlaceholder className={cn("min-h-[200px]", props.className)} />
285
+ }
286
+ >
287
+ <LazyTiptapEditor {...props} />
288
+ </Suspense>
257
289
  );
258
290
  }
259
291
 
@@ -87,6 +87,9 @@ export { Switch } from "./switch";
87
87
 
88
88
  export { Skeleton, SkeletonCard, SkeletonTable } from "./skeleton";
89
89
 
90
+ export { TextShimmer } from "./text-shimmer";
91
+ export type { TextShimmerProps } from "./text-shimmer";
92
+
90
93
  export { ToastContainer, ToastProvider, useToast } from "./toast";
91
94
  export type { Toast } from "./toast";
92
95
 
@@ -0,0 +1,34 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { TextShimmer } from "./text-shimmer";
4
+
5
+ describe("TextShimmer", () => {
6
+ it("wraps the label in the shimmer class and keeps the text readable", () => {
7
+ const { container } = render(<TextShimmer>Thinking…</TextShimmer>);
8
+ const span = container.querySelector(".tangle-text-shimmer");
9
+ expect(span).not.toBeNull();
10
+ expect(span!.textContent).toBe("Thinking…");
11
+ });
12
+
13
+ it("injects its stylesheet once for any number of instances", () => {
14
+ render(
15
+ <>
16
+ <TextShimmer>one</TextShimmer>
17
+ <TextShimmer>two</TextShimmer>
18
+ </>,
19
+ );
20
+ const sheets = document.querySelectorAll("#tangle-text-shimmer-styles");
21
+ expect(sheets).toHaveLength(1);
22
+ expect(sheets[0].textContent).toContain("@keyframes tangle-text-shimmer");
23
+ expect(sheets[0].textContent).toContain("mask-image");
24
+ expect(sheets[0].textContent).toContain("prefers-reduced-motion");
25
+ });
26
+
27
+ it("exposes the sweep period as the animation duration", () => {
28
+ const { container } = render(
29
+ <TextShimmer durationMs={900}>slow</TextShimmer>,
30
+ );
31
+ const span = container.querySelector<HTMLElement>(".tangle-text-shimmer");
32
+ expect(span!.style.animationDuration).toBe("900ms");
33
+ });
34
+ });
@@ -0,0 +1,82 @@
1
+ import { useEffect, useLayoutEffect, type ReactNode } from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ const CLASS_NAME = "tangle-text-shimmer";
5
+ const STYLE_ID = "tangle-text-shimmer-styles";
6
+
7
+ /**
8
+ * The sweep is a MASK over the text's own colour, not a gradient background
9
+ * clipped to the glyphs: if this stylesheet is missing the text still renders
10
+ * in its normal colour instead of disappearing (`color: transparent`).
11
+ *
12
+ * Mask geometry mirrors the background variant: a 250%-wide tile whose bright
13
+ * band sits at 45–60%, slid from 150% to -50% so one band crosses the text per
14
+ * cycle. The tile repeats, so the .72 floor covers every glyph at every frame —
15
+ * the label reads at ~72% foreground opacity with a brighter sweep through it.
16
+ */
17
+ const SHIMMER_CSS = `
18
+ @keyframes ${CLASS_NAME} {
19
+ from { -webkit-mask-position: 150% center; mask-position: 150% center; }
20
+ to { -webkit-mask-position: -50% center; mask-position: -50% center; }
21
+ }
22
+ .${CLASS_NAME} {
23
+ -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0.72) 0%, #000 45%, rgba(0,0,0,0.72) 60%);
24
+ mask-image: linear-gradient(90deg, rgba(0,0,0,0.72) 0%, #000 45%, rgba(0,0,0,0.72) 60%);
25
+ -webkit-mask-size: 250% 100%;
26
+ mask-size: 250% 100%;
27
+ animation: ${CLASS_NAME} 1.4s linear infinite;
28
+ }
29
+ @media (prefers-reduced-motion: reduce) {
30
+ .${CLASS_NAME} {
31
+ animation: none;
32
+ -webkit-mask-image: none;
33
+ mask-image: none;
34
+ text-decoration: underline dotted currentColor;
35
+ text-underline-offset: 3px;
36
+ }
37
+ }
38
+ `;
39
+
40
+ /** Inject the shimmer stylesheet once per document. A no-op on the server. */
41
+ function ensureShimmerStyles() {
42
+ if (typeof document === "undefined") return;
43
+ if (document.getElementById(STYLE_ID)) return;
44
+ const style = document.createElement("style");
45
+ style.id = STYLE_ID;
46
+ style.textContent = SHIMMER_CSS;
47
+ document.head.appendChild(style);
48
+ }
49
+
50
+ // Layout effect so the mask is in place before the first paint; effect on the
51
+ // server, where there is no layout phase.
52
+ const useIsomorphicLayoutEffect =
53
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
54
+
55
+ export interface TextShimmerProps {
56
+ children: ReactNode;
57
+ className?: string;
58
+ /** One sweep across the text, in milliseconds. */
59
+ durationMs?: number;
60
+ }
61
+
62
+ /**
63
+ * Inline text with a gradient-mask sweep — the in-flight signal for a run row
64
+ * label ("Thinking…", a tool verb while its call is running). Carries the
65
+ * styles it needs, so a consumer that only scans this package for Tailwind
66
+ * classes still gets the animation.
67
+ */
68
+ export function TextShimmer({
69
+ children,
70
+ className,
71
+ durationMs = 1400,
72
+ }: TextShimmerProps) {
73
+ useIsomorphicLayoutEffect(ensureShimmerStyles, []);
74
+ return (
75
+ <span
76
+ className={cn(CLASS_NAME, "text-foreground", className)}
77
+ style={{ animationDuration: `${durationMs}ms` }}
78
+ >
79
+ {children}
80
+ </span>
81
+ );
82
+ }
@@ -135,8 +135,8 @@ const bashTool: ToolPart = {
135
135
  },
136
136
  }
137
137
 
138
- // Reasoning and tool rows adjacent — they must read as one family (shared
139
- // badge geometry, trailing status/duration, chevron), not two designs.
138
+ // Reasoning and tool rows adjacent — they must read as one family (same glyph
139
+ // slot, same type scale, same hover reveal), not two designs.
140
140
  export const UnifiedWithToolRow: Story = {
141
141
  name: 'Unified shell (reasoning + tool)',
142
142
  render: () => (
@@ -3,6 +3,7 @@ import { Brain } from "lucide-react";
3
3
  import { truncateText } from "../utils/format";
4
4
  import type { ReasoningPart } from "../types/parts";
5
5
  import { Markdown } from "../markdown/markdown";
6
+ import { TextShimmer } from "../primitives/text-shimmer";
6
7
  import { RunRowShell } from "./run-row-shell";
7
8
  import { ExpandableContent } from "./expandable-content";
8
9
 
@@ -52,10 +53,9 @@ export const InlineThinkingItem = memo(
52
53
  return (
53
54
  <RunRowShell
54
55
  icon={<Brain className="h-3.5 w-3.5" />}
55
- title={isActive ? "Thinking…" : "Reasoning"}
56
+ title={isActive ? <TextShimmer>Thinking…</TextShimmer> : "Reasoning"}
56
57
  description={preview}
57
58
  status={isActive ? "running" : "idle"}
58
- startTime={startTime}
59
59
  durationMs={durationMs}
60
60
  open={open}
61
61
  onOpenChange={setOpen}
@@ -0,0 +1,76 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { ToolPart } from "../types/parts";
4
+ import { formatDuration } from "../utils/format";
5
+ import { InlineToolItem } from "./inline-tool-item";
6
+
7
+ const NOW = 1_700_000_000_000;
8
+
9
+ function part(overrides: Partial<ToolPart["state"]>, tool = "read"): ToolPart {
10
+ return {
11
+ type: "tool",
12
+ id: `tool-${tool}`,
13
+ tool,
14
+ state: {
15
+ status: "completed",
16
+ input: { file_path: "src/lib/jitter.ts" },
17
+ output: "export function jitter() {}",
18
+ time: { start: NOW - 1200, end: NOW - 1140 },
19
+ ...overrides,
20
+ },
21
+ };
22
+ }
23
+
24
+ describe("InlineToolItem", () => {
25
+ it("titles the row with the bare verb and prints the path once", () => {
26
+ const { container } = render(<InlineToolItem part={part({})} />);
27
+ expect(screen.getByText("Read")).toBeInTheDocument();
28
+ expect(screen.getAllByText("src/lib/jitter.ts")).toHaveLength(1);
29
+ // The path never repeats inside the title.
30
+ expect(container.textContent).not.toContain("Read src/lib/jitter.ts");
31
+ });
32
+
33
+ it("renders no status dot and no spinner once the call completes", () => {
34
+ const { container } = render(<InlineToolItem part={part({})} />);
35
+ expect(container.querySelector("[data-run-row-status]")).toBeNull();
36
+ expect(container.querySelector(".animate-spin")).toBeNull();
37
+ expect(container.querySelector(".tangle-text-shimmer")).toBeNull();
38
+ });
39
+
40
+ it("shimmers the verb while running instead of spinning", () => {
41
+ const { container } = render(
42
+ <InlineToolItem
43
+ part={part({ status: "running", output: undefined, time: { start: NOW - 400 } }, "bash")}
44
+ />,
45
+ );
46
+ const shimmer = container.querySelector(".tangle-text-shimmer");
47
+ expect(shimmer).not.toBeNull();
48
+ expect(shimmer!.textContent).toBe("Shell");
49
+ expect(container.querySelector(".animate-spin")).toBeNull();
50
+ expect(container.querySelector("[data-run-row-duration]")).toBeNull();
51
+ });
52
+
53
+ it("treats a pending call as running", () => {
54
+ const { container } = render(
55
+ <InlineToolItem part={part({ status: "pending", input: undefined, output: undefined })} />,
56
+ );
57
+ expect(container.querySelector(".tangle-text-shimmer")).not.toBeNull();
58
+ });
59
+
60
+ it("keeps the red dot and the collapsed error on failure", () => {
61
+ const { container } = render(
62
+ <InlineToolItem
63
+ part={part({ status: "error", error: "ENOENT: no such file", output: undefined })}
64
+ />,
65
+ );
66
+ expect(container.querySelector('[data-run-row-status="error"]')).not.toBeNull();
67
+ expect(screen.getByText("ENOENT: no such file")).toBeInTheDocument();
68
+ });
69
+
70
+ it("carries the completed duration as a hover-only label", () => {
71
+ const { container } = render(<InlineToolItem part={part({})} />);
72
+ const duration = container.querySelector("[data-run-row-duration]");
73
+ expect(duration!.textContent).toBe(formatDuration(60));
74
+ expect(duration!.className).toContain("group-hover:opacity-100");
75
+ });
76
+ });
@@ -20,6 +20,7 @@ import {
20
20
  import type { ToolPart } from "../types/parts";
21
21
  import type { ToolCategory } from "../types/run";
22
22
  import type { CustomToolRenderer } from "../types/tool-display";
23
+ import { TextShimmer } from "../primitives/text-shimmer";
23
24
  import { ExpandedToolDetail } from "./expanded-tool-detail";
24
25
  import { RunRowShell, type RunRowStatus } from "./run-row-shell";
25
26
 
@@ -60,10 +61,11 @@ function toRowStatus(status: ToolPart["state"]["status"]): RunRowStatus {
60
61
  }
61
62
 
62
63
  /**
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.
64
+ * Compact single-line tool call display: a category glyph, the tool verb, and
65
+ * the path or command it acted on. While the call runs the verb shimmers;
66
+ * once it completes the row goes quiet (duration on hover, a red dot only on
67
+ * error). Expands on click to show ExpandedToolDetail. Rendered through the
68
+ * shared RunRowShell so it stays consistent with the reasoning row.
67
69
  */
68
70
  export const InlineToolItem = memo(
69
71
  ({
@@ -88,15 +90,15 @@ export const InlineToolItem = memo(
88
90
 
89
91
  const category = getToolCategory(part.tool);
90
92
  const DefaultIcon = TOOL_CATEGORY_ICON_MAP[category] ?? Settings;
93
+ const status = toRowStatus(part.state.status);
91
94
 
92
95
  return (
93
96
  <RunRowShell
94
97
  icon={<DefaultIcon className="h-3.5 w-3.5" />}
95
- title={title}
98
+ title={status === "running" ? <TextShimmer>{title}</TextShimmer> : title}
96
99
  description={description}
97
100
  descriptionMono
98
- status={toRowStatus(part.state.status)}
99
- startTime={startTime}
101
+ status={status}
100
102
  durationMs={durationMs}
101
103
  groupPosition={groupPosition}
102
104
  collapsedError={errorText ?? undefined}
@@ -28,8 +28,9 @@ import { Markdown } from "../markdown/markdown";
28
28
 
29
29
  /**
30
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.
31
+ * narrow gutter, content to the right, so a run reads as separated, distinct
32
+ * steps — not one filled box. The dot's top offset centers it on a 30px run
33
+ * row header (RunRowShell: 22px glyph slot + 4px inset each side).
33
34
  */
34
35
  function SpineRow({
35
36
  accentClassName,
@@ -48,7 +49,7 @@ function SpineRow({
48
49
  )}
49
50
  <span
50
51
  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
+ "relative mt-2.5 h-[var(--timeline-dot-size,0.5rem)] w-[var(--timeline-dot-size,0.5rem)] rounded-full ring-4 ring-[var(--bg-root)]",
52
53
  accentClassName,
53
54
  )}
54
55
  />