@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.
- package/CHANGELOG.md +22 -0
- package/README.md +70 -2
- package/dist/chat.d.ts +18 -8
- package/dist/chat.js +6 -5
- package/dist/{chunk-XROQW5J7.js → chunk-4ENFLPWQ.js} +1 -1
- package/dist/{chunk-XPYPZMYT.js → chunk-6RIX7M27.js} +1 -1
- package/dist/{chunk-R5GVQDL3.js → chunk-CVX53NCC.js} +7 -5
- package/dist/{chunk-ULDNFLIM.js → chunk-EIUE5GV2.js} +10 -14
- package/dist/chunk-L57WRIND.js +59 -0
- package/dist/chunk-LFVF2HFB.js +19 -0
- package/dist/{chunk-7O22XQ4D.js → chunk-PLWRAS5Z.js} +1 -1
- package/dist/{chunk-NZLLDHEG.js → chunk-QGTIBEPX.js} +608 -420
- package/dist/{chunk-AZWDI2JG.js → chunk-UYTRNR7Z.js} +1 -1
- package/dist/{chunk-RKC3GXHE.js → chunk-VDCZHOOO.js} +99 -104
- package/dist/{chunk-J2G7KCYH.js → chunk-XT4JMYVB.js} +109 -67
- package/dist/{document-editor-pane-MX3FIZSE.js → document-editor-pane-A3JURPW5.js} +1 -1
- package/dist/{document-editor-pane-C3cYEDWZ.d.ts → document-editor-pane-IAEQn_Dl.d.ts} +8 -4
- package/dist/editor.d.ts +5 -4
- package/dist/editor.js +1 -1
- package/dist/files.d.ts +1 -1
- package/dist/files.js +1 -1
- package/dist/hooks.js +6 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +16 -11
- package/dist/primitives.d.ts +15 -1
- package/dist/primitives.js +5 -1
- package/dist/run.d.ts +22 -17
- package/dist/run.js +8 -6
- package/dist/sdk-hooks.js +6 -5
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +1 -1
- package/package.json +1 -1
- package/src/chat/agent-timeline.stories.tsx +163 -10
- package/src/chat/agent-timeline.test.tsx +114 -7
- package/src/chat/agent-timeline.tsx +120 -158
- package/src/chat/chat-container.test.tsx +59 -0
- package/src/chat/user-message.test.tsx +33 -0
- package/src/chat/user-message.tsx +13 -8
- package/src/editor/document-editor-pane.test.tsx +58 -0
- package/src/editor/editor-lazy.test.tsx +102 -0
- package/src/editor/editor-lazy.ts +40 -0
- package/src/editor/editor-loading.tsx +25 -0
- package/src/editor/editor-peers.test.ts +250 -0
- package/src/editor/editor-peers.ts +272 -0
- package/src/editor/editor-provider.test.tsx +226 -0
- package/src/editor/editor-provider.tsx +301 -265
- package/src/editor/editor-toolbar.tsx +1 -1
- package/src/editor/markdown-document-editor.test.tsx +121 -0
- package/src/editor/markdown-document-editor.tsx +114 -82
- package/src/editor/tiptap-editor.test.tsx +228 -0
- package/src/editor/tiptap-editor.tsx +162 -130
- package/src/primitives/index.ts +3 -0
- package/src/primitives/text-shimmer.test.tsx +34 -0
- package/src/primitives/text-shimmer.tsx +82 -0
- package/src/run/inline-thinking-item.stories.tsx +2 -2
- package/src/run/inline-thinking-item.tsx +2 -2
- package/src/run/inline-tool-item.test.tsx +76 -0
- package/src/run/inline-tool-item.tsx +9 -7
- package/src/run/run-group.tsx +4 -3
- package/src/run/run-row-shell.test.tsx +74 -4
- package/src/run/run-row-shell.tsx +81 -53
- package/src/run/tool-call-step.tsx +9 -7
- package/src/utils/tool-display.test.ts +53 -0
- package/src/utils/tool-display.ts +16 -15
- package/dist/chunk-LQS34IGP.js +0 -0
- /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
|
|
5
|
-
import
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
<
|
|
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
|
-
|
|
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
|
|
package/src/primitives/index.ts
CHANGED
|
@@ -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 (
|
|
139
|
-
//
|
|
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 ?
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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={
|
|
99
|
-
startTime={startTime}
|
|
101
|
+
status={status}
|
|
100
102
|
durationMs={durationMs}
|
|
101
103
|
groupPosition={groupPosition}
|
|
102
104
|
collapsedError={errorText ?? undefined}
|
package/src/run/run-group.tsx
CHANGED
|
@@ -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
|
|
32
|
-
*
|
|
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-
|
|
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
|
/>
|