@tangle-network/ui 11.6.0 → 11.8.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 +12 -0
- package/README.md +43 -0
- package/dist/chat.d.ts +18 -8
- package/dist/chat.js +9 -7
- package/dist/chunk-4CLN43XT.js +45 -0
- package/dist/{chunk-AZWDI2JG.js → chunk-565EATZZ.js} +1 -1
- package/dist/{chunk-XROQW5J7.js → chunk-BWOUSEHM.js} +1 -1
- package/dist/{chunk-J2G7KCYH.js → chunk-KNYSKNZX.js} +113 -69
- package/dist/chunk-L57WRIND.js +59 -0
- package/dist/{chunk-ULDNFLIM.js → chunk-ND3ZFXQK.js} +11 -58
- package/dist/{chunk-AAUNOHVL.js → chunk-NWECTSOH.js} +1 -1
- package/dist/{chunk-R5GVQDL3.js → chunk-OXZRCXJK.js} +9 -7
- package/dist/{chunk-XPYPZMYT.js → chunk-Q7ZR6LSD.js} +1 -1
- package/dist/{chunk-RKC3GXHE.js → chunk-X53RZSX6.js} +99 -104
- package/dist/{chunk-PLWRAS5Z.js → chunk-XGOWUDDW.js} +270 -106
- package/dist/{chunk-ZRVH3WCA.js → chunk-XU3HB5QL.js} +21 -1
- package/dist/chunk-YTO5V6QS.js +19 -0
- package/dist/files.d.ts +41 -12
- package/dist/files.js +13 -5
- package/dist/hooks.js +9 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -24
- package/dist/primitives.d.ts +15 -1
- package/dist/primitives.js +5 -1
- package/dist/run.d.ts +22 -17
- package/dist/run.js +11 -8
- package/dist/sdk-hooks.js +9 -7
- package/dist/tool-previews.js +2 -2
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +6 -4
- 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/files/file-artifact-pane.stories.tsx +149 -0
- package/src/files/file-artifact-pane.test.tsx +192 -0
- package/src/files/file-artifact-pane.tsx +6 -1
- package/src/files/file-format.ts +42 -4
- package/src/files/file-preview-kind.test.ts +109 -0
- package/src/files/file-preview.tsx +315 -132
- package/src/files/index.ts +8 -1
- 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
|
@@ -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
|
/>
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { render, screen } from "@testing-library/react";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { formatDuration } from "../utils/format";
|
|
3
4
|
import { RunRowShell } from "./run-row-shell";
|
|
4
5
|
|
|
5
|
-
function renderShell(
|
|
6
|
+
function renderShell(
|
|
7
|
+
status: "running" | "success" | "error" | "idle",
|
|
8
|
+
extra: { durationMs?: number; collapsedError?: string } = {},
|
|
9
|
+
) {
|
|
6
10
|
return render(
|
|
7
11
|
<RunRowShell
|
|
8
12
|
icon={<span data-testid="lead-icon">icon</span>}
|
|
9
13
|
title="Ran command"
|
|
10
14
|
status={status}
|
|
15
|
+
{...extra}
|
|
11
16
|
>
|
|
12
17
|
<div>body</div>
|
|
13
18
|
</RunRowShell>,
|
|
@@ -24,15 +29,80 @@ describe("RunRowShell", () => {
|
|
|
24
29
|
}
|
|
25
30
|
});
|
|
26
31
|
|
|
27
|
-
it("
|
|
32
|
+
it("draws no spinner while running — the running signal is the title", () => {
|
|
28
33
|
const { container } = renderShell("running");
|
|
29
|
-
expect(container.querySelector(".animate-spin")).
|
|
34
|
+
expect(container.querySelector(".animate-spin")).toBeNull();
|
|
35
|
+
expect(container.querySelector("[data-run-row-status]")).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders no status dot on success (success is silence)", () => {
|
|
39
|
+
const { container } = renderShell("success", { durationMs: 1200 });
|
|
40
|
+
expect(container.querySelector("[data-run-row-status]")).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders the red dot and the collapsed error on error", () => {
|
|
44
|
+
const { container } = renderShell("error", {
|
|
45
|
+
collapsedError: "exit code 1",
|
|
46
|
+
});
|
|
47
|
+
expect(
|
|
48
|
+
container.querySelector('[data-run-row-status="error"]'),
|
|
49
|
+
).not.toBeNull();
|
|
50
|
+
expect(screen.getByText("exit code 1")).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rests with no card chrome: transparent border and fill, bare glyph", () => {
|
|
54
|
+
const { container } = renderShell("success");
|
|
55
|
+
const row = container.querySelector("[data-run-row]");
|
|
56
|
+
expect(row).not.toBeNull();
|
|
57
|
+
expect(row!.className).toContain("border-transparent");
|
|
58
|
+
expect(row!.className).toContain("bg-transparent");
|
|
59
|
+
// The border stays reserved so hover/open never shift layout.
|
|
60
|
+
expect(row!.className).toMatch(/(^|\s)border(\s|$)/);
|
|
61
|
+
// No bordered badge box around the glyph.
|
|
62
|
+
const glyph = container.querySelector("[data-run-row-glyph]");
|
|
63
|
+
expect(glyph!.className).not.toContain("border");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("takes the card fill only while open", () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<RunRowShell icon={<span>i</span>} title="Ran command" open>
|
|
69
|
+
<div>body</div>
|
|
70
|
+
</RunRowShell>,
|
|
71
|
+
);
|
|
72
|
+
const row = container.querySelector("[data-run-row]");
|
|
73
|
+
expect(row!.className).toContain("bg-[var(--md3-surface-container)]");
|
|
74
|
+
expect(row!.className).toContain("border-border");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reveals the completed duration on hover only", () => {
|
|
78
|
+
const { container } = renderShell("success", { durationMs: 1840 });
|
|
79
|
+
const duration = container.querySelector("[data-run-row-duration]");
|
|
80
|
+
expect(duration).not.toBeNull();
|
|
81
|
+
expect(duration!.textContent).toBe(formatDuration(1840));
|
|
82
|
+
expect(duration!.className).toContain("opacity-0");
|
|
83
|
+
expect(duration!.className).toContain("group-hover:opacity-100");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("shows no duration while running", () => {
|
|
87
|
+
const { container } = renderShell("running", { durationMs: 1840 });
|
|
88
|
+
expect(container.querySelector("[data-run-row-duration]")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("puts the chevron in the glyph slot for expandable rows, hover/open only", () => {
|
|
92
|
+
const { container } = renderShell("success");
|
|
93
|
+
const chevron = container.querySelector("[data-run-row-chevron]");
|
|
94
|
+
expect(chevron).not.toBeNull();
|
|
95
|
+
expect(chevron!.getAttribute("class")).toContain("hidden");
|
|
96
|
+
expect(chevron!.getAttribute("class")).toContain("group-hover:block");
|
|
30
97
|
});
|
|
31
98
|
|
|
32
99
|
it("is not expandable and has no chevron when no children are provided", () => {
|
|
33
|
-
|
|
100
|
+
const { container } = render(
|
|
101
|
+
<RunRowShell icon={<span>i</span>} title="Reasoning" />,
|
|
102
|
+
);
|
|
34
103
|
// The trigger button is disabled when there is no expandable body.
|
|
35
104
|
const button = screen.getByRole("button", { name: /reasoning/i });
|
|
36
105
|
expect(button).toBeDisabled();
|
|
106
|
+
expect(container.querySelector("[data-run-row-chevron]")).toBeNull();
|
|
37
107
|
});
|
|
38
108
|
});
|
|
@@ -1,49 +1,44 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
3
|
-
import {
|
|
3
|
+
import { ChevronRight } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
5
5
|
import { formatDuration } from "../utils/format";
|
|
6
|
-
import { LiveDuration } from "./run-item-primitives";
|
|
7
6
|
|
|
8
7
|
export type RunRowStatus = "running" | "success" | "error" | "idle";
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
* Trailing status indicator shared by every run row
|
|
12
|
-
* a
|
|
13
|
-
* —
|
|
10
|
+
* Trailing status indicator shared by every run row. Only `error` draws a
|
|
11
|
+
* mark (a red dot). Success is silence, and a running row carries its state in
|
|
12
|
+
* the title — a `TextShimmer` sweep — so neither renders anything here.
|
|
14
13
|
*/
|
|
15
14
|
export function RunRowStatusDot({ status }: { status: RunRowStatus }) {
|
|
16
|
-
if (status
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
if (status === "error") {
|
|
25
|
-
return (
|
|
26
|
-
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--surface-danger-text)]" />
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
return null;
|
|
15
|
+
if (status !== "error") return null;
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
data-run-row-status="error"
|
|
19
|
+
className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--surface-danger-text)]"
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
30
22
|
}
|
|
31
23
|
|
|
32
24
|
export interface RunRowShellProps {
|
|
33
|
-
/** Semantic lead glyph
|
|
25
|
+
/** Semantic lead glyph (14px). At rest it is the row's only ornament; on
|
|
26
|
+
* hover and while open the expand chevron takes its slot. */
|
|
34
27
|
icon: ReactNode;
|
|
35
28
|
/** Row title. A string in the common case; a node when the title carries its
|
|
36
|
-
* own treatment
|
|
37
|
-
* the shell
|
|
29
|
+
* own treatment — a running row passes `<TextShimmer>` here, which is the
|
|
30
|
+
* whole in-flight signal (the shell draws no spinner). */
|
|
38
31
|
title: ReactNode;
|
|
39
32
|
/** Secondary inline text (tool path/command, or a reasoning preview). */
|
|
40
33
|
description?: string;
|
|
41
34
|
/** Render the description in mono (tools) vs prose (reasoning). */
|
|
42
35
|
descriptionMono?: boolean;
|
|
43
36
|
status?: RunRowStatus;
|
|
44
|
-
/**
|
|
37
|
+
/** @deprecated The shell no longer ticks a live duration while running; the
|
|
38
|
+
* running state lives in the title (`TextShimmer`). Accepted and ignored so
|
|
39
|
+
* existing callers keep compiling. */
|
|
45
40
|
startTime?: number;
|
|
46
|
-
/** Static duration
|
|
41
|
+
/** Static duration, revealed on hover once the row is no longer running. */
|
|
47
42
|
durationMs?: number;
|
|
48
43
|
/** Radius shaping when rows are stacked into a group. */
|
|
49
44
|
groupPosition?: "single" | "first" | "middle" | "last";
|
|
@@ -70,11 +65,17 @@ const SHAPE_CLASS: Record<
|
|
|
70
65
|
last: "rounded-t-[var(--radius-sm)] rounded-b-[var(--radius-lg)]",
|
|
71
66
|
};
|
|
72
67
|
|
|
68
|
+
// Header geometry: 8px inset, 22px glyph slot, 8px gap. The collapsed error
|
|
69
|
+
// indents by their sum so it starts under the title, not under the glyph.
|
|
70
|
+
const HEADER_INSET = "px-2 py-1";
|
|
71
|
+
const ERROR_INDENT = "pl-[2.375rem]";
|
|
72
|
+
|
|
73
73
|
/**
|
|
74
|
-
* Shared shell for the agent activity rows (reasoning + tool).
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* Shared shell for the agent activity rows (reasoning + tool). A quiet
|
|
75
|
+
* one-liner at rest — bare glyph, muted title and description, no fill, no
|
|
76
|
+
* border — that becomes a bordered card only while open. The border is always
|
|
77
|
+
* present (transparent at rest) so hover and expand never shift layout.
|
|
78
|
+
* Both row kinds render through it so they read as one family.
|
|
78
79
|
*/
|
|
79
80
|
export function RunRowShell({
|
|
80
81
|
icon,
|
|
@@ -82,7 +83,6 @@ export function RunRowShell({
|
|
|
82
83
|
description,
|
|
83
84
|
descriptionMono = false,
|
|
84
85
|
status = "idle",
|
|
85
|
-
startTime,
|
|
86
86
|
durationMs,
|
|
87
87
|
groupPosition = "single",
|
|
88
88
|
collapsedError,
|
|
@@ -109,11 +109,14 @@ export function RunRowShell({
|
|
|
109
109
|
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
|
110
110
|
<div className="flex items-start gap-2">
|
|
111
111
|
<div
|
|
112
|
+
data-run-row=""
|
|
113
|
+
data-state={open ? "open" : "closed"}
|
|
112
114
|
className={cn(
|
|
113
|
-
"min-w-0 flex-1 overflow-hidden border transition-colors",
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
"group min-w-0 flex-1 overflow-hidden border transition-colors",
|
|
116
|
+
open
|
|
117
|
+
? "border-border bg-[var(--md3-surface-container)]"
|
|
118
|
+
: "border-transparent bg-transparent",
|
|
119
|
+
expandable && !open && "hover:bg-[var(--md3-surface-container)]",
|
|
117
120
|
SHAPE_CLASS[groupPosition],
|
|
118
121
|
className,
|
|
119
122
|
)}
|
|
@@ -122,21 +125,48 @@ export function RunRowShell({
|
|
|
122
125
|
<button
|
|
123
126
|
type="button"
|
|
124
127
|
className={cn(
|
|
125
|
-
"flex w-full items-center gap-2
|
|
128
|
+
"flex w-full items-center gap-2 text-left",
|
|
129
|
+
HEADER_INSET,
|
|
126
130
|
expandable ? "cursor-pointer" : "cursor-default",
|
|
127
131
|
)}
|
|
128
132
|
>
|
|
129
|
-
<span
|
|
130
|
-
|
|
133
|
+
<span
|
|
134
|
+
aria-hidden
|
|
135
|
+
className="flex h-[1.375rem] w-[1.375rem] shrink-0 items-center justify-center text-muted-foreground"
|
|
136
|
+
>
|
|
137
|
+
<span
|
|
138
|
+
data-run-row-glyph=""
|
|
139
|
+
className={cn(
|
|
140
|
+
"flex items-center justify-center",
|
|
141
|
+
expandable && "group-hover:hidden",
|
|
142
|
+
open && "hidden",
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
{icon}
|
|
146
|
+
</span>
|
|
147
|
+
{expandable ? (
|
|
148
|
+
<ChevronRight
|
|
149
|
+
data-run-row-chevron=""
|
|
150
|
+
className={cn(
|
|
151
|
+
"hidden h-3.5 w-3.5 transition-transform group-hover:block",
|
|
152
|
+
open && "block rotate-90",
|
|
153
|
+
)}
|
|
154
|
+
/>
|
|
155
|
+
) : null}
|
|
131
156
|
</span>
|
|
132
157
|
|
|
133
|
-
<span
|
|
158
|
+
<span
|
|
159
|
+
className={cn(
|
|
160
|
+
"shrink-0 whitespace-nowrap text-sm font-normal text-muted-foreground transition-colors group-hover:text-foreground",
|
|
161
|
+
open && "text-foreground",
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
134
164
|
{title}
|
|
135
165
|
</span>
|
|
136
166
|
{description ? (
|
|
137
167
|
<span
|
|
138
168
|
className={cn(
|
|
139
|
-
"hidden min-w-0 flex-1 truncate text-
|
|
169
|
+
"hidden min-w-0 flex-1 truncate text-sm text-muted-foreground transition-colors group-hover:text-foreground sm:inline",
|
|
140
170
|
descriptionMono && "font-mono",
|
|
141
171
|
)}
|
|
142
172
|
>
|
|
@@ -144,29 +174,27 @@ export function RunRowShell({
|
|
|
144
174
|
</span>
|
|
145
175
|
) : null}
|
|
146
176
|
|
|
147
|
-
<
|
|
148
|
-
{isRunning &&
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
177
|
+
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
178
|
+
{!isRunning && durationMs != null ? (
|
|
179
|
+
<span
|
|
180
|
+
data-run-row-duration=""
|
|
181
|
+
className="font-mono text-[10px] tabular-nums text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
182
|
+
>
|
|
152
183
|
{formatDuration(durationMs)}
|
|
153
184
|
</span>
|
|
154
185
|
) : null}
|
|
155
186
|
<RunRowStatusDot status={status} />
|
|
156
|
-
|
|
157
|
-
<ChevronRight
|
|
158
|
-
className={cn(
|
|
159
|
-
"h-3 w-3 shrink-0 text-muted-foreground transition-transform",
|
|
160
|
-
open && "rotate-90",
|
|
161
|
-
)}
|
|
162
|
-
/>
|
|
163
|
-
) : null}
|
|
164
|
-
</div>
|
|
187
|
+
</span>
|
|
165
188
|
</button>
|
|
166
189
|
</Collapsible.Trigger>
|
|
167
190
|
|
|
168
191
|
{collapsedError && !open ? (
|
|
169
|
-
<div
|
|
192
|
+
<div
|
|
193
|
+
className={cn(
|
|
194
|
+
"pb-1.5 pr-2 text-xs leading-snug text-[var(--surface-danger-text)]",
|
|
195
|
+
ERROR_INDENT,
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
170
198
|
{collapsedError}
|
|
171
199
|
</div>
|
|
172
200
|
) : null}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { type ReactNode } from "react";
|
|
10
|
+
import { cn } from "../lib/utils";
|
|
10
11
|
import { CodeBlock } from "../markdown/code-block";
|
|
11
12
|
import type { ToolPart, ToolStatus } from "../types/parts";
|
|
12
13
|
import { InlineToolItem } from "./inline-tool-item";
|
|
@@ -129,7 +130,9 @@ export function ToolCallStep({
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
/**
|
|
132
|
-
* ToolCallGroup —
|
|
133
|
+
* ToolCallGroup — a tight stack of consecutive tool rows. The title names the
|
|
134
|
+
* group for assistive tech only; the rows themselves are the visible label, so
|
|
135
|
+
* no heading is drawn above them.
|
|
133
136
|
*/
|
|
134
137
|
export interface ToolCallGroupProps {
|
|
135
138
|
title?: string;
|
|
@@ -139,12 +142,11 @@ export interface ToolCallGroupProps {
|
|
|
139
142
|
|
|
140
143
|
export function ToolCallGroup({ title, children, className }: ToolCallGroupProps) {
|
|
141
144
|
return (
|
|
142
|
-
<div
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
)}
|
|
145
|
+
<div
|
|
146
|
+
role="group"
|
|
147
|
+
aria-label={title}
|
|
148
|
+
className={cn("flex flex-col gap-1", className)}
|
|
149
|
+
>
|
|
148
150
|
{children}
|
|
149
151
|
</div>
|
|
150
152
|
);
|