@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,62 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { DownloadIcon } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
Artifact,
|
|
5
|
+
ArtifactAction,
|
|
6
|
+
ArtifactActions,
|
|
7
|
+
ArtifactClose,
|
|
8
|
+
ArtifactContent,
|
|
9
|
+
ArtifactDescription,
|
|
10
|
+
ArtifactHeader,
|
|
11
|
+
ArtifactTitle,
|
|
12
|
+
} from "./artifact";
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: "AI/Artifact",
|
|
16
|
+
component: Artifact,
|
|
17
|
+
parameters: { layout: "padded" },
|
|
18
|
+
} satisfies Meta<typeof Artifact>;
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
render: () => (
|
|
24
|
+
<Artifact className="max-w-lg">
|
|
25
|
+
<ArtifactHeader>
|
|
26
|
+
<div>
|
|
27
|
+
<ArtifactTitle>Q3 retention cohort</ArtifactTitle>
|
|
28
|
+
<ArtifactDescription>Generated report</ArtifactDescription>
|
|
29
|
+
</div>
|
|
30
|
+
<ArtifactActions>
|
|
31
|
+
<ArtifactAction icon={DownloadIcon} tooltip="Download" />
|
|
32
|
+
<ArtifactClose />
|
|
33
|
+
</ArtifactActions>
|
|
34
|
+
</ArtifactHeader>
|
|
35
|
+
<ArtifactContent>
|
|
36
|
+
Enterprise churn fell 0.4pp to 1.8% this quarter, driven by the renewal-desk rollout.
|
|
37
|
+
</ArtifactContent>
|
|
38
|
+
</Artifact>
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// LOADING — the body hasn't arrived yet (loading-states.md `loading`).
|
|
43
|
+
// `ArtifactContent` renders a layout-shaped skeleton while `ArtifactHeader` /
|
|
44
|
+
// `ArtifactTitle` keep rendering normally, so the artifact's identity stays
|
|
45
|
+
// visible while its content streams in.
|
|
46
|
+
export const Loading: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<Artifact className="max-w-lg" loading>
|
|
49
|
+
<ArtifactHeader>
|
|
50
|
+
<div>
|
|
51
|
+
<ArtifactTitle>Q3 retention cohort</ArtifactTitle>
|
|
52
|
+
<ArtifactDescription>Generating report…</ArtifactDescription>
|
|
53
|
+
</div>
|
|
54
|
+
<ArtifactActions>
|
|
55
|
+
<ArtifactAction icon={DownloadIcon} tooltip="Download" disabled />
|
|
56
|
+
<ArtifactClose />
|
|
57
|
+
</ArtifactActions>
|
|
58
|
+
</ArtifactHeader>
|
|
59
|
+
<ArtifactContent>This content is not shown while loading.</ArtifactContent>
|
|
60
|
+
</Artifact>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact.test.tsx — smoke + naming lock for the generated-artifact frame (#59).
|
|
3
|
+
*
|
|
4
|
+
* `Artifact` is the panel a generated document/preview lands in. Every control
|
|
5
|
+
* in its header is ICON-ONLY, so the assertions that matter are accessible
|
|
6
|
+
* names: `ArtifactClose` must always announce "Close", and `ArtifactAction`
|
|
7
|
+
* must fall back to its tooltip text when no explicit label is given — an
|
|
8
|
+
* unnamed icon button is a WCAG 4.1.2 failure and is invisible in review.
|
|
9
|
+
*
|
|
10
|
+
* The frame's `border` is also locked here: it reads redundant on a light theme
|
|
11
|
+
* (the recess + shadow already lift it) but at high decoration/decoration the
|
|
12
|
+
* shadow is zeroed and the border becomes the SOLE structural cue — the
|
|
13
|
+
* canonical "redundant-on-light, sole-cue-under-decoration" KEEP (#194).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it, vi } from "vitest";
|
|
16
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
17
|
+
import { SaveIcon } from "lucide-react";
|
|
18
|
+
import {
|
|
19
|
+
Artifact,
|
|
20
|
+
ArtifactAction,
|
|
21
|
+
ArtifactActions,
|
|
22
|
+
ArtifactClose,
|
|
23
|
+
ArtifactContent,
|
|
24
|
+
ArtifactDescription,
|
|
25
|
+
ArtifactHeader,
|
|
26
|
+
ArtifactTitle,
|
|
27
|
+
} from "./artifact";
|
|
28
|
+
|
|
29
|
+
describe("Artifact — frame", () => {
|
|
30
|
+
it("renders header, title, description and content together", () => {
|
|
31
|
+
render(
|
|
32
|
+
<Artifact>
|
|
33
|
+
<ArtifactHeader>
|
|
34
|
+
<ArtifactTitle>Q3 forecast</ArtifactTitle>
|
|
35
|
+
<ArtifactDescription>Generated from the pipeline export</ArtifactDescription>
|
|
36
|
+
</ArtifactHeader>
|
|
37
|
+
<ArtifactContent>body</ArtifactContent>
|
|
38
|
+
</Artifact>,
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByText("Q3 forecast")).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByText("Generated from the pipeline export")).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByText("body")).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps its border (#194 — the sole structural cue once decoration zeroes the shadow)", () => {
|
|
46
|
+
const { container } = render(<Artifact>x</Artifact>);
|
|
47
|
+
expect(container.firstChild).toHaveClass("border");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("merges a caller className without dropping the frame", () => {
|
|
51
|
+
const { container } = render(<Artifact className="extra">x</Artifact>);
|
|
52
|
+
expect(container.firstChild).toHaveClass("extra");
|
|
53
|
+
expect(container.firstChild).toHaveClass("rounded-lg");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("spreads arbitrary props onto the root (id/aria-*)", () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<Artifact id="a1" aria-label="Forecast artifact">
|
|
59
|
+
x
|
|
60
|
+
</Artifact>,
|
|
61
|
+
);
|
|
62
|
+
expect(container.firstChild).toHaveAttribute("id", "a1");
|
|
63
|
+
expect(screen.getByLabelText("Forecast artifact")).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("ArtifactClose — icon-only naming", () => {
|
|
68
|
+
it('always announces "Close" even though it renders only a glyph', () => {
|
|
69
|
+
render(<ArtifactClose />);
|
|
70
|
+
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("calls onClick", () => {
|
|
74
|
+
const onClick = vi.fn();
|
|
75
|
+
render(<ArtifactClose onClick={onClick} />);
|
|
76
|
+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
77
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is type="button" so it cannot submit a surrounding form', () => {
|
|
81
|
+
render(<ArtifactClose />);
|
|
82
|
+
expect(screen.getByRole("button", { name: "Close" })).toHaveAttribute("type", "button");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("ArtifactAction — icon-only naming", () => {
|
|
87
|
+
it("takes its accessible name from label", () => {
|
|
88
|
+
render(<ArtifactAction icon={SaveIcon} label="Save to workspace" />);
|
|
89
|
+
expect(screen.getByRole("button", { name: "Save to workspace" })).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("falls back to the tooltip text when no label is given (never an unnamed button)", () => {
|
|
93
|
+
render(<ArtifactAction icon={SaveIcon} tooltip="Save" />);
|
|
94
|
+
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders inside a tooltip trigger without losing the name", () => {
|
|
98
|
+
render(
|
|
99
|
+
<ArtifactActions>
|
|
100
|
+
<ArtifactAction icon={SaveIcon} tooltip="Save" label="Save to workspace" />
|
|
101
|
+
</ArtifactActions>,
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByRole("button", { name: "Save to workspace" })).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("calls onClick", () => {
|
|
107
|
+
const onClick = vi.fn();
|
|
108
|
+
render(<ArtifactAction icon={SaveIcon} label="Save" onClick={onClick} />);
|
|
109
|
+
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
110
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/artifact.tsx
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, Skeleton, useLocale } from "@elabs-ai/components-ui";
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@elabs-ai/components-ui";
|
|
5
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
|
+
import type { LucideIcon } from "lucide-react";
|
|
7
|
+
import { XIcon } from "lucide-react";
|
|
8
|
+
import type { ComponentProps, HTMLAttributes } from "react";
|
|
9
|
+
import { createContext, use, useMemo } from "react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shared `loading` state (composition-patterns — lifted into the provider):
|
|
13
|
+
* `Artifact` is the ONLY place that knows whether the body has arrived;
|
|
14
|
+
* `ArtifactContent` reads the interface and renders its skeleton, while
|
|
15
|
+
* `ArtifactHeader`/`ArtifactTitle` (ordinary children of `Artifact`) keep
|
|
16
|
+
* rendering normally, untouched by the not-ready state.
|
|
17
|
+
*/
|
|
18
|
+
const ArtifactContext = createContext<{ loading: boolean }>({ loading: false });
|
|
19
|
+
|
|
20
|
+
export type ArtifactProps = HTMLAttributes<HTMLDivElement> & {
|
|
21
|
+
/**
|
|
22
|
+
* The artifact body has not arrived yet (loading-states.md loading signal).
|
|
23
|
+
* ArtifactContent renders a layout-shaped skeleton instead of its
|
|
24
|
+
* children; ArtifactHeader/ArtifactTitle keep rendering as passed.
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
loading?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const Artifact = ({ className, loading = false, children, ...props }: ArtifactProps) => {
|
|
31
|
+
const contextValue = useMemo(() => ({ loading }), [loading]);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ArtifactContext.Provider value={contextValue}>
|
|
35
|
+
<div
|
|
36
|
+
// KEEP this border (#194, research 08 §G.5): it is redundant on light
|
|
37
|
+
// (the #187 page-ground recess + shadow already lift the frame), but under
|
|
38
|
+
// high decoration the shadow is zeroed and the border becomes the
|
|
39
|
+
// SOLE structural cue — the canonical "redundant-on-light, sole-cue-under-
|
|
40
|
+
// decoration" KEEP example. Do not drop it in a border-noise pass.
|
|
41
|
+
className={cn(
|
|
42
|
+
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</div>
|
|
49
|
+
</ArtifactContext.Provider>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
|
|
54
|
+
|
|
55
|
+
export const ArtifactHeader = ({ className, ...props }: ArtifactHeaderProps) => (
|
|
56
|
+
<div
|
|
57
|
+
className={cn("flex items-center justify-between border-b bg-muted/50 px-4 py-3", className)}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export type ArtifactCloseProps = ComponentProps<typeof Button>;
|
|
63
|
+
|
|
64
|
+
export const ArtifactClose = ({
|
|
65
|
+
className,
|
|
66
|
+
children,
|
|
67
|
+
size = "sm",
|
|
68
|
+
variant = "ghost",
|
|
69
|
+
...props
|
|
70
|
+
}: ArtifactCloseProps) => (
|
|
71
|
+
<Button
|
|
72
|
+
className={cn("size-8 p-0 text-muted-foreground hover:text-foreground", className)}
|
|
73
|
+
size={size}
|
|
74
|
+
type="button"
|
|
75
|
+
variant={variant}
|
|
76
|
+
{...props}
|
|
77
|
+
>
|
|
78
|
+
{children ?? <XIcon className="size-4" />}
|
|
79
|
+
<span className="sr-only">Close</span>
|
|
80
|
+
</Button>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
|
84
|
+
|
|
85
|
+
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
|
86
|
+
<p className={cn("font-medium text-foreground text-sm", className)} {...props} />
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
|
90
|
+
|
|
91
|
+
export const ArtifactDescription = ({ className, ...props }: ArtifactDescriptionProps) => (
|
|
92
|
+
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
|
|
96
|
+
|
|
97
|
+
export const ArtifactActions = ({ className, ...props }: ArtifactActionsProps) => (
|
|
98
|
+
<div className={cn("flex items-center gap-1", className)} {...props} />
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
|
102
|
+
tooltip?: string;
|
|
103
|
+
label?: string;
|
|
104
|
+
icon?: LucideIcon;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const ArtifactAction = ({
|
|
108
|
+
tooltip,
|
|
109
|
+
label,
|
|
110
|
+
icon: Icon,
|
|
111
|
+
children,
|
|
112
|
+
className,
|
|
113
|
+
size = "sm",
|
|
114
|
+
variant = "ghost",
|
|
115
|
+
...props
|
|
116
|
+
}: ArtifactActionProps) => {
|
|
117
|
+
const button = (
|
|
118
|
+
<Button
|
|
119
|
+
className={cn("size-8 p-0 text-muted-foreground hover:text-foreground", className)}
|
|
120
|
+
size={size}
|
|
121
|
+
type="button"
|
|
122
|
+
variant={variant}
|
|
123
|
+
{...props}
|
|
124
|
+
>
|
|
125
|
+
{Icon ? <Icon className="size-4" /> : children}
|
|
126
|
+
<span className="sr-only">{label || tooltip}</span>
|
|
127
|
+
</Button>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (tooltip) {
|
|
131
|
+
return (
|
|
132
|
+
<TooltipProvider>
|
|
133
|
+
<Tooltip>
|
|
134
|
+
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
135
|
+
<TooltipContent>
|
|
136
|
+
<p>{tooltip}</p>
|
|
137
|
+
</TooltipContent>
|
|
138
|
+
</Tooltip>
|
|
139
|
+
</TooltipProvider>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return button;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
|
|
147
|
+
|
|
148
|
+
export const ArtifactContent = ({ className, children, ...props }: ArtifactContentProps) => {
|
|
149
|
+
const { loading } = use(ArtifactContext);
|
|
150
|
+
const { t } = useLocale();
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className={cn("flex-1 overflow-auto p-4", className)} {...props}>
|
|
154
|
+
{loading ? (
|
|
155
|
+
<div className="space-y-2" role="status" aria-live="polite">
|
|
156
|
+
<span className="sr-only">{t("loading")}</span>
|
|
157
|
+
<Skeleton className="h-4 w-3/4" />
|
|
158
|
+
<Skeleton className="h-4 w-full" />
|
|
159
|
+
<Skeleton className="h-4 w-5/6" />
|
|
160
|
+
<Skeleton className="h-4 w-1/2" />
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
children
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { AssetPreview } from "./asset-preview";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "AI/AssetPreview",
|
|
6
|
+
component: AssetPreview,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
// The real host is the 20rem context rail — preview at that width.
|
|
12
|
+
<div className="max-w-80">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
} satisfies Meta<typeof AssetPreview>;
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
const BOARD_NOTE = `# Board note — Q3
|
|
22
|
+
|
|
23
|
+
Revenue grew **12.4% QoQ**, led by EMEA.
|
|
24
|
+
|
|
25
|
+
## Highlights
|
|
26
|
+
|
|
27
|
+
- EMEA $18.4M (+14%)
|
|
28
|
+
- Americas $21.2M (+8%)
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/** Markdown renders as a DOCUMENT via MarkdownView (the ASSET-2 fix) — toggle Raw for source. */
|
|
32
|
+
export const Markdown: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
asset: { id: "board-note", name: "board-note.md", type: "markdown", content: BOARD_NOTE },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Code/SQL renders via CodeBlock (Shiki), soft-wrapped for the narrow rail. */
|
|
39
|
+
export const Sql: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
asset: {
|
|
42
|
+
id: "variance-query",
|
|
43
|
+
name: "variance-check.sql",
|
|
44
|
+
type: "sql",
|
|
45
|
+
content: "SELECT region, total\nFROM finance.revenue\nWHERE quarter = 'Q3';",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** CSV renders as a small table with a row-count summary. */
|
|
51
|
+
export const Csv: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
asset: {
|
|
54
|
+
id: "revenue",
|
|
55
|
+
name: "revenue.csv",
|
|
56
|
+
type: "csv",
|
|
57
|
+
content: "region,q2,q3\nEMEA,16.1,18.4\nAmericas,19.6,21.2\nAPAC,7.2,8.6",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Raw mode as the initial state. */
|
|
63
|
+
export const RawByDefault: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
asset: { id: "board-note", name: "board-note.md", type: "markdown", content: BOARD_NOTE },
|
|
66
|
+
defaultMode: "raw",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A format this package cannot parse, drawn by an injected renderer.
|
|
72
|
+
*
|
|
73
|
+
* `renderPreview` is how a PDF, a spreadsheet or a video reaches the rail:
|
|
74
|
+
* `@elabs-ai/components-viewer` is a layer PEER of this package, so
|
|
75
|
+
* neither may import the other (ADR 0024 §6) and the consuming app owns the
|
|
76
|
+
* edge — `renderPreview={(asset) => asset.source ? <FileViewer source={asset.source} /> : null}`.
|
|
77
|
+
* The stand-in below keeps this story free of that import.
|
|
78
|
+
*
|
|
79
|
+
* A renderer that returns `null` declines, so every built-in format above is
|
|
80
|
+
* untouched by an injection that only knows PDFs.
|
|
81
|
+
*/
|
|
82
|
+
export const InjectedRenderer: Story = {
|
|
83
|
+
args: {
|
|
84
|
+
asset: {
|
|
85
|
+
id: "audit-report",
|
|
86
|
+
name: "audit-report.pdf",
|
|
87
|
+
// The type union stays closed: a PDF arrives as a `source`, it does not
|
|
88
|
+
// add a case here.
|
|
89
|
+
type: "code",
|
|
90
|
+
mediaType: "application/pdf",
|
|
91
|
+
source: { kind: "url", url: "/audit-report.pdf", name: "audit-report.pdf" },
|
|
92
|
+
},
|
|
93
|
+
renderPreview: (asset) =>
|
|
94
|
+
asset.source ? (
|
|
95
|
+
<div className="bg-surface-muted text-body text-muted-foreground rounded-md p-4">
|
|
96
|
+
Rendered by the injected viewer: {asset.name}
|
|
97
|
+
</div>
|
|
98
|
+
) : null,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { AssetPreview } from "./asset-preview";
|
|
5
|
+
import { ContextPanelProvider, type ContextAsset } from "./context-panel";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
const MARKDOWN_ASSET: ContextAsset = {
|
|
10
|
+
id: "board-note",
|
|
11
|
+
name: "board-note.md",
|
|
12
|
+
type: "markdown",
|
|
13
|
+
content: "# Quarterly summary\n\nRevenue grew.",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const CSV_ASSET: ContextAsset = {
|
|
17
|
+
id: "revenue",
|
|
18
|
+
name: "revenue.csv",
|
|
19
|
+
type: "csv",
|
|
20
|
+
content: "region,total\nEMEA,16.1\nAmericas,19.6",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("AssetPreview (#193, research 04 §5 ASSET-4)", () => {
|
|
24
|
+
it("renders markdown as a document (the ASSET-2 fix), with a Preview/Raw toggle", async () => {
|
|
25
|
+
const user = userEvent.setup();
|
|
26
|
+
render(<AssetPreview asset={MARKDOWN_ASSET} />);
|
|
27
|
+
|
|
28
|
+
// Artifact chrome: the asset name headline.
|
|
29
|
+
expect(screen.getByText("board-note.md")).toBeInTheDocument();
|
|
30
|
+
// Preview = a real heading, not Shiki source.
|
|
31
|
+
expect(screen.getByRole("heading", { name: "Quarterly summary" })).toBeInTheDocument();
|
|
32
|
+
// Constrained rung: never an h1 inside the rail (research 09 §G.2).
|
|
33
|
+
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
|
|
34
|
+
|
|
35
|
+
// Raw shows the markdown source instead.
|
|
36
|
+
await user.click(screen.getByRole("button", { name: "Raw" }));
|
|
37
|
+
expect(screen.queryByRole("heading", { name: "Quarterly summary" })).not.toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText(/# Quarterly summary/)).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByRole("button", { name: "Raw" })).toHaveAttribute("aria-pressed", "true");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders csv as a small table with a row-count summary", () => {
|
|
43
|
+
render(<AssetPreview asset={CSV_ASSET} />);
|
|
44
|
+
expect(screen.getByRole("table")).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByText("EMEA")).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders an empty state for missing content, never broken UI", () => {
|
|
50
|
+
render(<AssetPreview asset={{ id: "x", name: "empty.md", type: "markdown" }} />);
|
|
51
|
+
expect(screen.getByText("No preview available…")).toBeInTheDocument();
|
|
52
|
+
// No Raw toggle without raw source.
|
|
53
|
+
expect(screen.queryByRole("button", { name: "Raw" })).not.toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The seam that lets a format this package cannot parse (a PDF, a spreadsheet,
|
|
59
|
+
* a video) show up in the rail without `@elabs-ai/components-ai`
|
|
60
|
+
* importing `@elabs-ai/components-viewer` — they are layer peers
|
|
61
|
+
* (ADR 0024 §6). The injection stands in for `FileViewer` here.
|
|
62
|
+
*/
|
|
63
|
+
describe("AssetPreview — renderPreview injection", () => {
|
|
64
|
+
const PDF_ASSET: ContextAsset = {
|
|
65
|
+
id: "report",
|
|
66
|
+
name: "report.pdf",
|
|
67
|
+
// The union stays closed: a PDF arrives as `code` + a `source`, it does not
|
|
68
|
+
// grow a `ContextAssetType` case.
|
|
69
|
+
type: "code",
|
|
70
|
+
mediaType: "application/pdf",
|
|
71
|
+
source: { kind: "url", url: "https://example.com/report.pdf", name: "report.pdf" },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
it("draws a format the built-in switch cannot, instead of 'No preview available…'", () => {
|
|
75
|
+
render(
|
|
76
|
+
<AssetPreview
|
|
77
|
+
asset={PDF_ASSET}
|
|
78
|
+
renderPreview={(asset) => (asset.source ? <p>viewer: {asset.name}</p> : null)}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
expect(screen.getByText("viewer: report.pdf")).toBeInTheDocument();
|
|
82
|
+
expect(screen.queryByText("No preview available…")).not.toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("is additive — a renderer that declines leaves every built-in format alone", () => {
|
|
86
|
+
// The whole point of the `null` return: an injection that only knows PDFs
|
|
87
|
+
// must not take markdown away from `MarkdownView`.
|
|
88
|
+
render(
|
|
89
|
+
<AssetPreview
|
|
90
|
+
asset={MARKDOWN_ASSET}
|
|
91
|
+
renderPreview={(asset) => (asset.mediaType === "application/pdf" ? <p>viewer</p> : null)}
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
expect(screen.getByRole("heading", { name: "Quarterly summary" })).toBeInTheDocument();
|
|
95
|
+
expect(screen.queryByText("viewer")).not.toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("takes an injection from the surrounding provider, so a rail is taught once", () => {
|
|
99
|
+
render(
|
|
100
|
+
<ContextPanelProvider renderPreview={(asset) => <p>rail: {asset.name}</p>}>
|
|
101
|
+
<AssetPreview asset={PDF_ASSET} />
|
|
102
|
+
</ContextPanelProvider>,
|
|
103
|
+
);
|
|
104
|
+
expect(screen.getByText("rail: report.pdf")).toBeInTheDocument();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("lets an explicit prop win over the provider's renderer", () => {
|
|
108
|
+
render(
|
|
109
|
+
<ContextPanelProvider renderPreview={() => <p>rail</p>}>
|
|
110
|
+
<AssetPreview asset={PDF_ASSET} renderPreview={() => <p>this one</p>} />
|
|
111
|
+
</ContextPanelProvider>,
|
|
112
|
+
);
|
|
113
|
+
expect(screen.getByText("this one")).toBeInTheDocument();
|
|
114
|
+
expect(screen.queryByText("rail")).not.toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("never intercepts Raw — Raw means 'show me the source'", async () => {
|
|
118
|
+
const user = userEvent.setup();
|
|
119
|
+
render(<AssetPreview asset={MARKDOWN_ASSET} renderPreview={() => <p>injected preview</p>} />);
|
|
120
|
+
expect(screen.getByText("injected preview")).toBeInTheDocument();
|
|
121
|
+
|
|
122
|
+
await user.click(screen.getByRole("button", { name: "Raw" }));
|
|
123
|
+
expect(screen.queryByText("injected preview")).not.toBeInTheDocument();
|
|
124
|
+
expect(screen.getByText(/# Quarterly summary/)).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
});
|