@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,182 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { Composer } from "./composer";
5
+
6
+ describe("Composer", () => {
7
+ it("renders the default status line, placeholder, and model pill", () => {
8
+ render(<Composer />);
9
+
10
+ expect(screen.getByText("Awaiting your input")).toBeInTheDocument();
11
+ expect(screen.getByPlaceholderText("Ask me anything…")).toBeInTheDocument();
12
+ expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
13
+ });
14
+
15
+ it("accepts a custom status and placeholder, and hides the model pill when model is null", () => {
16
+ render(<Composer status="Thinking…" placeholder="Ask the agent…" model={null} />);
17
+
18
+ expect(screen.getByText("Thinking…")).toBeInTheDocument();
19
+ expect(screen.getByPlaceholderText("Ask the agent…")).toBeInTheDocument();
20
+ expect(screen.queryByText("Claude Opus 4")).not.toBeInTheDocument();
21
+ });
22
+
23
+ it("renders suggestion chips and fires onSuggestionClick with the chosen suggestion", async () => {
24
+ const onSuggestionClick = vi.fn();
25
+ render(<Composer suggestions={["Summary", "Code"]} onSuggestionClick={onSuggestionClick} />);
26
+
27
+ const chip = screen.getByRole("button", { name: "Summary" });
28
+ await userEvent.click(chip);
29
+ expect(onSuggestionClick).toHaveBeenCalledWith("Summary");
30
+ });
31
+ });
32
+
33
+ describe("Composer — tone prop (double-card arrangement, #254)", () => {
34
+ // The outer frame is the `.rounded-xl` wrapper; `InputGroup` (the well) is
35
+ // `.rounded-md` (data-slot="input-group") nested inside PromptInput's own
36
+ // <form>, so the two are queried independently rather than via parentElement.
37
+ it("defaults to the outer bg-card frame around the standard well — existing usages unaffected", () => {
38
+ const { container } = render(<Composer />);
39
+ const frame = container.querySelector(".rounded-xl") as HTMLElement;
40
+ expect(frame.className.split(" ")).toContain("bg-card");
41
+ expect(frame.className.split(" ")).toContain("border");
42
+
43
+ const well = container.querySelector('[data-slot="input-group"]') as HTMLElement;
44
+ expect(well.className.split(" ")).toContain("bg-surface-muted");
45
+ });
46
+
47
+ it("swaps to an outer bg-surface-muted frame around a bg-card well when tone='card'", () => {
48
+ const { container } = render(<Composer tone="card" />);
49
+ const frame = container.querySelector(".rounded-xl") as HTMLElement;
50
+ expect(frame.className.split(" ")).toContain("bg-surface-muted");
51
+ expect(frame.className.split(" ")).not.toContain("bg-card");
52
+ // The frame keeps its border in BOTH tones: on light it's redundant
53
+ // (the fill already separates it from the page), but under high decoration the
54
+ // decoration dial zeros the shadow — the border becomes the SOLE structural
55
+ // cue there, so it must never be dropped for either tone (see #254 review).
56
+ expect(frame.className.split(" ")).toContain("border");
57
+
58
+ const well = container.querySelector('[data-slot="input-group"]') as HTMLElement;
59
+ expect(well.className.split(" ")).toContain("bg-card");
60
+ expect(well.className.split(" ")).not.toContain("bg-surface-muted");
61
+ });
62
+ });
63
+
64
+ describe("Composer send button — status affordances", () => {
65
+ const arrowUpIn = (el: HTMLElement) => el.querySelector("svg.lucide-arrow-up");
66
+
67
+ it("shows the ArrowUp send glyph at rest", () => {
68
+ render(<Composer />);
69
+ expect(arrowUpIn(screen.getByRole("button", { name: "Submit" }))).not.toBeNull();
70
+ });
71
+
72
+ it("swaps to the Stop affordance while streaming and calls onStop", async () => {
73
+ const onStop = vi.fn();
74
+ render(<Composer sendStatus="streaming" onStop={onStop} />);
75
+
76
+ const stop = screen.getByRole("button", { name: "Stop" });
77
+ // The regression: Composer's ArrowUp used to override the square Stop glyph,
78
+ // so the control was live (onStop fired) but looked like "send".
79
+ expect(arrowUpIn(stop)).toBeNull();
80
+
81
+ await userEvent.click(stop);
82
+ expect(onStop).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it("shows a spinner, not the send arrow, while submitted", () => {
86
+ render(<Composer sendStatus="submitted" />);
87
+ expect(arrowUpIn(screen.getByRole("button", { name: "Stop" }))).toBeNull();
88
+ expect(screen.getByRole("status")).toBeInTheDocument();
89
+ });
90
+
91
+ it("shows the error glyph on error, and keeps the control clickable to retry", () => {
92
+ render(<Composer sendStatus="error" />);
93
+ const button = screen.getByRole("button", { name: "Submit" });
94
+ expect(arrowUpIn(button)).toBeNull();
95
+ expect(button).toBeEnabled();
96
+ });
97
+
98
+ it("marks send aria-disabled at rest, and clears it once text is typed", async () => {
99
+ render(<Composer />);
100
+ const send = screen.getByRole("button", { name: "Submit" });
101
+ expect(send).toHaveAttribute("aria-disabled", "true");
102
+ expect(send).toBeEnabled(); // still a real tab stop — see PromptInputSubmit
103
+
104
+ await userEvent.type(screen.getByPlaceholderText("Ask me anything…"), "hello");
105
+ expect(send).not.toHaveAttribute("aria-disabled");
106
+ });
107
+
108
+ it("does not submit an empty message on Enter", async () => {
109
+ const onSubmit = vi.fn();
110
+ render(<Composer onSubmit={onSubmit} />);
111
+
112
+ await userEvent.type(screen.getByPlaceholderText("Ask me anything…"), "{Enter}");
113
+ expect(onSubmit).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("keeps the ArrowUp send glyph once the user types during a running turn (#351)", async () => {
117
+ const onSubmit = vi.fn();
118
+ render(<Composer sendStatus="streaming" onSubmit={onSubmit} onStop={() => undefined} />);
119
+
120
+ await userEvent.type(screen.getByPlaceholderText("Ask me anything…"), "a follow-up");
121
+
122
+ const control = screen.getByRole("button", { name: "Submit" });
123
+ expect(arrowUpIn(control)).not.toBeNull();
124
+
125
+ await userEvent.click(control);
126
+ expect(onSubmit).toHaveBeenCalledTimes(1);
127
+ });
128
+ });
129
+
130
+ describe("Composer — submitProps reaches the send button", () => {
131
+ it("forwards disabled, closing BOTH the click and the Enter path", async () => {
132
+ const onSubmit = vi.fn();
133
+ render(<Composer onSubmit={onSubmit} submitProps={{ disabled: true }} />);
134
+
135
+ const send = screen.getByRole("button", { name: "Submit" });
136
+ expect(send).toBeDisabled();
137
+
138
+ // The data-loss path this exists to close: PromptInput.handleSubmit calls
139
+ // form.reset() as soon as it ACCEPTS a submit, so refusing inside onSubmit
140
+ // has already destroyed the user's text. Disabling must stop the submit
141
+ // from being accepted at all — via Enter as well as via click, since
142
+ // PromptInputTextarea's Enter handler is a separate route to requestSubmit().
143
+ const textarea = screen.getByRole("textbox");
144
+ await userEvent.type(textarea, "a question worth not losing{Enter}");
145
+
146
+ expect(onSubmit).not.toHaveBeenCalled();
147
+ expect(textarea).toHaveValue("a question worth not losing");
148
+ });
149
+
150
+ it("leaves disabled UNSET when submitProps omits it, so autoDisabled still governs", async () => {
151
+ render(<Composer submitProps={{ id: "send" }} />);
152
+ const send = document.getElementById("send")!;
153
+ // Empty composer → the library's own guard must still refuse. It does so via
154
+ // `aria-disabled`, NOT the native attribute, so the control stays a real
155
+ // focusable tab stop (a natively-disabled button drops focus to <body> after
156
+ // every keyboard send). A defaulted literal `false` for `disabled` would
157
+ // still defeat the guard, which is why Composer leaves it undefined.
158
+ expect(send).toHaveAttribute("aria-disabled", "true");
159
+ expect(send).toBeEnabled();
160
+
161
+ await userEvent.type(screen.getByRole("textbox"), "hi");
162
+ expect(send).not.toHaveAttribute("aria-disabled");
163
+ });
164
+
165
+ it("extends the round shape rather than replacing it when given className", () => {
166
+ render(<Composer submitProps={{ className: "ring-2" }} />);
167
+ const send = screen.getByRole("button", { name: "Submit" });
168
+ expect(send).toHaveClass("rounded-full");
169
+ expect(send).toHaveClass("ring-2");
170
+ });
171
+ });
172
+
173
+ describe("Composer — icon-only buttons have an accessible name (#356)", () => {
174
+ it("exposes 'Attach files' and 'Voice' as accessible names, derived from the tooltip alone", () => {
175
+ render(<Composer />);
176
+
177
+ // Both controls are icon-only (no visible text) — their ONLY name source
178
+ // is `tooltip`. Before the fix these had no accessible name at all.
179
+ expect(screen.getByRole("button", { name: "Attach files" })).toBeInTheDocument();
180
+ expect(screen.getByRole("button", { name: "Voice" })).toBeInTheDocument();
181
+ });
182
+ });
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps, ReactNode } from "react";
4
+ import { ArrowUp, ChevronDown, Globe, Mic, Paperclip, Sparkles } from "lucide-react";
5
+ import { cn, useLocale } from "@elabs-ai/components-ui";
6
+
7
+ import {
8
+ PromptInput,
9
+ PromptInputBody,
10
+ PromptInputButton,
11
+ PromptInputFooter,
12
+ PromptInputSubmit,
13
+ PromptInputTextarea,
14
+ PromptInputTools,
15
+ type PromptInputProps,
16
+ } from "./prompt-input";
17
+ import { Suggestion, Suggestions } from "./suggestion";
18
+
19
+ export interface ComposerProps {
20
+ /** Submit handler — receives the assembled message (text + attachments). */
21
+ onSubmit?: PromptInputProps["onSubmit"];
22
+ /** Textarea placeholder. Default `"Ask me anything…"`. */
23
+ placeholder?: string;
24
+ /**
25
+ * Muted status line shown above the input well. Default `"Awaiting your
26
+ * input"`. Drive it from chat state ("Thinking…", "Generating…") or pass
27
+ * `null` to hide the strip entirely.
28
+ */
29
+ status?: ReactNode;
30
+ /** Model-selector pill label. Default `"Claude Opus 4"`; pass `null` to hide it. */
31
+ model?: ReactNode;
32
+ /**
33
+ * Override the left-hand tool cluster (attach + model pill) — e.g. to add a
34
+ * web-search or connect-data control. Render `PromptInputButton`s /
35
+ * `PromptInputActionMenu` here; when set, `showAttach`/`model` are ignored.
36
+ */
37
+ tools?: ReactNode;
38
+ /** Send-button state, forwarded to `PromptInputSubmit` (ready/submitted/streaming/error). */
39
+ sendStatus?: ComponentProps<typeof PromptInputSubmit>["status"];
40
+ /** Stop handler used while the send button shows the generating state. */
41
+ onStop?: ComponentProps<typeof PromptInputSubmit>["onStop"];
42
+ /**
43
+ * Extra props spread onto the send button — `disabled`, `aria-label`,
44
+ * `variant`, `id`, `className`. `status` and `onStop` are excluded because
45
+ * `Composer` owns them via `sendStatus` / `onStop` above. (A `data-*` key is
46
+ * not assignable through this object: TypeScript only permits arbitrary
47
+ * `data-*` on a JSX element directly, not on a typed props object. Reach for
48
+ * `id` or `aria-label` as the test hook.)
49
+ *
50
+ * **`disabled` is the one that matters for correctness.** `PromptInput`'s
51
+ * submit handler calls `form.reset()` as soon as it ACCEPTS a submit, so a
52
+ * consumer that refuses the send inside `onSubmit` — an app doing async setup
53
+ * on the first message, say — has already had the textarea cleared and the
54
+ * user's text destroyed, with nothing to restore from. Disabling the control
55
+ * is what actually prevents it: `PromptInputTextarea`'s Enter handler checks
56
+ * `submitControl?.disabled` and bails before `requestSubmit()`, so the Enter
57
+ * path is closed too, not just the click.
58
+ *
59
+ * Leave it UNSET rather than passing `false` when you have no opinion —
60
+ * `PromptInputSubmit` resolves `disabled ?? autoDisabled`, so a literal
61
+ * `false` opts out of the library's own empty-composer guard.
62
+ */
63
+ submitProps?: Omit<ComponentProps<typeof PromptInputSubmit>, "status" | "onStop">;
64
+ /**
65
+ * The two-tone arrangement, forwarded to the `tone` prop of `PromptInput`.
66
+ * `"surface"` (default, unchanged) is the original Composer look — an outer
67
+ * `bg-card` frame around the standard muted `PromptInput` well — so every
68
+ * existing usage is unaffected. `"card"` swaps to the tinted-outer/
69
+ * distinct-inner "double card" (#254): an outer `bg-surface-muted` frame
70
+ * around a `tone="card"` well. The well fill relative to the frame is
71
+ * theme-driven, not universally "white" — raised on light themes, recessed
72
+ * on dark; see the `tone` prop doc on `PromptInput`.
73
+ */
74
+ tone?: PromptInputProps["tone"];
75
+ /** Optional suggestion chips rendered beneath the composer. */
76
+ suggestions?: string[];
77
+ /** Click handler for a suggestion chip. */
78
+ onSuggestionClick?: (suggestion: string) => void;
79
+ /** Show the voice button. Default `true`. */
80
+ showVoice?: boolean;
81
+ /** Show the attach button. Default `true`. */
82
+ showAttach?: boolean;
83
+ /** Extra classes for the outer card frame. */
84
+ className?: string;
85
+ }
86
+
87
+ /**
88
+ * Composer — the standard brand-ui AI chat input.
89
+ *
90
+ * A rounded two-tone "double card": a status strip wrapping a recessed
91
+ * `PromptInput` well (sharp top, theme-rounded bottom), a model pill, voice,
92
+ * and a circular send. Built on the real `PromptInput`, so it drops into a
93
+ * `ChatShell` footer or stands alone as an empty-state composer. `tone`
94
+ * (default `"surface"`, unchanged) picks the arrangement: `"surface"` keeps
95
+ * the original outer `bg-card` frame around the standard muted well;
96
+ * `"card"` (#254) swaps to an outer `bg-surface-muted` frame around a
97
+ * `tone="card"` well — the well fill is theme-driven (raised on light
98
+ * themes, recessed on dark), not universally white; see the
99
+ * `tone` prop doc on `PromptInput`. Semantic tokens only; theme-aware radii
100
+ * (`rounded-xl` frame / `rounded-b-lg` well); reads in every theme.
101
+ *
102
+ * This is the canonical chat input — reach for it instead of hand-rolling a
103
+ * `PromptInput` footer.
104
+ */
105
+ export function Composer({
106
+ onSubmit,
107
+ placeholder,
108
+ status = "Awaiting your input",
109
+ model = "Claude Opus 4",
110
+ tools,
111
+ sendStatus,
112
+ onStop,
113
+ submitProps,
114
+ suggestions,
115
+ onSuggestionClick,
116
+ showVoice = true,
117
+ showAttach = true,
118
+ tone = "surface",
119
+ className,
120
+ }: ComposerProps) {
121
+ const { t } = useLocale();
122
+
123
+ return (
124
+ <div className="w-full">
125
+ <div
126
+ className={cn(
127
+ "rounded-xl p-1.5 border",
128
+ tone === "card" ? "bg-surface-muted" : "bg-card shadow-sm",
129
+ className,
130
+ )}
131
+ >
132
+ {/* Muted status strip — sits in the outer frame above the input well. */}
133
+ {status != null ? (
134
+ <div className="flex items-center gap-1.5 px-3 py-1.5 text-meta text-muted-foreground">
135
+ <Sparkles className="size-3.5" aria-hidden="true" />
136
+ <span>{status}</span>
137
+ </div>
138
+ ) : null}
139
+
140
+ {/* Inner well — sharp top (nests under the strip), theme-rounded bottom. */}
141
+ <PromptInput
142
+ onSubmit={onSubmit ?? (() => undefined)}
143
+ surfaceClassName="rounded-t-none rounded-b-lg"
144
+ tone={tone}
145
+ >
146
+ <PromptInputBody>
147
+ <PromptInputTextarea placeholder={placeholder ?? t("ai.composer.placeholder")} />
148
+ </PromptInputBody>
149
+ <PromptInputFooter>
150
+ <PromptInputTools>
151
+ {tools ?? (
152
+ <>
153
+ {showAttach ? (
154
+ <PromptInputButton tooltip="Attach files">
155
+ <Paperclip className="size-4" />
156
+ </PromptInputButton>
157
+ ) : null}
158
+ {model != null ? (
159
+ <PromptInputButton variant="ghost" className="gap-1.5 rounded-full">
160
+ <Globe className="size-4" />
161
+ <span>{model}</span>
162
+ <ChevronDown className="size-4 opacity-60" />
163
+ </PromptInputButton>
164
+ ) : null}
165
+ </>
166
+ )}
167
+ </PromptInputTools>
168
+ <div className="flex items-center gap-1">
169
+ {showVoice ? (
170
+ <PromptInputButton tooltip="Voice">
171
+ <Mic className="size-4" />
172
+ </PromptInputButton>
173
+ ) : null}
174
+ {/* `sendIcon` (not `children`) survives the send↔stop flip: the
175
+ circular ArrowUp shows at rest AND once a follow-up is typed
176
+ during a running turn (#351), while PromptInputSubmit's own
177
+ status glyphs (spinner / stop square / error) still render
178
+ whenever the control IS the Stop action. */}
179
+ <PromptInputSubmit
180
+ status={sendStatus}
181
+ onStop={onStop}
182
+ sendIcon={<ArrowUp className="size-4" />}
183
+ {...submitProps}
184
+ // After the spread so a caller's `className` EXTENDS the round
185
+ // shape instead of replacing it; every other key still wins.
186
+ className={cn("rounded-full", submitProps?.className)}
187
+ />
188
+ </div>
189
+ </PromptInputFooter>
190
+ </PromptInput>
191
+ </div>
192
+
193
+ {suggestions && suggestions.length > 0 ? (
194
+ <Suggestions className="mt-4 justify-center">
195
+ {suggestions.map((s) => (
196
+ <Suggestion key={s} suggestion={s} onClick={() => onSuggestionClick?.(s)} />
197
+ ))}
198
+ </Suggestions>
199
+ ) : null}
200
+ </div>
201
+ );
202
+ }
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import {
3
+ ApprovalCard,
4
+ ApprovalCardAccepted,
5
+ ApprovalCardActions,
6
+ ApprovalCardApprove,
7
+ ApprovalCardDeny,
8
+ ApprovalCardDescription,
9
+ ApprovalCardRejected,
10
+ ApprovalCardRequest,
11
+ ApprovalCardTitle,
12
+ } from "./confirmation";
13
+ const meta = {
14
+ title: "AI/ApprovalCard",
15
+ component: ApprovalCard,
16
+ tags: ["autodocs"],
17
+ parameters: { layout: "padded" },
18
+ } satisfies Meta<typeof ApprovalCard>;
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+ // Pending = the attention treatment (#191, research 11 §B.3): warning wash +
22
+ // hue-independent structural rail + lift; three zones; Approve/Deny grammar.
23
+ export const Pending: Story = {
24
+ render: () => (
25
+ <ApprovalCard approval={{ id: "call-1" }} state="approval-requested">
26
+ <ApprovalCardRequest>
27
+ <ApprovalCardTitle>Post the final note to #finance?</ApprovalCardTitle>
28
+ <ApprovalCardDescription>
29
+ Posts the summary as Atlas; visible to 42 people in the channel.
30
+ </ApprovalCardDescription>
31
+ <ApprovalCardActions>
32
+ <ApprovalCardDeny>Deny</ApprovalCardDeny>
33
+ <ApprovalCardApprove>Approve</ApprovalCardApprove>
34
+ </ApprovalCardActions>
35
+ </ApprovalCardRequest>
36
+ </ApprovalCard>
37
+ ),
38
+ };
39
+ export const Approved: Story = {
40
+ render: () => (
41
+ <ApprovalCard approval={{ id: "call-1", approved: true }} state="approval-responded">
42
+ <ApprovalCardAccepted>
43
+ <ApprovalCardDescription>The note was posted to #finance.</ApprovalCardDescription>
44
+ </ApprovalCardAccepted>
45
+ </ApprovalCard>
46
+ ),
47
+ };
48
+ export const Denied: Story = {
49
+ render: () => (
50
+ <ApprovalCard
51
+ approval={{ id: "call-1", approved: false, reason: "Numbers not final" }}
52
+ state="approval-responded"
53
+ >
54
+ <ApprovalCardRejected>
55
+ <ApprovalCardDescription>
56
+ Denied — the quarter close numbers are not final yet.
57
+ </ApprovalCardDescription>
58
+ </ApprovalCardRejected>
59
+ </ApprovalCard>
60
+ ),
61
+ };
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import {
4
+ ApprovalCard,
5
+ ApprovalCardActions,
6
+ ApprovalCardApprove,
7
+ ApprovalCardDeny,
8
+ ApprovalCardDescription,
9
+ ApprovalCardRequest,
10
+ ApprovalCardTitle,
11
+ Confirmation,
12
+ ConfirmationAccepted,
13
+ ConfirmationAction,
14
+ ConfirmationActions,
15
+ ConfirmationApprove,
16
+ ConfirmationDeny,
17
+ ConfirmationRejected,
18
+ ConfirmationRequest,
19
+ ConfirmationTitle,
20
+ } from "./confirmation";
21
+
22
+ describe("Confirmation outcome tone (#7) — quiet rail, no fill", () => {
23
+ it("uses the success RAIL + a status icon when accepted (no colored wash)", () => {
24
+ const { container } = render(
25
+ <Confirmation state="approval-responded" approval={{ id: "t", approved: true }}>
26
+ <ConfirmationAccepted>
27
+ <ConfirmationTitle>Approved</ConfirmationTitle>
28
+ </ConfirmationAccepted>
29
+ </Confirmation>,
30
+ );
31
+ const alert = screen.getByRole("alert");
32
+ expect(alert.className).toContain("border-s-success");
33
+ expect(alert.className).toContain("border-s-4");
34
+ // Quiet rail: a neutral card ground, NOT a colored wash.
35
+ expect(alert.className).not.toContain("bg-success/10");
36
+ expect(alert.className).not.toContain("bg-destructive/10");
37
+ expect(container.querySelector("svg")).not.toBeNull();
38
+ });
39
+
40
+ it("uses the destructive RAIL when rejected (no colored wash)", () => {
41
+ render(
42
+ <Confirmation state="approval-responded" approval={{ id: "t", approved: false }}>
43
+ <ConfirmationRejected>
44
+ <ConfirmationTitle>Denied</ConfirmationTitle>
45
+ </ConfirmationRejected>
46
+ </Confirmation>,
47
+ );
48
+ const alert = screen.getByRole("alert");
49
+ expect(alert.className).toContain("border-s-destructive");
50
+ expect(alert.className).not.toContain("bg-destructive/10");
51
+ expect(alert.className).not.toContain("bg-success/10");
52
+ });
53
+ });
54
+
55
+ describe("ApprovalCard pending treatment (#191, research 11 §B.3)", () => {
56
+ const pending = (
57
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
58
+ <ConfirmationRequest>
59
+ <ConfirmationTitle>Post the final note to #finance?</ConfirmationTitle>
60
+ <ApprovalCardDescription>Visible to 42 people.</ApprovalCardDescription>
61
+ <ConfirmationActions>
62
+ <ConfirmationDeny>Deny</ConfirmationDeny>
63
+ <ConfirmationApprove>Approve</ConfirmationApprove>
64
+ </ConfirmationActions>
65
+ </ConfirmationRequest>
66
+ </Confirmation>
67
+ );
68
+
69
+ it("renders a quiet structural rail + lift while pending (no warning wash)", () => {
70
+ render(pending);
71
+ const card = screen.getByRole("group");
72
+ expect(card.className).not.toContain("bg-warning/10");
73
+ expect(card.className).toContain("border-s-4");
74
+ expect(card.className).toContain("border-s-border-strong");
75
+ expect(card.className).toContain("shadow-sm");
76
+ });
77
+
78
+ it("is a labelled group (NOT role=alert) while pending", () => {
79
+ render(pending);
80
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
81
+ const card = screen.getByRole("group");
82
+ const labelledBy = card.getAttribute("aria-labelledby");
83
+ expect(labelledBy).toBeTruthy();
84
+ expect(screen.getByText("Post the final note to #finance?").id).toBe(labelledBy);
85
+ });
86
+
87
+ it("renders the three zones: up-scaled title, consequence, separated action band", () => {
88
+ render(pending);
89
+ const title = screen.getByText("Post the final note to #finance?");
90
+ expect(title.className).toContain("text-subtitle");
91
+ expect(screen.getByText("Visible to 42 people.")).toBeInTheDocument();
92
+ const band = screen.getByText("Approve").parentElement;
93
+ expect(band?.className).toContain("border-t");
94
+ expect(band?.className).toContain("border-border-strong");
95
+ });
96
+
97
+ it("uses the filled primary Approve + ghost Deny button grammar (never outline)", () => {
98
+ render(pending);
99
+ const approve = screen.getByText("Approve");
100
+ const deny = screen.getByText("Deny");
101
+ expect(approve.className).toContain("bg-primary");
102
+ expect(deny.className).not.toContain("bg-primary");
103
+ expect(deny.className).not.toContain("border-input");
104
+ expect(deny.className).toContain("hover:bg-accent");
105
+ });
106
+
107
+ it("hides the actions once resolved", () => {
108
+ render(
109
+ <Confirmation state="approval-responded" approval={{ id: "t", approved: true }}>
110
+ <ConfirmationActions>
111
+ <ConfirmationAction>Approve</ConfirmationAction>
112
+ </ConfirmationActions>
113
+ </Confirmation>,
114
+ );
115
+ expect(screen.queryByText("Approve")).not.toBeInTheDocument();
116
+ });
117
+ });
118
+
119
+ describe("ApprovalCard alias", () => {
120
+ it("exports the promoted ApprovalCard* front door over the same compound", () => {
121
+ render(
122
+ <ApprovalCard state="approval-requested" approval={{ id: "t" }}>
123
+ <ApprovalCardRequest>
124
+ <ApprovalCardTitle>Run the migration?</ApprovalCardTitle>
125
+ <ApprovalCardActions>
126
+ <ApprovalCardDeny>Deny</ApprovalCardDeny>
127
+ <ApprovalCardApprove>Approve</ApprovalCardApprove>
128
+ </ApprovalCardActions>
129
+ </ApprovalCardRequest>
130
+ </ApprovalCard>,
131
+ );
132
+ expect(screen.getByRole("group")).toBeInTheDocument();
133
+ expect(screen.getByText("Run the migration?")).toBeInTheDocument();
134
+ expect(screen.getByText("Approve").className).toContain("bg-primary");
135
+ });
136
+ });