@tangle-network/ui 11.0.0 → 11.1.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 +15 -0
- package/dist/chat.d.ts +8 -2
- package/dist/chat.js +3 -3
- package/dist/chunk-4ORVXSMU.js +507 -0
- package/dist/{chunk-CCM2KRQH.js → chunk-EWIWYC2I.js} +1 -1
- package/dist/{chunk-KBOD47AW.js → chunk-F22SQTBP.js} +1 -1
- package/dist/{chunk-KHO7DYXH.js → chunk-FIFE5HCW.js} +105 -70
- package/dist/{chunk-HHYUPAOJ.js → chunk-PX5KV5HI.js} +47 -79
- package/dist/hooks.js +3 -3
- package/dist/index.js +5 -5
- package/dist/run.d.ts +4 -3
- package/dist/run.js +3 -3
- package/dist/sdk-hooks.js +3 -3
- package/package.json +1 -1
- package/src/chat/agent-timeline.stories.tsx +11 -0
- package/src/chat/agent-timeline.test.tsx +34 -0
- package/src/chat/agent-timeline.tsx +65 -12
- package/src/chat/chat-container.tsx +5 -0
- package/src/run/expandable-content.test.tsx +52 -0
- package/src/run/expandable-content.tsx +73 -0
- package/src/run/expanded-tool-detail.tsx +17 -12
- package/src/run/inline-thinking-item.stories.tsx +30 -1
- package/src/run/inline-thinking-item.tsx +34 -70
- package/src/run/inline-tool-item.tsx +30 -116
- package/src/run/run-row-shell.test.tsx +38 -0
- package/src/run/run-row-shell.tsx +191 -0
- package/dist/chunk-INMWNFAS.js +0 -369
|
@@ -126,6 +126,40 @@ describe("AgentTimeline tool actions", () => {
|
|
|
126
126
|
).toBeInTheDocument()
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
it("renders every row and no toggle when collapseAfter is unset", () => {
|
|
130
|
+
const items: AgentTimelineItem[] = Array.from({ length: 5 }, (_, i) => ({
|
|
131
|
+
id: `s-${i}`,
|
|
132
|
+
kind: "status",
|
|
133
|
+
label: `Step ${i}`,
|
|
134
|
+
}))
|
|
135
|
+
render(<AgentTimeline items={items} />)
|
|
136
|
+
expect(screen.getByText("Step 4")).toBeInTheDocument()
|
|
137
|
+
expect(
|
|
138
|
+
screen.queryByRole("button", { name: /more step/i }),
|
|
139
|
+
).not.toBeInTheDocument()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("collapses to the first N spine rows behind a toggle and reveals the rest", async () => {
|
|
143
|
+
const user = userEvent.setup()
|
|
144
|
+
const items: AgentTimelineItem[] = Array.from({ length: 5 }, (_, i) => ({
|
|
145
|
+
id: `s-${i}`,
|
|
146
|
+
kind: "status",
|
|
147
|
+
label: `Step ${i}`,
|
|
148
|
+
}))
|
|
149
|
+
render(<AgentTimeline items={items} collapseAfter={2} />)
|
|
150
|
+
|
|
151
|
+
// First 2 visible, the other 3 hidden behind the toggle
|
|
152
|
+
expect(screen.getByText("Step 0")).toBeInTheDocument()
|
|
153
|
+
expect(screen.getByText("Step 1")).toBeInTheDocument()
|
|
154
|
+
expect(screen.queryByText("Step 4")).not.toBeInTheDocument()
|
|
155
|
+
|
|
156
|
+
const toggle = screen.getByRole("button", { name: /show 3 more steps/i })
|
|
157
|
+
await user.click(toggle)
|
|
158
|
+
|
|
159
|
+
expect(screen.getByText("Step 4")).toBeInTheDocument()
|
|
160
|
+
expect(screen.getByRole("button", { name: /show less/i })).toBeInTheDocument()
|
|
161
|
+
})
|
|
162
|
+
|
|
129
163
|
it("renders the source part's real input in the expanded detail", async () => {
|
|
130
164
|
const user = userEvent.setup()
|
|
131
165
|
const probePart: ToolPart = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type KeyboardEvent, type ReactNode } from "react";
|
|
1
|
+
import { useState, type KeyboardEvent, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
AlertTriangle,
|
|
4
4
|
CheckCircle2,
|
|
@@ -88,6 +88,9 @@ export interface AgentTimelineProps {
|
|
|
88
88
|
/** Optional actions rendered beside each tool item (e.g. "open in artifacts").
|
|
89
89
|
* Receives the source tool part carried on the item. */
|
|
90
90
|
renderToolActions?: (part: ToolPart) => ReactNode;
|
|
91
|
+
/** When set, collapse the timeline to the first N spine rows behind a
|
|
92
|
+
* "Show N more steps" toggle. Omit to always show every row. */
|
|
93
|
+
collapseAfter?: number;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
const TONE_STYLES: Record<AgentTimelineTone, { dot: string; card: string; text: string; icon: typeof Info }> = {
|
|
@@ -130,17 +133,24 @@ function formatTime(date: Date): string {
|
|
|
130
133
|
interface AgentTimelineRowProps {
|
|
131
134
|
isLast: boolean;
|
|
132
135
|
accentClassName: string;
|
|
136
|
+
/** Dot top-offset. Defaults to aligning with the first text line; card rows
|
|
137
|
+
* pass CARD_ROW_DOT to center the dot on the card header instead. */
|
|
138
|
+
dotClassName?: string;
|
|
133
139
|
children: ReactNode;
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
|
|
142
|
+
// Centers the dot on a RunRowShell card header (2.5rem: h-6 badge + py-2),
|
|
143
|
+
// so tool and reasoning rows read as dot-aligned rather than top-anchored.
|
|
144
|
+
const CARD_ROW_DOT = "mt-[calc((2.5rem-var(--timeline-dot-size))/2)]";
|
|
145
|
+
|
|
146
|
+
function AgentTimelineRow({ isLast, accentClassName, dotClassName = "mt-2", children }: AgentTimelineRowProps) {
|
|
137
147
|
return (
|
|
138
148
|
<div className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-4">
|
|
139
149
|
<div className="relative flex justify-center">
|
|
140
150
|
{!isLast && (
|
|
141
151
|
<span className="absolute top-4 bottom-[-0.75rem] left-1/2 w-px -translate-x-1/2 bg-border" />
|
|
142
152
|
)}
|
|
143
|
-
<span className={cn("relative
|
|
153
|
+
<span className={cn("relative h-[var(--timeline-dot-size)] w-[var(--timeline-dot-size)] rounded-full ring-4 ring-[var(--bg-root)]", dotClassName, accentClassName)} />
|
|
144
154
|
</div>
|
|
145
155
|
<div className="min-w-0 pb-3">{children}</div>
|
|
146
156
|
</div>
|
|
@@ -245,7 +255,10 @@ export function AgentTimeline({
|
|
|
245
255
|
emptyState,
|
|
246
256
|
className,
|
|
247
257
|
renderToolActions,
|
|
258
|
+
collapseAfter,
|
|
248
259
|
}: AgentTimelineProps) {
|
|
260
|
+
const [expanded, setExpanded] = useState(false);
|
|
261
|
+
|
|
249
262
|
if (items.length === 0 && !isThinking) {
|
|
250
263
|
return emptyState ? (
|
|
251
264
|
<div className={cn("flex h-full items-center justify-center p-4", className)}>
|
|
@@ -258,13 +271,41 @@ export function AgentTimeline({
|
|
|
258
271
|
? [...items, { id: "__thinking__", kind: "custom", content: <ThinkingIndicator /> }]
|
|
259
272
|
: items;
|
|
260
273
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
274
|
+
const isUserMessage = (item: AgentTimelineItem) =>
|
|
275
|
+
item.kind === "message" && item.role === "user";
|
|
276
|
+
|
|
277
|
+
// Items on the vertical connector (user messages render off-spine).
|
|
278
|
+
const timelineItems = renderedItems.filter((item) => !isUserMessage(item));
|
|
279
|
+
|
|
280
|
+
const limit = collapseAfter ?? Infinity;
|
|
281
|
+
const collapsible = timelineItems.length > limit;
|
|
282
|
+
const collapsed = collapsible && !expanded;
|
|
283
|
+
|
|
284
|
+
// While collapsed, keep only the first `limit` spine rows (and any user
|
|
285
|
+
// messages that precede them); the rest hides behind the toggle.
|
|
286
|
+
let renderList = renderedItems;
|
|
287
|
+
let hiddenCount = 0;
|
|
288
|
+
if (collapsed) {
|
|
289
|
+
const visible: AgentTimelineItem[] = [];
|
|
290
|
+
let spineCount = 0;
|
|
291
|
+
for (const item of renderedItems) {
|
|
292
|
+
if (spineCount >= limit) break;
|
|
293
|
+
visible.push(item);
|
|
294
|
+
if (!isUserMessage(item)) spineCount += 1;
|
|
295
|
+
}
|
|
296
|
+
renderList = visible;
|
|
297
|
+
hiddenCount = timelineItems.length - limit;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const visibleSpine = renderList.filter((item) => !isUserMessage(item));
|
|
301
|
+
// When a toggle row follows, no real row is last — the toggle owns the tail.
|
|
302
|
+
const lastSpineItem = collapsible
|
|
303
|
+
? undefined
|
|
304
|
+
: visibleSpine[visibleSpine.length - 1];
|
|
264
305
|
|
|
265
306
|
return (
|
|
266
307
|
<div className={cn("mx-auto w-full max-w-5xl px-4 py-4", className)}>
|
|
267
|
-
{
|
|
308
|
+
{renderList.map((item) => {
|
|
268
309
|
// User messages: right-aligned bubble, off-spine — needs its own vertical
|
|
269
310
|
// rhythm so it doesn't sit flush against the status/tool/agent row below.
|
|
270
311
|
if (item.kind === "message" && item.role === "user") {
|
|
@@ -275,8 +316,7 @@ export function AgentTimeline({
|
|
|
275
316
|
);
|
|
276
317
|
}
|
|
277
318
|
|
|
278
|
-
const
|
|
279
|
-
const isLast = timelineIndex === timelineItems.length - 1;
|
|
319
|
+
const isLast = item === lastSpineItem;
|
|
280
320
|
|
|
281
321
|
if (item.kind === "message") {
|
|
282
322
|
return (
|
|
@@ -288,7 +328,7 @@ export function AgentTimeline({
|
|
|
288
328
|
|
|
289
329
|
if (item.kind === "tool") {
|
|
290
330
|
return (
|
|
291
|
-
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
|
|
331
|
+
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
|
|
292
332
|
<ToolCallStep
|
|
293
333
|
type={item.call.type}
|
|
294
334
|
label={item.call.label}
|
|
@@ -305,7 +345,7 @@ export function AgentTimeline({
|
|
|
305
345
|
|
|
306
346
|
if (item.kind === "tool_group") {
|
|
307
347
|
return (
|
|
308
|
-
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
|
|
348
|
+
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
|
|
309
349
|
<ToolCallGroup title={item.title}>
|
|
310
350
|
{item.calls.map((call, callIndex) => {
|
|
311
351
|
const part = item.parts?.[callIndex];
|
|
@@ -354,11 +394,24 @@ export function AgentTimeline({
|
|
|
354
394
|
|
|
355
395
|
// custom
|
|
356
396
|
return (
|
|
357
|
-
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
|
|
397
|
+
<AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
|
|
358
398
|
{(item as AgentTimelineCustomItem).content}
|
|
359
399
|
</AgentTimelineRow>
|
|
360
400
|
);
|
|
361
401
|
})}
|
|
402
|
+
{collapsible ? (
|
|
403
|
+
<AgentTimelineRow isLast accentClassName="bg-[var(--border-hover)]">
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
onClick={() => setExpanded((value) => !value)}
|
|
407
|
+
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
408
|
+
>
|
|
409
|
+
{collapsed
|
|
410
|
+
? `Show ${hiddenCount} more step${hiddenCount === 1 ? "" : "s"}`
|
|
411
|
+
: "Show less"}
|
|
412
|
+
</button>
|
|
413
|
+
</AgentTimelineRow>
|
|
414
|
+
) : null}
|
|
362
415
|
</div>
|
|
363
416
|
);
|
|
364
417
|
}
|
|
@@ -63,6 +63,9 @@ export interface ChatContainerProps {
|
|
|
63
63
|
/** Optional actions rendered beside individual tool items in the `timeline`
|
|
64
64
|
* presentation, which has no run/message grouping (hence part-only). */
|
|
65
65
|
renderTimelineToolActions?: (part: ToolPart) => ReactNode;
|
|
66
|
+
/** In the `timeline` presentation, collapse to the first N spine rows behind
|
|
67
|
+
* a "Show N more steps" toggle. Omit to always show every row. */
|
|
68
|
+
collapseTimelineAfter?: number;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
const OPENUI_NODE_TYPES = new Set([
|
|
@@ -373,6 +376,7 @@ export const ChatContainer = memo(
|
|
|
373
376
|
renderUserMessageActions,
|
|
374
377
|
renderToolActions,
|
|
375
378
|
renderTimelineToolActions,
|
|
379
|
+
collapseTimelineAfter,
|
|
376
380
|
}: ChatContainerProps) => {
|
|
377
381
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
378
382
|
|
|
@@ -414,6 +418,7 @@ export const ChatContainer = memo(
|
|
|
414
418
|
items={timeline.items}
|
|
415
419
|
isThinking={timeline.showThinking}
|
|
416
420
|
renderToolActions={renderTimelineToolActions}
|
|
421
|
+
collapseAfter={collapseTimelineAfter}
|
|
417
422
|
/>
|
|
418
423
|
</div>
|
|
419
424
|
) : (
|
|
@@ -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
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
3
|
-
import {
|
|
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 {
|
|
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 =
|
|
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
|
-
<
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
);
|