@elabs-ai/components-ai 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- package/src/web-preview.tsx +283 -0
|
@@ -0,0 +1,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
|
+
});
|
package/src/composer.tsx
ADDED
|
@@ -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
|
+
});
|