@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +43 -0
  3. package/dist/chat.d.ts +18 -8
  4. package/dist/chat.js +9 -7
  5. package/dist/chunk-4CLN43XT.js +45 -0
  6. package/dist/{chunk-AZWDI2JG.js → chunk-565EATZZ.js} +1 -1
  7. package/dist/{chunk-XROQW5J7.js → chunk-BWOUSEHM.js} +1 -1
  8. package/dist/{chunk-J2G7KCYH.js → chunk-KNYSKNZX.js} +113 -69
  9. package/dist/chunk-L57WRIND.js +59 -0
  10. package/dist/{chunk-ULDNFLIM.js → chunk-ND3ZFXQK.js} +11 -58
  11. package/dist/{chunk-AAUNOHVL.js → chunk-NWECTSOH.js} +1 -1
  12. package/dist/{chunk-R5GVQDL3.js → chunk-OXZRCXJK.js} +9 -7
  13. package/dist/{chunk-XPYPZMYT.js → chunk-Q7ZR6LSD.js} +1 -1
  14. package/dist/{chunk-RKC3GXHE.js → chunk-X53RZSX6.js} +99 -104
  15. package/dist/{chunk-PLWRAS5Z.js → chunk-XGOWUDDW.js} +270 -106
  16. package/dist/{chunk-ZRVH3WCA.js → chunk-XU3HB5QL.js} +21 -1
  17. package/dist/chunk-YTO5V6QS.js +19 -0
  18. package/dist/files.d.ts +41 -12
  19. package/dist/files.js +13 -5
  20. package/dist/hooks.js +9 -7
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +37 -24
  23. package/dist/primitives.d.ts +15 -1
  24. package/dist/primitives.js +5 -1
  25. package/dist/run.d.ts +22 -17
  26. package/dist/run.js +11 -8
  27. package/dist/sdk-hooks.js +9 -7
  28. package/dist/tool-previews.js +2 -2
  29. package/dist/utils.d.ts +6 -0
  30. package/dist/utils.js +6 -4
  31. package/package.json +1 -1
  32. package/src/chat/agent-timeline.stories.tsx +163 -10
  33. package/src/chat/agent-timeline.test.tsx +114 -7
  34. package/src/chat/agent-timeline.tsx +120 -158
  35. package/src/chat/chat-container.test.tsx +59 -0
  36. package/src/chat/user-message.test.tsx +33 -0
  37. package/src/chat/user-message.tsx +13 -8
  38. package/src/files/file-artifact-pane.stories.tsx +149 -0
  39. package/src/files/file-artifact-pane.test.tsx +192 -0
  40. package/src/files/file-artifact-pane.tsx +6 -1
  41. package/src/files/file-format.ts +42 -4
  42. package/src/files/file-preview-kind.test.ts +109 -0
  43. package/src/files/file-preview.tsx +315 -132
  44. package/src/files/index.ts +8 -1
  45. package/src/primitives/index.ts +3 -0
  46. package/src/primitives/text-shimmer.test.tsx +34 -0
  47. package/src/primitives/text-shimmer.tsx +82 -0
  48. package/src/run/inline-thinking-item.stories.tsx +2 -2
  49. package/src/run/inline-thinking-item.tsx +2 -2
  50. package/src/run/inline-tool-item.test.tsx +76 -0
  51. package/src/run/inline-tool-item.tsx +9 -7
  52. package/src/run/run-group.tsx +4 -3
  53. package/src/run/run-row-shell.test.tsx +74 -4
  54. package/src/run/run-row-shell.tsx +81 -53
  55. package/src/run/tool-call-step.tsx +9 -7
  56. package/src/utils/tool-display.test.ts +53 -0
  57. package/src/utils/tool-display.ts +16 -15
  58. package/dist/chunk-LQS34IGP.js +0 -0
  59. /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 (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
  />
@@ -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(status: "running" | "success" | "error" | "idle") {
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("shows a spinner while running", () => {
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")).not.toBeNull();
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
- render(<RunRowShell icon={<span>i</span>} title="Reasoning" />);
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 { Loader2, ChevronRight } from "lucide-react";
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: a spinner while running,
12
- * a green/red dot on terminal states, nothing when idle. Status is shown here
13
- * — it never replaces the row's semantic lead icon.
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 === "running") {
17
- return <Loader2 className="h-3 w-3 shrink-0 animate-spin text-[var(--accent-text)]" />;
18
- }
19
- if (status === "success") {
20
- return (
21
- <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--surface-success-text)]" />
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 — always visible; never replaced by status. */
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 (e.g. an active-state shimmer sweep) — it renders inline in
37
- * the shell's title slot either way. */
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
- /** Live-ticks a duration while running. */
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 shown once the row is no longer running. */
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). Owns the
75
- * bordered container, the lead icon badge, title/description, the trailing
76
- * meta cluster (duration + status + chevron) and the collapsible body, so
77
- * both row kinds read as one family instead of two bespoke layouts.
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
- "border-[var(--border-subtle)] bg-[var(--md3-surface-container)]",
115
- expandable && "hover:border-border",
116
- open && "border-border",
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.5 px-3 py-2 text-left",
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 className="flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border border-border bg-muted text-muted-foreground">
130
- {icon}
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 className="shrink-0 whitespace-nowrap text-xs font-medium text-foreground">
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-xs text-muted-foreground sm:inline",
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
- <div className="ml-auto flex shrink-0 items-center gap-1.5">
148
- {isRunning && startTime != null ? (
149
- <LiveDuration startTime={startTime} />
150
- ) : durationMs != null ? (
151
- <span className="text-[10px] font-mono tabular-nums text-muted-foreground">
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
- {expandable ? (
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 className="border-t border-border px-3 py-2 text-xs text-[var(--surface-danger-text)]">
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 — groups multiple tool calls under a heading.
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 className={["my-2 space-y-2", className].filter(Boolean).join(" ")}>
143
- {title && (
144
- <div className="mb-1 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
145
- {title}
146
- </div>
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
  );