@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.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- 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
|
+
};
|
package/src/toolbar.tsx
ADDED
|
@@ -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
|
+
};
|