@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,96 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
|
|
3
|
+
import { ChatGreeting } from "./chat-greeting";
|
|
4
|
+
import { Composer } from "./composer";
|
|
5
|
+
|
|
6
|
+
const SUGGESTIONS = ["Summary", "Code", "Design", "Research"];
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: "AI/ChatGreeting",
|
|
10
|
+
component: ChatGreeting,
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: "padded",
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component:
|
|
16
|
+
"The centered first-run greeting for an empty chat/composer scene — a display-scale headline with an accent phrase and an optional soft primary glow. Distinct from ConversationEmptyState (a generic 'No messages yet' message panel); pair ChatGreeting with <Composer /> for the standard empty/first-run chat state.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
args: {
|
|
21
|
+
title: "Good morning, Avery",
|
|
22
|
+
subtitle: "How can I",
|
|
23
|
+
accent: "assist you today?",
|
|
24
|
+
},
|
|
25
|
+
tags: ["autodocs"],
|
|
26
|
+
} satisfies Meta<typeof ChatGreeting>;
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
type Story = StoryObj<typeof meta>;
|
|
30
|
+
|
|
31
|
+
/** The standard greeting with its glow. */
|
|
32
|
+
export const Default: Story = {
|
|
33
|
+
render: (args) => (
|
|
34
|
+
<div className="mx-auto max-w-2xl bg-background p-10">
|
|
35
|
+
<ChatGreeting {...args} />
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Dense/embedded use — no glow behind the headline. */
|
|
41
|
+
export const NoOrb: Story = {
|
|
42
|
+
args: { orb: false },
|
|
43
|
+
render: (args) => (
|
|
44
|
+
<div className="mx-auto max-w-2xl bg-background p-10">
|
|
45
|
+
<ChatGreeting {...args} />
|
|
46
|
+
</div>
|
|
47
|
+
),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A long name and a two-line accent phrase — `text-balance` keeps the headline readable. */
|
|
51
|
+
export const LongContent: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
title: "Good afternoon, Alexandra Christodoulopoulos",
|
|
54
|
+
subtitle: "What would you like help with",
|
|
55
|
+
accent: "today across your reports, dashboards, and data models?",
|
|
56
|
+
},
|
|
57
|
+
render: (args) => (
|
|
58
|
+
<div className="mx-auto max-w-2xl bg-background p-10">
|
|
59
|
+
<ChatGreeting {...args} />
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** `accent` alone, with no `subtitle` line — the second line must not be dropped. */
|
|
65
|
+
export const AccentOnly: Story = {
|
|
66
|
+
args: { title: "Hi Avery", subtitle: undefined, accent: "what are we building today?" },
|
|
67
|
+
render: (args) => (
|
|
68
|
+
<div className="mx-auto max-w-2xl bg-background p-10">
|
|
69
|
+
<ChatGreeting {...args} />
|
|
70
|
+
</div>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A route that already owns the page's `<h1>` — pass `level={2}` to keep a single, hierarchical outline. */
|
|
75
|
+
export const HeadingLevel2: Story = {
|
|
76
|
+
args: { level: 2 },
|
|
77
|
+
render: (args) => (
|
|
78
|
+
<div className="mx-auto max-w-2xl bg-background p-10">
|
|
79
|
+
<h1 className="sr-only">Assistant</h1>
|
|
80
|
+
<ChatGreeting {...args} />
|
|
81
|
+
</div>
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** The real first-run chat state: greeting above the standard Composer. */
|
|
86
|
+
export const WithComposer: Story = {
|
|
87
|
+
parameters: { layout: "fullscreen" },
|
|
88
|
+
render: (args) => (
|
|
89
|
+
<div className="grid min-h-[28rem] place-items-center bg-background px-4 py-10 text-foreground">
|
|
90
|
+
<div className="w-full max-w-2xl">
|
|
91
|
+
<ChatGreeting {...args} />
|
|
92
|
+
<Composer onSubmit={() => undefined} suggestions={SUGGESTIONS} />
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
),
|
|
96
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { ChatGreeting } from "./chat-greeting";
|
|
4
|
+
|
|
5
|
+
describe("ChatGreeting", () => {
|
|
6
|
+
it("renders the title and accent text", () => {
|
|
7
|
+
render(
|
|
8
|
+
<ChatGreeting title="Good morning, Avery" subtitle="How can I" accent="assist you today?" />,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const heading = screen.getByRole("heading", { level: 1 });
|
|
12
|
+
expect(heading).toHaveTextContent("Good morning, Avery");
|
|
13
|
+
expect(screen.getByText("assist you today?")).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// #254 follow-up: `accent` used to be nested inside the `subtitle` branch, so
|
|
17
|
+
// an accent-without-subtitle combination silently rendered nothing past the
|
|
18
|
+
// title. Both must be independently renderable.
|
|
19
|
+
it("renders accent even when subtitle is omitted", () => {
|
|
20
|
+
render(<ChatGreeting title="Hi Avery" accent="what are we building today?" />);
|
|
21
|
+
|
|
22
|
+
expect(screen.getByText("what are we building today?")).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// #254 follow-up: a monochrome theme's --primary can be a near-white "pen",
|
|
26
|
+
// ΔL/ΔC vs --foreground so small that `text-primary` alone is invisible as
|
|
27
|
+
// an accent there. The underline + bold weight are a NON-hue channel that
|
|
28
|
+
// must survive regardless of which theme resolves `text-primary`.
|
|
29
|
+
it("carries a non-hue emphasis (bold + underline) on the accent, not color alone", () => {
|
|
30
|
+
render(<ChatGreeting title="Hi Avery" accent="assist you today?" />);
|
|
31
|
+
|
|
32
|
+
const accent = screen.getByText("assist you today?");
|
|
33
|
+
const classes = accent.className.split(" ");
|
|
34
|
+
expect(classes).toContain("font-bold");
|
|
35
|
+
expect(classes).toContain("underline");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders subtitle even when accent is omitted", () => {
|
|
39
|
+
render(<ChatGreeting title="Hi Avery" subtitle="Ready when you are" />);
|
|
40
|
+
|
|
41
|
+
// `subtitle` is a bare text node beside `title` inside the same heading (no
|
|
42
|
+
// wrapping element of its own, unlike `accent`), so assert via the
|
|
43
|
+
// heading's text content rather than `getByText` (which matches an
|
|
44
|
+
// element's OWN text, not a node it merely contains).
|
|
45
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Ready when you are");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders an aria-hidden orb by default and omits it when orb is false", () => {
|
|
49
|
+
const { container, rerender } = render(<ChatGreeting title="Hello" />);
|
|
50
|
+
|
|
51
|
+
const orb = container.querySelector('[aria-hidden="true"]');
|
|
52
|
+
expect(orb).not.toBeNull();
|
|
53
|
+
|
|
54
|
+
rerender(<ChatGreeting title="Hello" orb={false} />);
|
|
55
|
+
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// #254 root-cause: without `isolate`, the negative-z-index orb paints BELOW
|
|
59
|
+
// an opaque ancestor's own background (CSS painting order), so it's
|
|
60
|
+
// invisible in every shipped usage (all wrap ChatGreeting in a filled
|
|
61
|
+
// container). `isolate` gives ChatGreeting its own stacking context so the
|
|
62
|
+
// whole subtree — orb included — paints as one unit above the ancestor fill.
|
|
63
|
+
it("isolates its own stacking context so the orb can paint above an ancestor fill", () => {
|
|
64
|
+
const { container } = render(<ChatGreeting title="Hello" />);
|
|
65
|
+
const root = container.firstElementChild as HTMLElement;
|
|
66
|
+
expect(root.className.split(" ")).toContain("isolate");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("defaults to a level-1 heading and honors an explicit level override", () => {
|
|
70
|
+
const { rerender } = render(<ChatGreeting title="Hello" />);
|
|
71
|
+
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
|
|
72
|
+
|
|
73
|
+
rerender(<ChatGreeting title="Hello" level={2} />);
|
|
74
|
+
expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument();
|
|
75
|
+
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Reuse audit: the headline is the shared `Heading` primitive so it carries
|
|
79
|
+
// the `--font-display` brand seam, not a hand-rolled `<h1>`.
|
|
80
|
+
it("renders the title through the shared Heading primitive (font-display seam)", () => {
|
|
81
|
+
render(<ChatGreeting title="Hello" />);
|
|
82
|
+
const heading = screen.getByRole("heading", { level: 1 });
|
|
83
|
+
expect(heading.className.split(" ")).toContain("font-display");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("merges a custom className onto the root", () => {
|
|
87
|
+
const { container } = render(<ChatGreeting title="Hello" className="custom-class" />);
|
|
88
|
+
|
|
89
|
+
expect(container.firstElementChild).toHaveClass("custom-class");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import { cn, Heading, type HeadingLevel } from "@elabs-ai/components-ui";
|
|
4
|
+
|
|
5
|
+
export interface ChatGreetingProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
6
|
+
/** Primary line, e.g. "Good morning, Avery". */
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
/**
|
|
9
|
+
* Second line, rendered under `title` inside the same heading. Independent
|
|
10
|
+
* of `accent` — either may be passed alone.
|
|
11
|
+
*/
|
|
12
|
+
subtitle?: ReactNode;
|
|
13
|
+
/**
|
|
14
|
+
* Phrase rendered in `text-primary-text` with a bold underline, appended
|
|
15
|
+
* after `subtitle` (or alone). The underline + weight is a NON-hue channel
|
|
16
|
+
* deliberately layered on top of the color — a monochrome theme's `--primary`
|
|
17
|
+
* can be a near-white "pen" by design, in which case the accent colour alone
|
|
18
|
+
* is indistinguishable from the surrounding `--foreground` headline text. The
|
|
19
|
+
* underline/weight keep the phrase legible as an accent in EVERY theme,
|
|
20
|
+
* not only the two where hue alone would carry it.
|
|
21
|
+
*/
|
|
22
|
+
accent?: ReactNode;
|
|
23
|
+
/** Soft primary glow behind the headline. Default `true`; `false` for dense/embedded use. */
|
|
24
|
+
orb?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Semantic heading level for `title`. Default `1` (a chat empty state is
|
|
27
|
+
* typically the only heading on the page). Override to `2`+ when ChatGreeting
|
|
28
|
+
* sits inside a page or route that already renders its own `<h1>` —
|
|
29
|
+
* `Heading` decouples `level` from `size`, so the visual display-scale rung
|
|
30
|
+
* stays put regardless of the level chosen.
|
|
31
|
+
*/
|
|
32
|
+
level?: HeadingLevel;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ChatGreeting — the centered first-run greeting for an empty chat/composer scene.
|
|
37
|
+
*
|
|
38
|
+
* A display-scale headline (`title`, then `subtitle` with an `accent` phrase
|
|
39
|
+
* in `text-primary-text` + a bold underline, so the accent reads in every theme,
|
|
40
|
+
* including a monochrome one whose `--primary` is otherwise the same
|
|
41
|
+
* near-white as the headline) over an optional soft primary glow. Pair it
|
|
42
|
+
* with `<Composer />` for the standard empty/first-run chat state.
|
|
43
|
+
*
|
|
44
|
+
* Distinct from `ConversationEmptyState` (a generic centered "No messages yet"
|
|
45
|
+
* message panel) — this is the display-scale greeting anatomy, not a status
|
|
46
|
+
* message.
|
|
47
|
+
*/
|
|
48
|
+
export const ChatGreeting = forwardRef<HTMLDivElement, ChatGreetingProps>(function ChatGreeting(
|
|
49
|
+
{ title, subtitle, accent, orb = true, level = 1, className, ...props },
|
|
50
|
+
ref,
|
|
51
|
+
) {
|
|
52
|
+
const hasSecondLine = subtitle != null || accent != null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
ref={ref}
|
|
57
|
+
data-slot="chat-greeting"
|
|
58
|
+
// `isolate` gives the root its own stacking context so the negative-
|
|
59
|
+
// z-index orb paints ABOVE an ancestor's own background instead of
|
|
60
|
+
// beneath it (CSS painting order step 2 vs step 3) — without it, any
|
|
61
|
+
// opaque wrapper (e.g. a `bg-background` scene container) occludes the
|
|
62
|
+
// glow entirely. See issue #254 root-cause.
|
|
63
|
+
className={cn("relative isolate mb-10 text-center", className)}
|
|
64
|
+
{...props}
|
|
65
|
+
>
|
|
66
|
+
{orb ? (
|
|
67
|
+
<div
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
data-slot="chat-greeting-orb"
|
|
70
|
+
className="pointer-events-none absolute start-1/2 top-0 -z-10 size-40 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/15 blur-3xl"
|
|
71
|
+
/>
|
|
72
|
+
) : null}
|
|
73
|
+
<Heading data-slot="chat-greeting-title" level={level} size="display">
|
|
74
|
+
{title}
|
|
75
|
+
{hasSecondLine ? (
|
|
76
|
+
<>
|
|
77
|
+
<br />
|
|
78
|
+
{subtitle}
|
|
79
|
+
{subtitle != null && accent != null ? " " : null}
|
|
80
|
+
{accent != null ? (
|
|
81
|
+
// #399 — accent phrase inside a headline is TEXT: `-text` rung.
|
|
82
|
+
<span className="font-bold text-primary-text underline decoration-2 underline-offset-4">
|
|
83
|
+
{accent}
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</>
|
|
87
|
+
) : null}
|
|
88
|
+
</Heading>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { cn } from "@elabs-ai/components-ui";
|
|
4
|
+
import { ChatShell } from "./chat-shell";
|
|
5
|
+
import { Conversation, ConversationContent, ConversationScrollButton } from "./conversation";
|
|
6
|
+
import { Message, MessageContent, MessageResponse } from "./message";
|
|
7
|
+
import { Composer } from "./composer";
|
|
8
|
+
import { Reasoning, ReasoningContent, ReasoningTrigger } from "./reasoning";
|
|
9
|
+
import { Source, Sources, SourcesContent, SourcesTrigger } from "./sources";
|
|
10
|
+
import { ContextPanelRail } from "./_chat-shell-rail";
|
|
11
|
+
|
|
12
|
+
interface Msg {
|
|
13
|
+
id: string;
|
|
14
|
+
role: "user" | "assistant";
|
|
15
|
+
text: string;
|
|
16
|
+
reasoning?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const initial: Msg[] = [
|
|
20
|
+
{ id: "1", role: "user", text: "What changed in last week's deploys?" },
|
|
21
|
+
{
|
|
22
|
+
id: "2",
|
|
23
|
+
role: "assistant",
|
|
24
|
+
text: "Three services shipped this week. **Billing** is currently degraded — elevated p95 latency after the last rollout.",
|
|
25
|
+
reasoning:
|
|
26
|
+
"Queried CI for the last 7 days, grouped by service, then flagged regressions from the rollback log.",
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function ChatShellExample({ variant = "card" }: { variant?: "card" | "bare" }) {
|
|
31
|
+
const [messages, setMessages] = useState<Msg[]>(initial);
|
|
32
|
+
const [status, setStatus] = useState<"ready" | "submitted">("ready");
|
|
33
|
+
|
|
34
|
+
const send = (text: string) => {
|
|
35
|
+
setMessages((m) => [...m, { id: String(m.length + 1), role: "user", text }]);
|
|
36
|
+
setStatus("submitted");
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
setMessages((m) => [
|
|
39
|
+
...m,
|
|
40
|
+
{
|
|
41
|
+
id: String(m.length + 1),
|
|
42
|
+
role: "assistant",
|
|
43
|
+
text: "Three services shipped; billing is degraded.",
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
setStatus("ready");
|
|
47
|
+
}, 600);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
// For `bare`, the wrapper is the bounded pane the immersive shell fills —
|
|
52
|
+
// exactly how a workspace `SidebarInset` already frames the region.
|
|
53
|
+
<div
|
|
54
|
+
className={cn(
|
|
55
|
+
"mx-auto h-[640px] max-w-4xl",
|
|
56
|
+
variant === "bare" && "overflow-hidden rounded-xl border bg-background",
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<ChatShell
|
|
60
|
+
variant={variant}
|
|
61
|
+
header={<span className="text-body font-semibold">Ops Assistant</span>}
|
|
62
|
+
aside={<ContextPanelRail />}
|
|
63
|
+
composer={
|
|
64
|
+
<Composer
|
|
65
|
+
placeholder="Ask about deploys…"
|
|
66
|
+
status={status === "submitted" ? "Generating…" : "Awaiting your input"}
|
|
67
|
+
sendStatus={status}
|
|
68
|
+
onSubmit={(message) => {
|
|
69
|
+
const text = message.text?.trim();
|
|
70
|
+
if (text) send(text);
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
}
|
|
74
|
+
>
|
|
75
|
+
<Conversation className="flex-1">
|
|
76
|
+
<ConversationContent>
|
|
77
|
+
{messages.map((m) => (
|
|
78
|
+
<Message from={m.role} key={m.id}>
|
|
79
|
+
<MessageContent>
|
|
80
|
+
{m.reasoning ? (
|
|
81
|
+
<Reasoning>
|
|
82
|
+
<ReasoningTrigger />
|
|
83
|
+
<ReasoningContent>{m.reasoning}</ReasoningContent>
|
|
84
|
+
</Reasoning>
|
|
85
|
+
) : null}
|
|
86
|
+
<MessageResponse>{m.text}</MessageResponse>
|
|
87
|
+
{m.role === "assistant" ? (
|
|
88
|
+
<Sources>
|
|
89
|
+
<SourcesTrigger count={2} />
|
|
90
|
+
<SourcesContent>
|
|
91
|
+
<Source href="https://example.com" title="Deploy log — wk 23" />
|
|
92
|
+
<Source href="https://example.com" title="Billing runbook" />
|
|
93
|
+
</SourcesContent>
|
|
94
|
+
</Sources>
|
|
95
|
+
) : null}
|
|
96
|
+
</MessageContent>
|
|
97
|
+
</Message>
|
|
98
|
+
))}
|
|
99
|
+
</ConversationContent>
|
|
100
|
+
<ConversationScrollButton />
|
|
101
|
+
</Conversation>
|
|
102
|
+
</ChatShell>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const meta = {
|
|
108
|
+
title: "AI/ChatShell",
|
|
109
|
+
component: ChatShellExample,
|
|
110
|
+
parameters: { layout: "fullscreen" },
|
|
111
|
+
} satisfies Meta<typeof ChatShellExample>;
|
|
112
|
+
export default meta;
|
|
113
|
+
type Story = StoryObj<typeof meta>;
|
|
114
|
+
|
|
115
|
+
export const Default: Story = {};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Frameless immersive layout: no card border, the transcript fades under the
|
|
119
|
+
* header and above the composer, and the composer floats on the pane ground.
|
|
120
|
+
* Use inside an already-bounded region (an AppShell pane, a full-page
|
|
121
|
+
* workspace) so the shell doesn't draw a redundant second frame.
|
|
122
|
+
*/
|
|
123
|
+
export const Bare: Story = { args: { variant: "bare" } };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat-shell.test.tsx — smoke + layout-contract lock for the chat frame (#59).
|
|
3
|
+
*
|
|
4
|
+
* ChatShell is the outermost container of every chat surface in the library, and
|
|
5
|
+
* until #59 it had no test at all — its only coverage was inside stories. The
|
|
6
|
+
* contract worth locking is structural: the four slots render where the caller
|
|
7
|
+
* expects them and in transcript order; an unsupplied slot is OMITTED rather
|
|
8
|
+
* than rendered empty (an empty header would still reserve its 3rem row); and
|
|
9
|
+
* the `bare` variant's edge scrims are inert — `aria-hidden` AND
|
|
10
|
+
* `pointer-events-none`, or an immersive pane would silently eat taps at the top
|
|
11
|
+
* and bottom of its own transcript.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here is mocked: the assertions run against the real component as it
|
|
14
|
+
* ships (the #34/#46 lesson — a stand-in that happens to honour an aria
|
|
15
|
+
* attribute proves nothing about the shipped surface).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { render, screen } from "@testing-library/react";
|
|
19
|
+
import { ChatShell } from "./chat-shell";
|
|
20
|
+
|
|
21
|
+
describe("ChatShell — slots", () => {
|
|
22
|
+
it("renders the transcript children", () => {
|
|
23
|
+
render(
|
|
24
|
+
<ChatShell>
|
|
25
|
+
<p>transcript</p>
|
|
26
|
+
</ChatShell>,
|
|
27
|
+
);
|
|
28
|
+
expect(screen.getByText("transcript")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders header, transcript, composer and aside together", () => {
|
|
32
|
+
render(
|
|
33
|
+
<ChatShell
|
|
34
|
+
header={<h2>Assistant</h2>}
|
|
35
|
+
composer={<textarea aria-label="Message" />}
|
|
36
|
+
aside={<nav aria-label="Sources" />}
|
|
37
|
+
>
|
|
38
|
+
<p>transcript</p>
|
|
39
|
+
</ChatShell>,
|
|
40
|
+
);
|
|
41
|
+
expect(screen.getByRole("heading", { name: "Assistant" })).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByText("transcript")).toBeInTheDocument();
|
|
43
|
+
expect(screen.getByRole("textbox", { name: "Message" })).toBeInTheDocument();
|
|
44
|
+
expect(screen.getByRole("navigation", { name: "Sources" })).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("puts the composer AFTER the transcript in DOM order (tab order follows reading order)", () => {
|
|
48
|
+
render(
|
|
49
|
+
<ChatShell composer={<textarea aria-label="Message" />}>
|
|
50
|
+
<p>transcript</p>
|
|
51
|
+
</ChatShell>,
|
|
52
|
+
);
|
|
53
|
+
const transcript = screen.getByText("transcript");
|
|
54
|
+
const composer = screen.getByRole("textbox", { name: "Message" });
|
|
55
|
+
// Node.DOCUMENT_POSITION_FOLLOWING === 4
|
|
56
|
+
expect(transcript.compareDocumentPosition(composer) & 4).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("omits the header row entirely when no header is supplied", () => {
|
|
60
|
+
const { container } = render(<ChatShell>t</ChatShell>);
|
|
61
|
+
// An empty header would still occupy its fixed h-12 row.
|
|
62
|
+
expect(container.querySelector(".h-12")).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("omits the aside rail entirely when no aside is supplied", () => {
|
|
66
|
+
const { container } = render(<ChatShell>t</ChatShell>);
|
|
67
|
+
expect(container.querySelector(".w-80")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("ChatShell — variants", () => {
|
|
72
|
+
it("frames itself as a card by default", () => {
|
|
73
|
+
const { container } = render(<ChatShell>t</ChatShell>);
|
|
74
|
+
expect(container.firstChild).toHaveClass("rounded-xl");
|
|
75
|
+
expect(container.firstChild).toHaveClass("border");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("drops the frame in the bare variant (no second box inside a bounded region)", () => {
|
|
79
|
+
const { container } = render(<ChatShell variant="bare">t</ChatShell>);
|
|
80
|
+
expect(container.firstChild).not.toHaveClass("rounded-xl");
|
|
81
|
+
expect(container.firstChild).not.toHaveClass("border");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("drops the header/composer dividers in the bare variant", () => {
|
|
85
|
+
const { container } = render(
|
|
86
|
+
<ChatShell variant="bare" header={<span>h</span>} composer={<span>c</span>}>
|
|
87
|
+
t
|
|
88
|
+
</ChatShell>,
|
|
89
|
+
);
|
|
90
|
+
expect(container.querySelector(".border-b")).toBeNull();
|
|
91
|
+
expect(container.querySelector(".border-t")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("makes the bare variant's edge scrims inert (aria-hidden AND pointer-events-none)", () => {
|
|
95
|
+
const { container } = render(<ChatShell variant="bare">t</ChatShell>);
|
|
96
|
+
const scrims = container.querySelectorAll("[aria-hidden='true']");
|
|
97
|
+
expect(scrims).toHaveLength(2);
|
|
98
|
+
for (const scrim of scrims) expect(scrim).toHaveClass("pointer-events-none");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("renders no scrims in the card variant (there is a real divider instead)", () => {
|
|
102
|
+
const { container } = render(<ChatShell>t</ChatShell>);
|
|
103
|
+
expect(container.querySelectorAll("[aria-hidden='true']")).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("ChatShell — composability", () => {
|
|
108
|
+
it("merges a caller className without dropping the fill-parent layout", () => {
|
|
109
|
+
const { container } = render(<ChatShell className="extra">t</ChatShell>);
|
|
110
|
+
expect(container.firstChild).toHaveClass("extra");
|
|
111
|
+
expect(container.firstChild).toHaveClass("h-full");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface ChatShellProps {
|
|
7
|
+
/** Scrollable transcript — typically a <Conversation>. */
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
/** Sticky composer — typically a <Composer> (the standard chat input). */
|
|
10
|
+
composer?: ReactNode;
|
|
11
|
+
/** Optional header row (title, model picker, actions). */
|
|
12
|
+
header?: ReactNode;
|
|
13
|
+
/** Optional right-side context rail (sources, files, memory). */
|
|
14
|
+
aside?: ReactNode;
|
|
15
|
+
/**
|
|
16
|
+
* Layout treatment:
|
|
17
|
+
* - `"card"` (default) — a self-contained rounded, bordered chat card. Use
|
|
18
|
+
* when the shell sits on a page among other content and needs its own frame.
|
|
19
|
+
* - `"bare"` — an immersive pane that fills its container with NO frame: the
|
|
20
|
+
* transcript fades under the header and above the composer (a token-driven
|
|
21
|
+
* top/bottom scrim) and the composer floats on the pane background instead
|
|
22
|
+
* of a hard bordered bar. Use when the shell already lives inside a bounded
|
|
23
|
+
* region (an AppShell `SidebarInset`, a full-page workspace) so it doesn't
|
|
24
|
+
* draw a redundant second frame.
|
|
25
|
+
*/
|
|
26
|
+
variant?: "card" | "bare";
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Layout shell for an AI chat built from the AI Elements: a header, a scrollable
|
|
32
|
+
* transcript (give it a <Conversation>, which is `flex-1`), a sticky composer
|
|
33
|
+
* (a <Composer>, the standard chat input), and an optional context rail. Fills
|
|
34
|
+
* its parent's height.
|
|
35
|
+
*
|
|
36
|
+
* Two layout modes (`variant`): a framed `"card"` for in-page use, and a
|
|
37
|
+
* frameless `"bare"` immersive pane for when the shell already sits inside a
|
|
38
|
+
* bounded region and a second frame would just read as boxy.
|
|
39
|
+
*/
|
|
40
|
+
export function ChatShell({
|
|
41
|
+
children,
|
|
42
|
+
composer,
|
|
43
|
+
header,
|
|
44
|
+
aside,
|
|
45
|
+
variant = "card",
|
|
46
|
+
className,
|
|
47
|
+
}: ChatShellProps) {
|
|
48
|
+
const bare = variant === "bare";
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"flex h-full min-h-0 w-full overflow-hidden bg-background",
|
|
53
|
+
bare ? null : "rounded-xl border",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
58
|
+
{header ? (
|
|
59
|
+
<div className={cn("flex h-12 shrink-0 items-center px-4", bare ? null : "border-b")}>
|
|
60
|
+
{header}
|
|
61
|
+
</div>
|
|
62
|
+
) : null}
|
|
63
|
+
|
|
64
|
+
{bare ? (
|
|
65
|
+
// Immersive transcript: the scroll area fades at both edges so content
|
|
66
|
+
// dissolves under the header and above the composer instead of hitting
|
|
67
|
+
// a hard bordered bar. The overlays are token-driven (`from-background`)
|
|
68
|
+
// so they fade to the shell's own ground in every theme, and inert
|
|
69
|
+
// (`pointer-events-none` + `aria-hidden`) so they never block scroll,
|
|
70
|
+
// steal a tap, or get announced.
|
|
71
|
+
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
72
|
+
{children}
|
|
73
|
+
<div
|
|
74
|
+
aria-hidden
|
|
75
|
+
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-8 bg-gradient-to-b from-background to-transparent"
|
|
76
|
+
/>
|
|
77
|
+
<div
|
|
78
|
+
aria-hidden
|
|
79
|
+
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-12 bg-gradient-to-t from-background to-transparent"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
) : (
|
|
83
|
+
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{composer ? (
|
|
87
|
+
// Bare: the composer floats on the pane ground (transparent, no
|
|
88
|
+
// divider) above the fade; card: the classic bordered footer.
|
|
89
|
+
<div className={cn("shrink-0", bare ? "relative z-20 px-4 pb-4" : "border-t p-3")}>
|
|
90
|
+
{composer}
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
</div>
|
|
94
|
+
{aside ? <div className="hidden w-80 shrink-0 border-s lg:block">{aside}</div> : null}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|