@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,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+
4
+ import { PERSONA_SOURCES, Persona } from "./persona";
5
+
6
+ describe("Persona — remote artwork escape hatches", () => {
7
+ it("renders a fallback rather than an empty box before the Rive chunk loads", () => {
8
+ // The Rive runtime is behind `lazy()`, so the first frame is always the
9
+ // Suspense fallback. Previously a blocked `.riv` left a bare 64px void.
10
+ const { container } = render(<Persona state="idle" src="about:blank#offline" />);
11
+ expect(container.firstElementChild).not.toBeNull();
12
+ // The visually-hidden status announcement is the only text content now.
13
+ expect(container.textContent).toBe("Assistant idle");
14
+ expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
15
+ });
16
+
17
+ it("renders a caller-supplied fallback", () => {
18
+ render(<Persona state="idle" src="about:blank#offline" fallback={<span>resting</span>} />);
19
+ expect(screen.getByText("resting")).toBeInTheDocument();
20
+ });
21
+
22
+ it("throws on an unknown variant, as before", () => {
23
+ expect(() =>
24
+ // @ts-expect-error — deliberately invalid variant
25
+ render(<Persona state="idle" variant="nope" />),
26
+ ).toThrow(/Invalid variant/);
27
+ });
28
+ });
29
+
30
+ describe("Persona — state announced to assistive tech", () => {
31
+ it("announces a localized status for the current state", () => {
32
+ render(<Persona state="thinking" src="about:blank#offline" />);
33
+ expect(screen.getByRole("status")).toHaveTextContent("Assistant thinking…");
34
+ });
35
+
36
+ it("updates the SAME live region rather than remounting it when state changes", () => {
37
+ const { rerender } = render(<Persona state="thinking" src="about:blank#offline" />);
38
+ const region = screen.getByRole("status");
39
+ expect(region).toHaveTextContent("Assistant thinking…");
40
+
41
+ rerender(<Persona state="speaking" src="about:blank#offline" />);
42
+ expect(screen.getByRole("status")).toBe(region);
43
+ expect(region).toHaveTextContent("Assistant speaking");
44
+ });
45
+
46
+ it("renders no status region when statusLabel is explicitly null", () => {
47
+ render(<Persona state="thinking" src="about:blank#offline" statusLabel={null} />);
48
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
49
+ });
50
+
51
+ it("renders a caller-supplied statusLabel instead of the default", () => {
52
+ render(
53
+ <Persona state="thinking" src="about:blank#offline" statusLabel={<span>Working</span>} />,
54
+ );
55
+ expect(screen.getByRole("status")).toHaveTextContent("Working");
56
+ });
57
+ });
58
+
59
+ describe("PERSONA_SOURCES", () => {
60
+ it("is exported so consumers can self-host every artwork file", () => {
61
+ const variants = Object.keys(PERSONA_SOURCES);
62
+ expect(variants).toHaveLength(6);
63
+ for (const source of Object.values(PERSONA_SOURCES)) {
64
+ expect(source.source).toMatch(/^https:\/\/.*\.riv$/);
65
+ }
66
+ });
67
+ });
@@ -0,0 +1,148 @@
1
+ "use client";
2
+
3
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
4
+ import { useLocale } from "@elabs-ai/components-ui";
5
+ import type { RiveParameters } from "@rive-app/react-webgl2";
6
+ import type { FC, ReactNode } from "react";
7
+ import { lazy, memo, Suspense, useCallback, useState } from "react";
8
+
9
+ import { PERSONA_SOURCES, type PersonaState, type PersonaVariant } from "./persona-sources";
10
+
11
+ export { PERSONA_SOURCES };
12
+ export type { PersonaState, PersonaVariant };
13
+
14
+ /**
15
+ * The Rive runtime lives behind a dynamic import — `@rive-app/react-webgl2`
16
+ * declares no `sideEffects`, so a static import would put the whole WebGL2
17
+ * runtime and its `.wasm` in every consumer's entry chunk, `Persona` rendered or
18
+ * not. `RiveParameters` above is a TYPE import and erases. See ADR 0019.
19
+ */
20
+ const PersonaRive = lazy(() => import("./_persona-rive"));
21
+
22
+ /**
23
+ * Localized status announced when `state` changes, one key per `PersonaState`.
24
+ * Typed as a `Record` (not a bare template string) so a `PersonaState` value
25
+ * added later without a matching key here fails to typecheck.
26
+ */
27
+ const PERSONA_STATE_KEYS: Record<PersonaState, string> = {
28
+ idle: "ai.persona.idle",
29
+ listening: "ai.persona.listening",
30
+ thinking: "ai.persona.thinking",
31
+ speaking: "ai.persona.speaking",
32
+ asleep: "ai.persona.asleep",
33
+ };
34
+
35
+ export interface PersonaProps {
36
+ className?: string;
37
+ /**
38
+ * Rendered while the artwork loads, and permanently if it fails (blocked by a
39
+ * `connect-src` CSP, offline, or a bad `src`). Defaults to a token-driven orb,
40
+ * so a blocked fetch never leaves an empty box.
41
+ */
42
+ fallback?: ReactNode;
43
+ onLoad?: RiveParameters["onLoad"];
44
+ onLoadError?: RiveParameters["onLoadError"];
45
+ onPause?: RiveParameters["onPause"];
46
+ onPlay?: RiveParameters["onPlay"];
47
+ onReady?: () => void;
48
+ onStop?: RiveParameters["onStop"];
49
+ /**
50
+ * Override the `.riv` artwork URL — point at a self-hosted copy when a CSP
51
+ * blocks the default origin. Defaults to `PERSONA_SOURCES[variant].source`.
52
+ * Mirrors the `mapStyle` override on `MapCanvas`.
53
+ */
54
+ src?: string;
55
+ state: PersonaState;
56
+ /**
57
+ * Visually-hidden text announced when `state` changes. Defaults to a
58
+ * localized label for the current `state`; pass `null` when the consuming
59
+ * surface already renders its own `role="status"` line, so AT is not told
60
+ * twice.
61
+ */
62
+ statusLabel?: ReactNode | null;
63
+ variant?: PersonaVariant;
64
+ }
65
+
66
+ /**
67
+ * The offline/blocked stand-in: a token-driven orb.
68
+ *
69
+ * Opacity-only pulse (no transform), neutralised under reduced motion.
70
+ */
71
+ const PersonaFallback = ({ className }: { className?: string }) => (
72
+ <div
73
+ aria-hidden="true"
74
+ className={cn("grid size-16 shrink-0 place-items-center rounded-full bg-primary/10", className)}
75
+ >
76
+ <span className="size-6 animate-pulse rounded-full bg-primary/40 motion-reduce:animate-none" />
77
+ </div>
78
+ );
79
+
80
+ export const Persona: FC<PersonaProps> = memo(
81
+ ({
82
+ className,
83
+ fallback,
84
+ onLoad,
85
+ onLoadError,
86
+ onPause,
87
+ onPlay,
88
+ onReady,
89
+ onStop,
90
+ src,
91
+ state = "idle",
92
+ statusLabel,
93
+ variant = "obsidian",
94
+ }) => {
95
+ const source = PERSONA_SOURCES[variant];
96
+
97
+ if (!source) {
98
+ throw new Error(`Invalid variant: ${variant}`);
99
+ }
100
+
101
+ const { t } = useLocale();
102
+ const [failed, setFailed] = useState(false);
103
+
104
+ const handleLoadError = useCallback<NonNullable<RiveParameters["onLoadError"]>>(
105
+ (event) => {
106
+ setFailed(true);
107
+ onLoadError?.(event);
108
+ },
109
+ [onLoadError],
110
+ );
111
+
112
+ const placeholder = fallback ?? <PersonaFallback className={className} />;
113
+
114
+ const label = statusLabel === null ? null : (statusLabel ?? t(PERSONA_STATE_KEYS[state]));
115
+
116
+ const visual = failed ? (
117
+ placeholder
118
+ ) : (
119
+ <Suspense fallback={placeholder}>
120
+ <PersonaRive
121
+ className={className}
122
+ onLoad={onLoad}
123
+ onLoadError={handleLoadError}
124
+ onPause={onPause}
125
+ onPlay={onPlay}
126
+ onReady={onReady}
127
+ onStop={onStop}
128
+ source={source}
129
+ src={src ?? source.source}
130
+ state={state}
131
+ />
132
+ </Suspense>
133
+ );
134
+
135
+ return (
136
+ <>
137
+ {visual}
138
+ {label !== null && (
139
+ <span className="sr-only" role="status" aria-live="polite">
140
+ {label}
141
+ </span>
142
+ )}
143
+ </>
144
+ );
145
+ },
146
+ );
147
+
148
+ Persona.displayName = "Persona";
package/src/plan.tsx ADDED
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import { Button } from "@elabs-ai/components-ui";
4
+ import {
5
+ Card,
6
+ CardAction,
7
+ CardContent,
8
+ CardDescription,
9
+ CardFooter,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from "@elabs-ai/components-ui";
13
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
14
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
15
+ import { ChevronsUpDownIcon } from "lucide-react";
16
+ import type { ComponentProps } from "react";
17
+ import { createContext, useContext, useMemo } from "react";
18
+
19
+ import { Shimmer } from "./shimmer";
20
+
21
+ interface PlanContextValue {
22
+ isStreaming: boolean;
23
+ }
24
+
25
+ const PlanContext = createContext<PlanContextValue | null>(null);
26
+
27
+ const usePlan = () => {
28
+ const context = useContext(PlanContext);
29
+ if (!context) {
30
+ throw new Error("Plan components must be used within Plan");
31
+ }
32
+ return context;
33
+ };
34
+
35
+ export type PlanProps = ComponentProps<typeof Collapsible> & {
36
+ isStreaming?: boolean;
37
+ };
38
+
39
+ export const Plan = ({ className, isStreaming = false, children, ...props }: PlanProps) => {
40
+ const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
41
+
42
+ return (
43
+ <PlanContext.Provider value={contextValue}>
44
+ <Collapsible asChild data-slot="plan" {...props}>
45
+ <Card className={cn("shadow-none", className)}>{children}</Card>
46
+ </Collapsible>
47
+ </PlanContext.Provider>
48
+ );
49
+ };
50
+
51
+ export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
52
+
53
+ export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
54
+ <CardHeader
55
+ className={cn("flex items-start justify-between", className)}
56
+ data-slot="plan-header"
57
+ {...props}
58
+ />
59
+ );
60
+
61
+ export type PlanTitleProps = Omit<ComponentProps<typeof CardTitle>, "children"> & {
62
+ children: string;
63
+ };
64
+
65
+ export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
66
+ const { isStreaming } = usePlan();
67
+
68
+ return (
69
+ <CardTitle data-slot="plan-title" {...props}>
70
+ {isStreaming ? <Shimmer>{children}</Shimmer> : children}
71
+ </CardTitle>
72
+ );
73
+ };
74
+
75
+ export type PlanDescriptionProps = Omit<ComponentProps<typeof CardDescription>, "children"> & {
76
+ children: string;
77
+ };
78
+
79
+ export const PlanDescription = ({ className, children, ...props }: PlanDescriptionProps) => {
80
+ const { isStreaming } = usePlan();
81
+
82
+ return (
83
+ <CardDescription
84
+ className={cn("text-balance", className)}
85
+ data-slot="plan-description"
86
+ {...props}
87
+ >
88
+ {isStreaming ? <Shimmer>{children}</Shimmer> : children}
89
+ </CardDescription>
90
+ );
91
+ };
92
+
93
+ export type PlanActionProps = ComponentProps<typeof CardAction>;
94
+
95
+ export const PlanAction = (props: PlanActionProps) => (
96
+ <CardAction data-slot="plan-action" {...props} />
97
+ );
98
+
99
+ export type PlanContentProps = ComponentProps<typeof CardContent>;
100
+
101
+ export const PlanContent = ({ className, ...props }: PlanContentProps) => (
102
+ <CollapsibleContent asChild>
103
+ <CardContent
104
+ className={cn(
105
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 data-[state=closed]:fade-out-0 data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)] outline-none",
106
+ className,
107
+ )}
108
+ data-slot="plan-content"
109
+ {...props}
110
+ />
111
+ </CollapsibleContent>
112
+ );
113
+
114
+ export type PlanFooterProps = ComponentProps<"div">;
115
+
116
+ export const PlanFooter = (props: PlanFooterProps) => (
117
+ <CardFooter data-slot="plan-footer" {...props} />
118
+ );
119
+
120
+ export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
121
+
122
+ export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
123
+ <CollapsibleTrigger asChild>
124
+ <Button
125
+ className={cn("size-8", className)}
126
+ data-slot="plan-trigger"
127
+ size="icon"
128
+ variant="ghost"
129
+ {...props}
130
+ >
131
+ <ChevronsUpDownIcon className="size-4" />
132
+ <span className="sr-only">Toggle plan</span>
133
+ </Button>
134
+ </CollapsibleTrigger>
135
+ );
@@ -0,0 +1,261 @@
1
+ /**
2
+ * PromptInput — the raw composer FORM primitive `Composer` is built on.
3
+ *
4
+ * Compose `PromptInputBody` / `PromptInputTextarea` / `PromptInputFooter` /
5
+ * `PromptInputTools` / `PromptInputSubmit` yourself when you need a bespoke
6
+ * shell; reach for `<Composer />` for the standard chat input.
7
+ *
8
+ * `tone` picks the inner well's fill: `"surface"` (default, muted — the look
9
+ * every existing usage keeps) or `"card"`, letting the well nest inside an
10
+ * already-tinted outer frame — the "double card" (#254) that `PromptInput`
11
+ * could not express before this prop existed. The `card` well's fill is
12
+ * theme-driven, not universally "white" — raised (lighter than the outer
13
+ * frame) on light themes, recessed (darker) on dark; see the
14
+ * `tone` prop doc.
15
+ */
16
+ import type { Meta, StoryObj } from "@storybook/react-vite";
17
+ import { expect, fn, waitFor } from "storybook/test";
18
+
19
+ import {
20
+ PromptInput,
21
+ PromptInputBody,
22
+ PromptInputFooter,
23
+ PromptInputProvider,
24
+ PromptInputStop,
25
+ PromptInputSubmit,
26
+ PromptInputTextarea,
27
+ PromptInputTools,
28
+ } from "./prompt-input";
29
+
30
+ const meta = {
31
+ title: "AI/PromptInput",
32
+ component: PromptInput,
33
+ parameters: {
34
+ layout: "padded",
35
+ docs: {
36
+ description: {
37
+ component:
38
+ "The raw composer FORM primitive Composer is built on. `tone` picks the inner well's fill — `surface` (default, muted) or `card` — so it can nest inside an already-tinted outer frame (the double card, #254). The `card` fill is theme-driven, not universally white: raised on light themes, recessed on dark — still a distinct, legible tone against the outer frame in every theme.",
39
+ },
40
+ },
41
+ },
42
+ args: {
43
+ onSubmit: () => undefined,
44
+ },
45
+ tags: ["autodocs"],
46
+ } satisfies Meta<typeof PromptInput>;
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof meta>;
50
+
51
+ /** The default `surface` (muted) inner well — the look every existing usage keeps. */
52
+ export const Default: Story = {
53
+ render: (args) => (
54
+ <div className="mx-auto max-w-2xl">
55
+ <PromptInput {...args}>
56
+ <PromptInputBody>
57
+ <PromptInputTextarea />
58
+ </PromptInputBody>
59
+ <PromptInputFooter>
60
+ <PromptInputTools />
61
+ <PromptInputSubmit />
62
+ </PromptInputFooter>
63
+ </PromptInput>
64
+ </div>
65
+ ),
66
+ };
67
+
68
+ /**
69
+ * The "double card" (#254): a tinted `bg-surface-muted` outer frame around a
70
+ * `tone="card"` well. Raised (lighter than the frame) on light themes,
71
+ * recessed (darker) on dark — check both themes here, not
72
+ * just the light default.
73
+ */
74
+ export const DoubleCardToned: Story = {
75
+ render: (args) => (
76
+ <div className="mx-auto max-w-2xl rounded-xl bg-surface-muted p-1.5">
77
+ <PromptInput {...args} tone="card">
78
+ <PromptInputBody>
79
+ <PromptInputTextarea />
80
+ </PromptInputBody>
81
+ <PromptInputFooter>
82
+ <PromptInputTools />
83
+ <PromptInputSubmit />
84
+ </PromptInputFooter>
85
+ </PromptInput>
86
+ </div>
87
+ ),
88
+ };
89
+
90
+ /* -------------------------------------------------------------------------- */
91
+ /* PromptInputSubmit — the merged primary-action contract (#351) */
92
+ /* */
93
+ /* 1. ready/undefined/error -> Send (unchanged) */
94
+ /* 2. running + composer EMPTY -> Stop (unchanged) */
95
+ /* 3. running + composer NON-EMPTY -> Send (the fix — a follow-up can */
96
+ /* always be composed and submitted mid-turn; brand-ui never asserts */
97
+ /* what the app's onSubmit does with it — that's the app's call, D5) */
98
+ /* 4. a PromptInputStop mounted alongside -> this control is ALWAYS Send */
99
+ /* -------------------------------------------------------------------------- */
100
+
101
+ /** At rest: Send, disabled until there is text or an attachment. */
102
+ export const Idle: Story = {
103
+ args: { onSubmit: fn() },
104
+ render: (args) => (
105
+ <div className="mx-auto max-w-2xl">
106
+ <PromptInput {...args}>
107
+ <PromptInputBody>
108
+ <PromptInputTextarea />
109
+ </PromptInputBody>
110
+ <PromptInputFooter>
111
+ <PromptInputTools />
112
+ <PromptInputSubmit />
113
+ </PromptInputFooter>
114
+ </PromptInput>
115
+ </div>
116
+ ),
117
+ play: async ({ canvas }) => {
118
+ const send = await canvas.findByRole("button", { name: "Submit" });
119
+ // `aria-disabled`, not the native `disabled` attribute — see the comment
120
+ // on PromptInputSubmit (prompt-input.tsx) and the "Nothing to submit"
121
+ // exception in .claude/rules/interaction-guidelines.md: a natively
122
+ // disabled control is dropped from the focus order and strands keyboard
123
+ // users right after a keyboard-initiated send.
124
+ await expect(send).toHaveAttribute("aria-disabled", "true");
125
+ },
126
+ };
127
+
128
+ // `onStop` isn't a `PromptInput` prop, so it can't live in typed `args` —
129
+ // each story below keeps its own module-scoped mock, set in `render` and
130
+ // read back in `play`.
131
+ let runningEmptyOnStop: ReturnType<typeof fn>;
132
+
133
+ /** Running, composer empty — the control IS the Stop affordance (unchanged). */
134
+ export const RunningEmpty: Story = {
135
+ args: { onSubmit: fn() },
136
+ render: (args) => {
137
+ runningEmptyOnStop = fn();
138
+ return (
139
+ <div className="mx-auto max-w-2xl">
140
+ <PromptInput {...args}>
141
+ <PromptInputBody>
142
+ <PromptInputTextarea />
143
+ </PromptInputBody>
144
+ <PromptInputFooter>
145
+ <PromptInputTools />
146
+ <PromptInputSubmit status="streaming" onStop={runningEmptyOnStop} />
147
+ </PromptInputFooter>
148
+ </PromptInput>
149
+ </div>
150
+ );
151
+ },
152
+ play: async ({ canvas, args }) => {
153
+ const stop = await canvas.findByRole("button", { name: "Stop" });
154
+ await expect(stop).toHaveAttribute("data-action", "stop");
155
+ await stop.click();
156
+ await waitFor(() => expect(runningEmptyOnStop).toHaveBeenCalledTimes(1));
157
+ await expect(args.onSubmit).not.toHaveBeenCalled();
158
+ },
159
+ };
160
+
161
+ let runningWithDraftOnStop: ReturnType<typeof fn>;
162
+
163
+ /**
164
+ * Running, but the user has typed a follow-up — the P0 fix. The control flips
165
+ * back to Send so the draft can always be submitted mid-turn; brand-ui never
166
+ * asserts what `onSubmit` does with it (queue, interleave — the app decides).
167
+ */
168
+ export const RunningWithDraft: Story = {
169
+ args: { onSubmit: fn() },
170
+ render: (args) => {
171
+ runningWithDraftOnStop = fn();
172
+ return (
173
+ <div className="mx-auto max-w-2xl">
174
+ <PromptInputProvider initialInput="What about the edge cases?">
175
+ <PromptInput {...args}>
176
+ <PromptInputBody>
177
+ <PromptInputTextarea />
178
+ </PromptInputBody>
179
+ <PromptInputFooter>
180
+ <PromptInputTools />
181
+ <PromptInputSubmit status="streaming" onStop={runningWithDraftOnStop} />
182
+ </PromptInputFooter>
183
+ </PromptInput>
184
+ </PromptInputProvider>
185
+ </div>
186
+ );
187
+ },
188
+ play: async ({ canvas, args }) => {
189
+ const send = await canvas.findByRole("button", { name: "Submit" });
190
+ await expect(send).toHaveAttribute("data-action", "send");
191
+ await expect(send).not.toHaveAttribute("data-generating");
192
+ await send.click();
193
+ await waitFor(() => expect(args.onSubmit).toHaveBeenCalledTimes(1));
194
+ await expect(runningWithDraftOnStop).not.toHaveBeenCalled();
195
+ },
196
+ };
197
+
198
+ /** Failed — an X that stays clickable so the user can retry. */
199
+ export const ErrorState: Story = {
200
+ args: { onSubmit: fn() },
201
+ render: (args) => (
202
+ <div className="mx-auto max-w-2xl">
203
+ <PromptInput {...args}>
204
+ <PromptInputBody>
205
+ <PromptInputTextarea />
206
+ </PromptInputBody>
207
+ <PromptInputFooter>
208
+ <PromptInputTools />
209
+ <PromptInputSubmit status="error" />
210
+ </PromptInputFooter>
211
+ </PromptInput>
212
+ </div>
213
+ ),
214
+ play: async ({ canvas }) => {
215
+ const send = await canvas.findByRole("button", { name: "Submit" });
216
+ await expect(send).toBeEnabled();
217
+ },
218
+ };
219
+
220
+ let separateStopControlOnStop: ReturnType<typeof fn>;
221
+
222
+ /**
223
+ * The composed "separate" arrangement: a dedicated `PromptInputStop` beside
224
+ * `PromptInputSubmit`. Never a `mode` prop (component-api.md bans behavioural
225
+ * modes) — compose the two controls instead. While `PromptInputStop` is
226
+ * mounted, `PromptInputSubmit` stays Send in every state; the dedicated
227
+ * control owns stopping.
228
+ */
229
+ export const SeparateStopControl: Story = {
230
+ args: { onSubmit: fn() },
231
+ render: (args) => {
232
+ separateStopControlOnStop = fn();
233
+ return (
234
+ <div className="mx-auto max-w-2xl">
235
+ <PromptInput {...args}>
236
+ <PromptInputBody>
237
+ <PromptInputTextarea />
238
+ </PromptInputBody>
239
+ <PromptInputFooter>
240
+ <PromptInputTools />
241
+ <div className="flex items-center gap-1">
242
+ <PromptInputStop status="streaming" onStop={separateStopControlOnStop} />
243
+ <PromptInputSubmit status="streaming" onStop={separateStopControlOnStop} />
244
+ </div>
245
+ </PromptInputFooter>
246
+ </PromptInput>
247
+ </div>
248
+ );
249
+ },
250
+ play: async ({ canvas }) => {
251
+ const submit = await canvas.findByRole("button", { name: "Submit" });
252
+ await expect(submit).toHaveAttribute("data-action", "send");
253
+ // `aria-disabled`, not the native `disabled` attribute — see the Idle
254
+ // story above and prompt-input.tsx's PromptInputSubmit comment.
255
+ await expect(submit).toHaveAttribute("aria-disabled", "true");
256
+
257
+ const stop = await canvas.findByRole("button", { name: "Stop" });
258
+ await stop.click();
259
+ await waitFor(() => expect(separateStopControlOnStop).toHaveBeenCalledTimes(1));
260
+ },
261
+ };