@elabs-ai/components-ai 4.0.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ToolResultCard — an artifact the agent PRODUCED, presented as the headline
5
+ * (#192, research 10 §B.4).
6
+ *
7
+ * The elevation channel of the surface-separation system (research 08 §H):
8
+ * raised fill + shadow, NO border (elevation is the separation gesture — a
9
+ * border here would be the redundant-border anti-pattern). A separate
10
+ * component from `Tool` by design: `Tool` owns the rail/inspect idiom (a step
11
+ * that ran, JSON behind disclosure); `ToolResultCard` hosts the produced
12
+ * chart/table/file as `children` — so an agent picks the channel by NAME,
13
+ * not by a behavioural mode flag.
14
+ *
15
+ * Presentational and dependency-light: it does NOT import `@elabs-ai/components-charts` or
16
+ * `@elabs-ai/components-data` (one-way dep graph) — the consumer passes the artifact (an
17
+ * `<AutoChart>`, `<DataTable>`, file preview, …) as children, and maps an
18
+ * AI-SDK tool state to `status` via `statusFromToolState` (tool.tsx).
19
+ *
20
+ * **The card owns artifact-scoped chrome in its header row** — `title |
21
+ * actions | status`, matching `ArtifactActions`, `DialogSection.actions` and
22
+ * `SectionHeader.actions` elsewhere in the system. `actions` is scoped to the
23
+ * artifact AS A WHOLE (expand it, download it, open it somewhere else); the
24
+ * payload's own controls — a chart's flip-to-table, a table's sort — stay in
25
+ * the payload's own frame, in the body. A card that hoisted them would present
26
+ * one row of buttons that act on two different things.
27
+ */
28
+ import { StatusBadge, type Status } from "@elabs-ai/components-ui";
29
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
30
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
31
+
32
+ export interface ToolResultCardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
33
+ /** Headline of the produced artifact (`text-subtitle`). */
34
+ title?: ReactNode;
35
+ /** The business summary line under the title (`text-meta`), not JSON. */
36
+ summary?: ReactNode;
37
+ /** Canonical execution status (closed enum); renders a `StatusBadge`. */
38
+ status?: Status;
39
+ /**
40
+ * Controls scoped to the whole artifact — expand, download, open. Rendered in
41
+ * the header row between the title and the status.
42
+ *
43
+ * A `ReactNode` slot, not a `role="toolbar"`: that role promises roving-tabindex
44
+ * traversal, which this row does not implement. Keep the payload's own controls
45
+ * out of here (see the note at the top of this file).
46
+ */
47
+ actions?: ReactNode;
48
+ /**
49
+ * The technical view, kept behind disclosure — pass a default-collapsed
50
+ * `<ToolDetails>` holding `ToolInput`/`ToolOutput` (research 10 §B.5).
51
+ */
52
+ details?: ReactNode;
53
+ }
54
+
55
+ /** The produced artifact IS the headline; pass it as `children`. */
56
+ export const ToolResultCard = forwardRef<HTMLDivElement, ToolResultCardProps>(
57
+ function ToolResultCard(
58
+ { title, summary, status, actions, details, className, children, ...props },
59
+ ref,
60
+ ) {
61
+ const hasHeader = title != null || summary != null || status != null || actions != null;
62
+ return (
63
+ <div
64
+ ref={ref}
65
+ className={cn(
66
+ "not-prose w-full space-y-3 rounded-lg bg-surface-elevated p-4 shadow-sm",
67
+ className,
68
+ )}
69
+ {...props}
70
+ >
71
+ {hasHeader ? (
72
+ <div className="space-y-0.5">
73
+ <div className="flex items-center justify-between gap-3">
74
+ {title ? <h3 className="min-w-0 truncate text-subtitle">{title}</h3> : null}
75
+ {actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
76
+ {status ? <StatusBadge className="shrink-0" size="sm" status={status} /> : null}
77
+ </div>
78
+ {summary ? <p className="text-meta text-muted-foreground">{summary}</p> : null}
79
+ </div>
80
+ ) : null}
81
+ {children}
82
+ {details}
83
+ </div>
84
+ );
85
+ },
86
+ );
@@ -0,0 +1,60 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import {
3
+ Tool,
4
+ ToolContent,
5
+ ToolDetails,
6
+ ToolHeader,
7
+ ToolInput,
8
+ ToolOutput,
9
+ statusFromToolState,
10
+ } from "./tool";
11
+ const meta = { title: "AI/Tool", component: Tool, parameters: { layout: "padded" } } satisfies Meta<
12
+ typeof Tool
13
+ >;
14
+ export default meta;
15
+ type Story = StoryObj<typeof meta>;
16
+ // JSON-behind-disclosure is the package default (#192, research 10 §B.5): the
17
+ // header carries the business summary; the raw payload sits inside the
18
+ // default-COLLAPSED ToolDetails, one expand away.
19
+ export const Default: Story = {
20
+ render: () => (
21
+ <Tool defaultOpen className="max-w-prose">
22
+ <ToolHeader type="tool-search_web" state="output-available" summary="3 results found" />
23
+ <ToolContent>
24
+ <ToolDetails>
25
+ <ToolInput input={{ query: "platform status page" }} />
26
+ <ToolOutput
27
+ output={{ hits: 3, sources: ["status.example.com", "community", "docs"] }}
28
+ errorText={undefined}
29
+ />
30
+ </ToolDetails>
31
+ </ToolContent>
32
+ </Tool>
33
+ ),
34
+ };
35
+ // STREAMING — the call is still running (`input-streaming`). `ToolOutput`
36
+ // derives `isStreaming` from the SAME `statusFromToolState` mapping the header
37
+ // badge uses (no second source of truth), and renders a layout-shaped
38
+ // skeleton in the Result slot instead of `null` — and never the error branch,
39
+ // even though this demo call will eventually resolve to `errorText`.
40
+ export const Streaming: Story = {
41
+ name: "ToolOutput isStreaming",
42
+ render: () => {
43
+ const state = "input-streaming" as const;
44
+ return (
45
+ <Tool defaultOpen className="max-w-prose">
46
+ <ToolHeader type="tool-search_web" state={state} summary="Searching…" />
47
+ <ToolContent>
48
+ <ToolDetails defaultOpen>
49
+ <ToolInput input={{ query: "platform status page" }} />
50
+ <ToolOutput
51
+ output={undefined}
52
+ errorText={undefined}
53
+ isStreaming={statusFromToolState(state) === "pending"}
54
+ />
55
+ </ToolDetails>
56
+ </ToolContent>
57
+ </Tool>
58
+ );
59
+ },
60
+ };
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { Tool, ToolContent, ToolDetails, ToolHeader, ToolInput, ToolOutput } from "./tool";
5
+
6
+ const renderTool = () =>
7
+ render(
8
+ <Tool defaultOpen>
9
+ <ToolHeader type="tool-search_web" state="output-available" summary="3 results found" />
10
+ <ToolContent>
11
+ <ToolDetails>
12
+ <ToolInput input={{ query: "platform status page" }} />
13
+ </ToolDetails>
14
+ </ToolContent>
15
+ </Tool>,
16
+ );
17
+
18
+ describe("Tool JSON-behind-disclosure (#192, research 10 §B.5)", () => {
19
+ it("shows the business summary in the header and keeps ToolDetails COLLAPSED by default", () => {
20
+ renderTool();
21
+ expect(screen.getByText("3 results found")).toBeInTheDocument();
22
+ expect(screen.getByRole("button", { name: /show technical details/i })).toBeInTheDocument();
23
+ // The JSON payload is NOT the headline — hidden until opted in.
24
+ expect(screen.queryByText("Parameters")).not.toBeInTheDocument();
25
+ });
26
+
27
+ it("reveals the technical view on demand", async () => {
28
+ const user = userEvent.setup();
29
+ renderTool();
30
+ await user.click(screen.getByRole("button", { name: /show technical details/i }));
31
+ expect(screen.getByText("Parameters")).toBeInTheDocument();
32
+ });
33
+ });
34
+
35
+ describe("ToolOutput isStreaming (#269, loading-states.md)", () => {
36
+ it("renders a skeleton (not null) while streaming with no output yet", () => {
37
+ const { container } = render(
38
+ <ToolOutput output={undefined} errorText={undefined} isStreaming />,
39
+ );
40
+ expect(screen.getByText("Result")).toBeInTheDocument();
41
+ const statuses = container.querySelectorAll('[role="status"]');
42
+ expect(statuses).toHaveLength(1);
43
+ expect(statuses[0]).toHaveAttribute("aria-live", "polite");
44
+ });
45
+
46
+ it("never shows the error branch while streaming, even with a stale errorText", () => {
47
+ render(<ToolOutput output={undefined} errorText="boom" isStreaming />);
48
+ expect(screen.getByText("Result")).toBeInTheDocument();
49
+ expect(screen.queryByText("Error")).not.toBeInTheDocument();
50
+ expect(screen.queryByText("boom")).not.toBeInTheDocument();
51
+ });
52
+
53
+ it("shows the terminal error branch once settled (not streaming)", () => {
54
+ render(<ToolOutput output={undefined} errorText="boom" />);
55
+ expect(screen.getByText("Error")).toBeInTheDocument();
56
+ expect(screen.getByText("boom")).toBeInTheDocument();
57
+ });
58
+
59
+ it("still renders nothing when idle (no output, no error, not streaming)", () => {
60
+ const { container } = render(<ToolOutput output={undefined} errorText={undefined} />);
61
+ expect(container).toBeEmptyDOMElement();
62
+ });
63
+ });
package/src/tool.tsx ADDED
@@ -0,0 +1,235 @@
1
+ "use client";
2
+
3
+ import { Skeleton, StatusBadge, useLocale, type Status } from "@elabs-ai/components-ui";
4
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
5
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
6
+ import type { DynamicToolUIPart, ToolUIPart } from "ai";
7
+ import { ChevronDownIcon, WrenchIcon } from "lucide-react";
8
+ import type { ComponentProps, ReactNode } from "react";
9
+ import { isValidElement } from "react";
10
+
11
+ import { CodeBlock } from "./code-block";
12
+
13
+ export type ToolProps = ComponentProps<typeof Collapsible>;
14
+
15
+ export const Tool = ({ className, ...props }: ToolProps) => (
16
+ <Collapsible
17
+ className={cn("group not-prose mb-4 w-full rounded-md border", className)}
18
+ {...props}
19
+ />
20
+ );
21
+
22
+ export type ToolPart = ToolUIPart | DynamicToolUIPart;
23
+
24
+ export type ToolHeaderProps = {
25
+ title?: string;
26
+ /**
27
+ * The business summary line ("3 documents found", "8 rows reconciled"),
28
+ * shown beside the name + StatusBadge (#192, research 10 §B.5). Falls back
29
+ * to nothing — the derived tool name still labels the row.
30
+ */
31
+ summary?: ReactNode;
32
+ className?: string;
33
+ } & (
34
+ | { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
35
+ | {
36
+ type: DynamicToolUIPart["type"];
37
+ state: DynamicToolUIPart["state"];
38
+ toolName: string;
39
+ }
40
+ );
41
+
42
+ /**
43
+ * Map the AI-SDK `ToolUIPart` 7-state machine onto the canonical `Status`
44
+ * enum (#189, research 10 §B.1 mapping a). Lives here — not in `@elabs-ai/components-ui` —
45
+ * because it is typed against the SDK union; the `ai` import stays TYPES-ONLY
46
+ * (D6, gate-enforced by `pnpm ai:types-only`).
47
+ */
48
+ export const statusFromToolState = (state: ToolPart["state"]): Status => {
49
+ switch (state) {
50
+ case "input-streaming":
51
+ return "pending";
52
+ case "input-available":
53
+ return "running";
54
+ case "approval-requested":
55
+ return "awaiting-approval";
56
+ case "approval-responded":
57
+ return "running";
58
+ case "output-available":
59
+ return "complete";
60
+ case "output-denied":
61
+ return "denied";
62
+ case "output-error":
63
+ return "failed";
64
+ }
65
+ };
66
+
67
+ /** Same signature as before #189; renders the canonical StatusBadge. */
68
+ export const getStatusBadge = (status: ToolPart["state"]) => (
69
+ <StatusBadge status={statusFromToolState(status)} />
70
+ );
71
+
72
+ export const ToolHeader = ({
73
+ className,
74
+ title,
75
+ summary,
76
+ type,
77
+ state,
78
+ toolName,
79
+ ...props
80
+ }: ToolHeaderProps) => {
81
+ const derivedName = type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
82
+
83
+ return (
84
+ <CollapsibleTrigger
85
+ className={cn("flex w-full items-center justify-between gap-4 p-3", className)}
86
+ {...props}
87
+ >
88
+ <div className="flex min-w-0 items-center gap-2">
89
+ <WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
90
+ <span className="shrink-0 text-body font-medium">{title ?? derivedName}</span>
91
+ {getStatusBadge(state)}
92
+ {summary ? (
93
+ <span className="truncate text-meta text-muted-foreground">{summary}</span>
94
+ ) : null}
95
+ </div>
96
+ <ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
97
+ </CollapsibleTrigger>
98
+ );
99
+ };
100
+
101
+ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
102
+
103
+ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
104
+ <CollapsibleContent
105
+ className={cn(
106
+ "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)]",
107
+ className,
108
+ )}
109
+ {...props}
110
+ />
111
+ );
112
+
113
+ export type ToolDetailsProps = ComponentProps<typeof Collapsible> & {
114
+ /** The disclosure label. */
115
+ label?: ReactNode;
116
+ };
117
+
118
+ /**
119
+ * The technical view, behind disclosure — the PACKAGE DEFAULT for tool JSON
120
+ * (#192, research 10 §B.5): a nested collapsible, COLLAPSED by default,
121
+ * holding `ToolInput`/`ToolOutput`. The header carries the business `summary`;
122
+ * the raw payload is one expand away, never the headline.
123
+ */
124
+ export const ToolDetails = ({
125
+ className,
126
+ label = "Show technical details",
127
+ defaultOpen = false,
128
+ children,
129
+ ...props
130
+ }: ToolDetailsProps) => (
131
+ <Collapsible
132
+ className={cn("group/tool-details not-prose", className)}
133
+ defaultOpen={defaultOpen}
134
+ {...props}
135
+ >
136
+ <CollapsibleTrigger className="flex items-center gap-1 rounded-sm text-meta text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
137
+ <ChevronDownIcon className="size-3.5 transition-transform group-data-[state=open]/tool-details:rotate-180" />
138
+ {label}
139
+ </CollapsibleTrigger>
140
+ <CollapsibleContent className="mt-3 space-y-4">{children}</CollapsibleContent>
141
+ </Collapsible>
142
+ );
143
+
144
+ export type ToolInputProps = ComponentProps<"div"> & {
145
+ input: ToolPart["input"];
146
+ };
147
+
148
+ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
149
+ <div className={cn("space-y-2 overflow-hidden", className)} {...props}>
150
+ <h4 className="text-meta uppercase text-muted-foreground">Parameters</h4>
151
+ <div className="rounded-md bg-muted/50">
152
+ <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
153
+ </div>
154
+ </div>
155
+ );
156
+
157
+ export type ToolOutputProps = ComponentProps<"div"> & {
158
+ /**
159
+ * The tool result. Objects/strings render as JSON inside the technical view.
160
+ *
161
+ * @deprecated Passing a pre-rendered **React element** here (the "rich
162
+ * output under a muted Result heading" path) is deprecated since #192
163
+ * (research 10 §B.5): a produced artifact is the HEADLINE, not a technical
164
+ * detail — host it in a `<ToolResultCard>` and keep `ToolOutput` for the
165
+ * JSON payload behind `<ToolDetails>`. The element path still renders for
166
+ * existing consumers (e.g. the copy-owned `ai-chart` registry block) but
167
+ * will be removed in a future release.
168
+ */
169
+ output: ToolPart["output"];
170
+ errorText: ToolPart["errorText"];
171
+ /**
172
+ * The call has not produced output yet (loading-states.md `isStreaming`) —
173
+ * derive it from the existing `statusFromToolState(state)` mapping
174
+ * (`"input-streaming"`/`"input-available"` → not yet `"complete"`/`"failed"`)
175
+ * rather than a second source of truth. While true and no `output`/
176
+ * `errorText` has arrived, renders a layout-shaped skeleton in the Result
177
+ * slot instead of `null`, so the technical view reserves its space. A
178
+ * still-running call is never a terminal failure — the error branch is
179
+ * suppressed while `isStreaming` per the loading-states.md error rule, even
180
+ * if a stale `errorText` is still set from a previous render.
181
+ * @default false
182
+ */
183
+ isStreaming?: boolean;
184
+ };
185
+
186
+ export const ToolOutput = ({
187
+ className,
188
+ output,
189
+ errorText,
190
+ isStreaming = false,
191
+ ...props
192
+ }: ToolOutputProps) => {
193
+ const { t } = useLocale();
194
+
195
+ if (!(output || errorText || isStreaming)) {
196
+ return null;
197
+ }
198
+
199
+ const showError = !isStreaming && Boolean(errorText);
200
+ const pending = isStreaming && !output && !errorText;
201
+
202
+ let Output = <div>{output as ReactNode}</div>;
203
+
204
+ if (typeof output === "object" && !isValidElement(output)) {
205
+ Output = <CodeBlock code={JSON.stringify(output, null, 2)} language="json" />;
206
+ } else if (typeof output === "string") {
207
+ Output = <CodeBlock code={output} language="json" />;
208
+ }
209
+
210
+ return (
211
+ <div className={cn("space-y-2", className)} {...props}>
212
+ <h4 className="text-meta uppercase text-muted-foreground">
213
+ {showError ? "Error" : "Result"}
214
+ </h4>
215
+ {pending ? (
216
+ <div className="space-y-2 rounded-md bg-muted/50 p-3" role="status" aria-live="polite">
217
+ <span className="sr-only">{t("loading")}</span>
218
+ <Skeleton className="h-4 w-3/4" />
219
+ <Skeleton className="h-4 w-full" />
220
+ <Skeleton className="h-4 w-1/2" />
221
+ </div>
222
+ ) : (
223
+ <div
224
+ className={cn(
225
+ "overflow-x-auto rounded-md text-caption [&_table]:w-full",
226
+ showError ? "bg-destructive/10 text-destructive" : "bg-muted/50 text-foreground",
227
+ )}
228
+ >
229
+ {showError && <div>{errorText}</div>}
230
+ {Output}
231
+ </div>
232
+ )}
233
+ </div>
234
+ );
235
+ };
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import type { NodeToolbar as FlowNodeToolbar } from "@xyflow/react";
4
+ import type { ComponentProps } from "react";
5
+ import { Suspense } from "react";
6
+
7
+ import { lazyFlowPart } from "./_flow-lazy";
8
+
9
+ export type NodeToolbarProps = ComponentProps<typeof FlowNodeToolbar>;
10
+
11
+ /** React Flow lives behind a dynamic import — see `_flow-lazy.ts` and ADR 0019. */
12
+ const NodeToolbarImpl = lazyFlowPart<NodeToolbarProps>((m) => m.Toolbar);
13
+
14
+ /**
15
+ * The floating control strip attached to a selected canvas NODE — it portals out
16
+ * of the node and follows it, and it is meaningless outside a `<Canvas>`.
17
+ *
18
+ * Named `NodeToolbar` (after the React Flow primitive it wraps) because
19
+ * `Toolbar` now means the general-purpose WAI-ARIA toolbar in
20
+ * `@elabs-ai/components-ui`: a keyboard row with a roving tabindex.
21
+ * The two solve unrelated problems, and the collision made "use the Toolbar"
22
+ * ambiguous at every call site.
23
+ */
24
+ export const NodeToolbar = (props: NodeToolbarProps) => (
25
+ // `NodeToolbar` only ever renders inside a `<Canvas>` (it portals out of the
26
+ // node), so the engine chunk is already in cache; it is wrapped anyway because
27
+ // it is independently barrel-exported.
28
+ <Suspense fallback={null}>
29
+ {/* The strip portals out of the node it belongs to, so it is nowhere near
30
+ that node in the DOM — a stable selector is the only way a consumer or a
31
+ test can find it. Overridable, like every other `data-slot`. */}
32
+ <NodeToolbarImpl data-slot="node-toolbar" {...props} />
33
+ </Suspense>
34
+ );
35
+
36
+ /**
37
+ * @deprecated Renamed to {@link NodeToolbar}. `Toolbar` is now the WAI-ARIA
38
+ * toolbar in `@elabs-ai/components-ui`. This alias will be removed in
39
+ * the next major.
40
+ */
41
+ export const Toolbar = NodeToolbar;
42
+
43
+ /** @deprecated Renamed to {@link NodeToolbarProps}. */
44
+ export type ToolbarProps = NodeToolbarProps;
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";
6
+ import type { ComponentProps, ReactNode } from "react";
7
+ import { createContext, useCallback, useContext, useMemo } from "react";
8
+
9
+ type TranscriptionSegment = TranscriptionResult["segments"][number];
10
+
11
+ interface TranscriptionContextValue {
12
+ segments: TranscriptionSegment[];
13
+ currentTime: number;
14
+ onTimeUpdate: (time: number) => void;
15
+ onSeek?: (time: number) => void;
16
+ }
17
+
18
+ const TranscriptionContext = createContext<TranscriptionContextValue | null>(null);
19
+
20
+ const useTranscription = () => {
21
+ const context = useContext(TranscriptionContext);
22
+ if (!context) {
23
+ throw new Error("Transcription components must be used within Transcription");
24
+ }
25
+ return context;
26
+ };
27
+
28
+ export type TranscriptionProps = Omit<ComponentProps<"div">, "children"> & {
29
+ segments: TranscriptionSegment[];
30
+ currentTime?: number;
31
+ onSeek?: (time: number) => void;
32
+ children: (segment: TranscriptionSegment, index: number) => ReactNode;
33
+ };
34
+
35
+ export const Transcription = ({
36
+ segments,
37
+ currentTime: externalCurrentTime,
38
+ onSeek,
39
+ className,
40
+ children,
41
+ ...props
42
+ }: TranscriptionProps) => {
43
+ const [currentTime, setCurrentTime] = useControllableState({
44
+ defaultProp: 0,
45
+ onChange: onSeek,
46
+ prop: externalCurrentTime,
47
+ });
48
+
49
+ const contextValue = useMemo(
50
+ () => ({ currentTime, onSeek, onTimeUpdate: setCurrentTime, segments }),
51
+ [currentTime, onSeek, setCurrentTime, segments],
52
+ );
53
+
54
+ return (
55
+ <TranscriptionContext.Provider value={contextValue}>
56
+ <div
57
+ className={cn("flex flex-wrap gap-1 text-sm leading-relaxed", className)}
58
+ data-slot="transcription"
59
+ {...props}
60
+ >
61
+ {segments
62
+ .filter((segment) => segment.text.trim())
63
+ .map((segment, index) => children(segment, index))}
64
+ </div>
65
+ </TranscriptionContext.Provider>
66
+ );
67
+ };
68
+
69
+ export type TranscriptionSegmentProps = ComponentProps<"button"> & {
70
+ segment: TranscriptionSegment;
71
+ index: number;
72
+ };
73
+
74
+ export const TranscriptionSegment = ({
75
+ segment,
76
+ index,
77
+ className,
78
+ onClick,
79
+ ...props
80
+ }: TranscriptionSegmentProps) => {
81
+ const { currentTime, onSeek } = useTranscription();
82
+
83
+ const isActive = currentTime >= segment.startSecond && currentTime < segment.endSecond;
84
+ const isPast = currentTime >= segment.endSecond;
85
+
86
+ const handleClick = useCallback(
87
+ (event: React.MouseEvent<HTMLButtonElement>) => {
88
+ if (onSeek) {
89
+ onSeek(segment.startSecond);
90
+ }
91
+ onClick?.(event);
92
+ },
93
+ [onSeek, segment.startSecond, onClick],
94
+ );
95
+
96
+ return (
97
+ <button
98
+ className={cn(
99
+ "inline text-start",
100
+ // #399 — the active word is transcript TEXT: `-text` rung, not the fill.
101
+ isActive && "text-primary-text",
102
+ isPast && "text-muted-foreground",
103
+ !(isActive || isPast) && "text-muted-foreground/60",
104
+ onSeek && "cursor-pointer hover:text-foreground",
105
+ !onSeek && "cursor-default",
106
+ className,
107
+ )}
108
+ data-active={isActive}
109
+ data-index={index}
110
+ data-slot="transcription-segment"
111
+ onClick={handleClick}
112
+ type="button"
113
+ {...props}
114
+ >
115
+ {segment.text}
116
+ </button>
117
+ );
118
+ };