@elabs-ai/components-ai 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,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
+ }