@elabs-ai/components-ai 4.0.0 → 4.1.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/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Plan,
|
|
6
|
+
PlanApprove,
|
|
7
|
+
PlanComment,
|
|
8
|
+
PlanContent,
|
|
9
|
+
PlanDescription,
|
|
10
|
+
PlanFooter,
|
|
11
|
+
PlanHeader,
|
|
12
|
+
PlanRequestChanges,
|
|
13
|
+
PlanStatusLine,
|
|
14
|
+
PlanTitle,
|
|
15
|
+
} from "./plan";
|
|
16
|
+
|
|
17
|
+
describe("Plan decision contract (#108)", () => {
|
|
18
|
+
it("exposes a labelled GROUP (not role=alert) containing the three actions while awaiting", () => {
|
|
19
|
+
render(
|
|
20
|
+
<Plan status="awaiting">
|
|
21
|
+
<PlanHeader>
|
|
22
|
+
<PlanTitle>Post the final note to #finance?</PlanTitle>
|
|
23
|
+
</PlanHeader>
|
|
24
|
+
<PlanFooter>
|
|
25
|
+
<PlanComment>Comment</PlanComment>
|
|
26
|
+
<PlanRequestChanges>Request changes</PlanRequestChanges>
|
|
27
|
+
<PlanApprove>Approve</PlanApprove>
|
|
28
|
+
</PlanFooter>
|
|
29
|
+
</Plan>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
33
|
+
|
|
34
|
+
const group = screen.getByRole("group");
|
|
35
|
+
const labelledBy = group.getAttribute("aria-labelledby");
|
|
36
|
+
expect(labelledBy).toBeTruthy();
|
|
37
|
+
expect(screen.getByText("Post the final note to #finance?").id).toBe(labelledBy);
|
|
38
|
+
|
|
39
|
+
expect(screen.getByText("Comment")).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText("Request changes")).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByText("Approve")).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("presets the filled primary variant on PlanApprove, never outline/ghost", () => {
|
|
45
|
+
render(
|
|
46
|
+
<Plan status="awaiting">
|
|
47
|
+
<PlanFooter>
|
|
48
|
+
<PlanComment>Comment</PlanComment>
|
|
49
|
+
<PlanRequestChanges>Request changes</PlanRequestChanges>
|
|
50
|
+
<PlanApprove>Approve</PlanApprove>
|
|
51
|
+
</PlanFooter>
|
|
52
|
+
</Plan>,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(screen.getByText("Approve").className).toContain("bg-primary");
|
|
56
|
+
expect(screen.getByText("Request changes").className).not.toContain("bg-primary");
|
|
57
|
+
expect(screen.getByText("Comment").className).not.toContain("bg-primary");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each([
|
|
61
|
+
["approved", "Approved"],
|
|
62
|
+
["changes-requested", "Changes requested"],
|
|
63
|
+
] as const)("exposes role=alert with no actions once settled (%s)", (status, label) => {
|
|
64
|
+
render(
|
|
65
|
+
<Plan status={status}>
|
|
66
|
+
<PlanFooter>
|
|
67
|
+
<PlanComment>Comment</PlanComment>
|
|
68
|
+
<PlanRequestChanges>Request changes</PlanRequestChanges>
|
|
69
|
+
<PlanApprove>Approve</PlanApprove>
|
|
70
|
+
<PlanStatusLine />
|
|
71
|
+
</PlanFooter>
|
|
72
|
+
</Plan>,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
76
|
+
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
|
77
|
+
expect(screen.queryByText("Comment")).not.toBeInTheDocument();
|
|
78
|
+
expect(screen.queryByText("Request changes")).not.toBeInTheDocument();
|
|
79
|
+
expect(screen.queryByText("Approve")).not.toBeInTheDocument();
|
|
80
|
+
|
|
81
|
+
// Recoverable in greyscale: the outcome is in TEXT, not only rail colour.
|
|
82
|
+
expect(screen.getByText(label)).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("renders the outcome with a non-text glyph alongside the label (WCAG 1.4.1)", () => {
|
|
86
|
+
render(
|
|
87
|
+
<Plan status="approved">
|
|
88
|
+
<PlanFooter>
|
|
89
|
+
<PlanStatusLine />
|
|
90
|
+
</PlanFooter>
|
|
91
|
+
</Plan>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const alert = screen.getByRole("alert");
|
|
95
|
+
expect(alert.querySelector("svg")).not.toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders no decision UI while streaming", () => {
|
|
99
|
+
render(
|
|
100
|
+
<Plan status="streaming">
|
|
101
|
+
<PlanHeader>
|
|
102
|
+
<PlanTitle>Drafting…</PlanTitle>
|
|
103
|
+
</PlanHeader>
|
|
104
|
+
<PlanFooter>
|
|
105
|
+
<PlanComment>Comment</PlanComment>
|
|
106
|
+
<PlanRequestChanges>Request changes</PlanRequestChanges>
|
|
107
|
+
<PlanApprove>Approve</PlanApprove>
|
|
108
|
+
<PlanStatusLine />
|
|
109
|
+
</PlanFooter>
|
|
110
|
+
</Plan>,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
|
114
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
115
|
+
expect(screen.queryByText("Approve")).not.toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it.each([
|
|
119
|
+
["approved", "Approved"],
|
|
120
|
+
["changes-requested", "Changes requested"],
|
|
121
|
+
] as const)(
|
|
122
|
+
"does not re-announce the settled outcome on a redundant re-render with an unchanged status (%s)",
|
|
123
|
+
(status, label) => {
|
|
124
|
+
// A helper that returns a FRESH element tree on every call — mirrors a
|
|
125
|
+
// real parent re-rendering for an unrelated reason (new element objects,
|
|
126
|
+
// identical prop VALUES). Passing the exact same element object to
|
|
127
|
+
// `rerender` would let React bail out of calling `Plan` at all (a
|
|
128
|
+
// referential-props short-circuit), which would make this lock vacuous.
|
|
129
|
+
const renderPlan = () => (
|
|
130
|
+
<Plan status={status}>
|
|
131
|
+
<PlanFooter>
|
|
132
|
+
<PlanStatusLine />
|
|
133
|
+
</PlanFooter>
|
|
134
|
+
</Plan>
|
|
135
|
+
);
|
|
136
|
+
const { rerender } = render(renderPlan());
|
|
137
|
+
|
|
138
|
+
const alertBefore = screen.getByRole("alert");
|
|
139
|
+
expect(alertBefore).toHaveTextContent(label);
|
|
140
|
+
|
|
141
|
+
// A settled `role="alert"` is an assertive live region: it is announced
|
|
142
|
+
// when it is FIRST INSERTED, or when its observable content changes — an
|
|
143
|
+
// AT re-announces it if either happens again. Watch the region for both
|
|
144
|
+
// via a real MutationObserver (jsdom implements it), so this is an
|
|
145
|
+
// observable-effect assertion, not a guess about React internals.
|
|
146
|
+
const observer = new MutationObserver(() => {});
|
|
147
|
+
observer.observe(alertBefore, {
|
|
148
|
+
childList: true,
|
|
149
|
+
subtree: true,
|
|
150
|
+
characterData: true,
|
|
151
|
+
attributes: true,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// A parent re-rendering for an unrelated reason, passing the SAME
|
|
155
|
+
// status again with a freshly-built element tree, must be a no-op for
|
|
156
|
+
// the settled region.
|
|
157
|
+
rerender(renderPlan());
|
|
158
|
+
|
|
159
|
+
const mutations = observer.takeRecords();
|
|
160
|
+
observer.disconnect();
|
|
161
|
+
|
|
162
|
+
expect(mutations).toHaveLength(0);
|
|
163
|
+
// Same DOM node — not unmounted/remounted, which would itself be a
|
|
164
|
+
// fresh live-region insertion and would re-trigger the announcement.
|
|
165
|
+
expect(screen.getByRole("alert")).toBe(alertBefore);
|
|
166
|
+
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("Plan legacy (no `status`) API stays byte-compatible (#108)", () => {
|
|
172
|
+
const legacy = (
|
|
173
|
+
<Plan>
|
|
174
|
+
<PlanHeader>
|
|
175
|
+
<PlanTitle>Draft the Q3 board note</PlanTitle>
|
|
176
|
+
<PlanDescription>Retrieve filings, reconcile, then summarize.</PlanDescription>
|
|
177
|
+
</PlanHeader>
|
|
178
|
+
<PlanContent>Body</PlanContent>
|
|
179
|
+
</Plan>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
it("renders no ARIA decision role at all", () => {
|
|
183
|
+
render(legacy);
|
|
184
|
+
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
|
185
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("produces the exact pre-change markup for the root Card (no role, no aria-labelledby, no rail)", () => {
|
|
189
|
+
const { container } = render(legacy);
|
|
190
|
+
const card = container.firstElementChild as HTMLElement;
|
|
191
|
+
expect(card.getAttribute("role")).toBeNull();
|
|
192
|
+
expect(card.getAttribute("aria-labelledby")).toBeNull();
|
|
193
|
+
expect(card.className).not.toContain("border-s-");
|
|
194
|
+
expect(card.className).toContain("shadow-none");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("PlanTitle renders no `id` attribute when no status is set", () => {
|
|
198
|
+
render(legacy);
|
|
199
|
+
expect(screen.getByText("Draft the Q3 board note").hasAttribute("id")).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("PlanApprove/PlanRequestChanges/PlanComment render nothing without an awaiting status", () => {
|
|
203
|
+
render(
|
|
204
|
+
<Plan>
|
|
205
|
+
<PlanApprove>Approve</PlanApprove>
|
|
206
|
+
<PlanRequestChanges>Request changes</PlanRequestChanges>
|
|
207
|
+
<PlanComment>Comment</PlanComment>
|
|
208
|
+
</Plan>,
|
|
209
|
+
);
|
|
210
|
+
expect(screen.queryByText("Approve")).not.toBeInTheDocument();
|
|
211
|
+
expect(screen.queryByText("Request changes")).not.toBeInTheDocument();
|
|
212
|
+
expect(screen.queryByText("Comment")).not.toBeInTheDocument();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("PlanStatusLine renders nothing without a status", () => {
|
|
216
|
+
render(
|
|
217
|
+
<Plan>
|
|
218
|
+
<PlanStatusLine />
|
|
219
|
+
</Plan>,
|
|
220
|
+
);
|
|
221
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
222
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("isStreaming keeps shimmering the title exactly as before", () => {
|
|
226
|
+
render(
|
|
227
|
+
<Plan isStreaming>
|
|
228
|
+
<PlanHeader>
|
|
229
|
+
<PlanTitle>Draft the Q3 board note</PlanTitle>
|
|
230
|
+
</PlanHeader>
|
|
231
|
+
</Plan>,
|
|
232
|
+
);
|
|
233
|
+
// Shimmer renders the text via its own motion span, not a plain text node.
|
|
234
|
+
expect(screen.getByText("Draft the Q3 board note")).toBeInTheDocument();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("Plan `status` supersedes `isStreaming` when both are given (#108)", () => {
|
|
239
|
+
it("a settled status stops the shimmer even when isStreaming is still true", () => {
|
|
240
|
+
render(
|
|
241
|
+
<Plan status="approved" isStreaming>
|
|
242
|
+
<PlanHeader>
|
|
243
|
+
<PlanTitle>Draft the Q3 board note</PlanTitle>
|
|
244
|
+
</PlanHeader>
|
|
245
|
+
<PlanFooter>
|
|
246
|
+
<PlanStatusLine />
|
|
247
|
+
</PlanFooter>
|
|
248
|
+
</Plan>,
|
|
249
|
+
);
|
|
250
|
+
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
251
|
+
expect(screen.getByText("Approved")).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("status='streaming' plus isStreaming=false still shimmers", () => {
|
|
255
|
+
render(
|
|
256
|
+
<Plan status="streaming" isStreaming={false}>
|
|
257
|
+
<PlanHeader>
|
|
258
|
+
<PlanTitle>Draft the Q3 board note</PlanTitle>
|
|
259
|
+
</PlanHeader>
|
|
260
|
+
</Plan>,
|
|
261
|
+
);
|
|
262
|
+
// No decision role while streaming, and the title still renders.
|
|
263
|
+
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
|
264
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
265
|
+
expect(screen.getByText("Draft the Q3 board note")).toBeInTheDocument();
|
|
266
|
+
});
|
|
267
|
+
});
|
package/src/plan.tsx
CHANGED
|
@@ -11,15 +11,28 @@ import {
|
|
|
11
11
|
CardTitle,
|
|
12
12
|
} from "@elabs-ai/components-ui";
|
|
13
13
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
14
|
+
import { StatusBadge, type StatusTone } from "@elabs-ai/components-ui";
|
|
15
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
14
16
|
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";
|
|
17
|
+
import { CheckCircle2, ChevronsUpDownIcon, Clock, PencilLine, type LucideIcon } from "lucide-react";
|
|
18
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
19
|
+
import { createContext, useContext, useId, useMemo } from "react";
|
|
18
20
|
|
|
19
21
|
import { Shimmer } from "./shimmer";
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The plan's decision status (#108). Additive: a `Plan` rendered with no
|
|
25
|
+
* `status` behaves exactly as before (no ARIA region, no rail, no status
|
|
26
|
+
* line) — every prop below this point is opt-in.
|
|
27
|
+
*/
|
|
28
|
+
export type PlanStatus = "streaming" | "awaiting" | "approved" | "changes-requested";
|
|
29
|
+
|
|
21
30
|
interface PlanContextValue {
|
|
22
31
|
isStreaming: boolean;
|
|
32
|
+
/** `undefined` when the plan is rendered via the legacy `isStreaming`-only API. */
|
|
33
|
+
status?: PlanStatus;
|
|
34
|
+
/** Wired to `PlanTitle` so the awaiting region can be labelled by it. */
|
|
35
|
+
titleId?: string;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
const PlanContext = createContext<PlanContextValue | null>(null);
|
|
@@ -34,15 +47,72 @@ const usePlan = () => {
|
|
|
34
47
|
|
|
35
48
|
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
|
36
49
|
isStreaming?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* The plan's decision status. Omit it to keep today's display-only
|
|
52
|
+
* behaviour. When both `status` and `isStreaming` are given, `status`
|
|
53
|
+
* wins (a `status="approved"` plan never shimmers, even if `isStreaming`
|
|
54
|
+
* is still `true`).
|
|
55
|
+
*/
|
|
56
|
+
status?: PlanStatus;
|
|
37
57
|
};
|
|
38
58
|
|
|
39
|
-
export const Plan = ({
|
|
40
|
-
|
|
59
|
+
export const Plan = ({
|
|
60
|
+
className,
|
|
61
|
+
isStreaming = false,
|
|
62
|
+
status,
|
|
63
|
+
role,
|
|
64
|
+
children,
|
|
65
|
+
...props
|
|
66
|
+
}: PlanProps) => {
|
|
67
|
+
const titleId = useId();
|
|
68
|
+
const hasStatus = status !== undefined;
|
|
69
|
+
const resolvedIsStreaming = hasStatus ? status === "streaming" : isStreaming;
|
|
70
|
+
|
|
71
|
+
// A pending decision containing focusable controls is a labelled region,
|
|
72
|
+
// NOT an assertive live region; a settled outcome is `role="alert"` — the
|
|
73
|
+
// same convention `Confirmation` already proves (packages/ai/src/confirmation.tsx:100-101).
|
|
74
|
+
const isPending = status === "awaiting";
|
|
75
|
+
const isSettled = status === "approved" || status === "changes-requested";
|
|
76
|
+
const resolvedRole = hasStatus
|
|
77
|
+
? isPending
|
|
78
|
+
? "group"
|
|
79
|
+
: isSettled
|
|
80
|
+
? "alert"
|
|
81
|
+
: undefined
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
// Quiet structural rail per outcome — the outcome itself is always ALSO
|
|
85
|
+
// carried in text + glyph via `PlanStatusLine` (WCAG 1.4.1), the rail is a
|
|
86
|
+
// redundant, hue-independent cue on top of it.
|
|
87
|
+
const railClass = !hasStatus
|
|
88
|
+
? undefined
|
|
89
|
+
: status === "approved"
|
|
90
|
+
? "border-s-4 border-s-success"
|
|
91
|
+
: status === "changes-requested"
|
|
92
|
+
? "border-s-4 border-s-warning"
|
|
93
|
+
: isPending
|
|
94
|
+
? "border-s-4 border-s-border-strong"
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
const contextValue = useMemo<PlanContextValue>(
|
|
98
|
+
() => ({
|
|
99
|
+
isStreaming: resolvedIsStreaming,
|
|
100
|
+
status: hasStatus ? status : undefined,
|
|
101
|
+
titleId: hasStatus ? titleId : undefined,
|
|
102
|
+
}),
|
|
103
|
+
[resolvedIsStreaming, hasStatus, status, titleId],
|
|
104
|
+
);
|
|
41
105
|
|
|
42
106
|
return (
|
|
43
107
|
<PlanContext.Provider value={contextValue}>
|
|
44
108
|
<Collapsible asChild data-slot="plan" {...props}>
|
|
45
|
-
<Card
|
|
109
|
+
<Card
|
|
110
|
+
role={role ?? resolvedRole}
|
|
111
|
+
aria-labelledby={isPending ? titleId : undefined}
|
|
112
|
+
className={cn("shadow-none", railClass, className)}
|
|
113
|
+
>
|
|
114
|
+
{children}
|
|
115
|
+
</Card>
|
|
46
116
|
</Collapsible>
|
|
47
117
|
</PlanContext.Provider>
|
|
48
118
|
);
|
|
@@ -62,11 +132,11 @@ export type PlanTitleProps = Omit<ComponentProps<typeof CardTitle>, "children">
|
|
|
62
132
|
children: string;
|
|
63
133
|
};
|
|
64
134
|
|
|
65
|
-
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
|
66
|
-
const { isStreaming } = usePlan();
|
|
135
|
+
export const PlanTitle = ({ id, children, ...props }: PlanTitleProps) => {
|
|
136
|
+
const { isStreaming, titleId } = usePlan();
|
|
67
137
|
|
|
68
138
|
return (
|
|
69
|
-
<CardTitle data-slot="plan-title" {...props}>
|
|
139
|
+
<CardTitle id={id ?? titleId} data-slot="plan-title" {...props}>
|
|
70
140
|
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
|
71
141
|
</CardTitle>
|
|
72
142
|
);
|
|
@@ -92,6 +162,7 @@ export const PlanDescription = ({ className, children, ...props }: PlanDescripti
|
|
|
92
162
|
|
|
93
163
|
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
|
94
164
|
|
|
165
|
+
/** The header's layout slot (e.g. a menu trigger) — unrelated to the decision actions below. */
|
|
95
166
|
export const PlanAction = (props: PlanActionProps) => (
|
|
96
167
|
<CardAction data-slot="plan-action" {...props} />
|
|
97
168
|
);
|
|
@@ -119,17 +190,108 @@ export const PlanFooter = (props: PlanFooterProps) => (
|
|
|
119
190
|
|
|
120
191
|
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
|
121
192
|
|
|
122
|
-
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
193
|
+
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => {
|
|
194
|
+
const { t } = useLocale();
|
|
195
|
+
return (
|
|
196
|
+
<CollapsibleTrigger asChild>
|
|
197
|
+
<Button
|
|
198
|
+
className={cn("size-8", className)}
|
|
199
|
+
data-slot="plan-trigger"
|
|
200
|
+
size="icon"
|
|
201
|
+
variant="ghost"
|
|
202
|
+
{...props}
|
|
203
|
+
>
|
|
204
|
+
<ChevronsUpDownIcon className="size-4" />
|
|
205
|
+
<span className="sr-only">{t("ai.plan.togglePlan")}</span>
|
|
206
|
+
</Button>
|
|
207
|
+
</CollapsibleTrigger>
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
type PlanOutcomeStatus = Exclude<PlanStatus, "streaming">;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Default label/tone/glyph per non-streaming status (#108). Every entry is a
|
|
215
|
+
* CALM tone (the `StatusBadge` out-of-vocabulary escape hatch, #363) — the
|
|
216
|
+
* decision is carried in text + glyph, never by rail colour alone (WCAG 1.4.1).
|
|
217
|
+
*/
|
|
218
|
+
const PLAN_STATUS_CONTENT: Record<
|
|
219
|
+
PlanOutcomeStatus,
|
|
220
|
+
{ label: string; tone: StatusTone; icon: LucideIcon }
|
|
221
|
+
> = {
|
|
222
|
+
awaiting: { label: "Waiting on plan approval", tone: "warning", icon: Clock },
|
|
223
|
+
approved: { label: "Approved", tone: "success", icon: CheckCircle2 },
|
|
224
|
+
"changes-requested": { label: "Changes requested", tone: "warning", icon: PencilLine },
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export type PlanStatusLineProps = Omit<ComponentProps<typeof StatusBadge>, "status"> & {
|
|
228
|
+
children?: ReactNode;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The decision status line (#108) — text + glyph, never rail colour alone.
|
|
233
|
+
* Renders nothing while `status` is unset or `"streaming"` (nothing has been
|
|
234
|
+
* decided yet); shows the outcome once the plan is awaiting a decision or
|
|
235
|
+
* settled.
|
|
236
|
+
*/
|
|
237
|
+
export const PlanStatusLine = ({ className, children, ...props }: PlanStatusLineProps) => {
|
|
238
|
+
const { status } = usePlan();
|
|
239
|
+
|
|
240
|
+
if (!status || status === "streaming") {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { label, tone, icon } = PLAN_STATUS_CONTENT[status];
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<StatusBadge
|
|
248
|
+
data-slot="plan-status-line"
|
|
249
|
+
className={cn("self-start", className)}
|
|
250
|
+
status={{ label, tone, icon }}
|
|
129
251
|
{...props}
|
|
130
252
|
>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
253
|
+
{children}
|
|
254
|
+
</StatusBadge>
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export type PlanDecisionActionProps = ComponentProps<typeof Button>;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Shared decision-button primitive. Renders only while `status === "awaiting"`
|
|
262
|
+
* — mirrors the gating `ConfirmationActions` uses (packages/ai/src/confirmation.tsx)
|
|
263
|
+
* — so a settled or still-streaming plan never shows stale controls.
|
|
264
|
+
*/
|
|
265
|
+
const PlanDecisionAction = ({ className, ...props }: PlanDecisionActionProps) => {
|
|
266
|
+
const { status } = usePlan();
|
|
267
|
+
|
|
268
|
+
if (status !== "awaiting") {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return <Button className={cn("h-8 px-3 text-body", className)} type="button" {...props} />;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export type PlanApproveProps = PlanDecisionActionProps;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The proceed action — presets the filled primary variant so the primary path
|
|
279
|
+
* can't drift to a quieter variant (same reasoning as `ConfirmationApprove`).
|
|
280
|
+
*/
|
|
281
|
+
export const PlanApprove = (props: PlanApproveProps) => (
|
|
282
|
+
<PlanDecisionAction data-slot="plan-approve" variant="default" {...props} />
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
export type PlanRequestChangesProps = PlanDecisionActionProps;
|
|
286
|
+
|
|
287
|
+
/** Sends the plan back for revision — a quieter, non-primary action. */
|
|
288
|
+
export const PlanRequestChanges = (props: PlanRequestChangesProps) => (
|
|
289
|
+
<PlanDecisionAction data-slot="plan-request-changes" variant="outline" {...props} />
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
export type PlanCommentProps = PlanDecisionActionProps;
|
|
293
|
+
|
|
294
|
+
/** Leaves feedback without settling the decision — the quietest action. */
|
|
295
|
+
export const PlanComment = (props: PlanCommentProps) => (
|
|
296
|
+
<PlanDecisionAction data-slot="plan-comment" variant="ghost" {...props} />
|
|
135
297
|
);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptInputEffort — an ORDERED reasoning-effort/budget control (#107).
|
|
3
|
+
* Renders `levels` (low → high, the order IS the semantics) as a row of
|
|
4
|
+
* same-shape squares that GROW in size; selecting a level fills every square
|
|
5
|
+
* up to and including it. The fill is a non-colour channel (size ramp +
|
|
6
|
+
* solid/hollow shape), so the level is recoverable in greyscale — plus the
|
|
7
|
+
* level's name is always rendered as text. Ships no effort vocabulary.
|
|
8
|
+
*/
|
|
9
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
10
|
+
import { expect, fn } from "storybook/test";
|
|
11
|
+
import { type EffortLevel } from "@elabs-ai/components-ui";
|
|
12
|
+
|
|
13
|
+
import { PromptInputEffort } from "./prompt-input-effort";
|
|
14
|
+
|
|
15
|
+
const LEVELS: EffortLevel[] = [
|
|
16
|
+
{ id: "low", label: "Low" },
|
|
17
|
+
{ id: "medium", label: "Medium" },
|
|
18
|
+
{ id: "high", label: "High" },
|
|
19
|
+
{ id: "max", label: "Max" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const meta = {
|
|
23
|
+
title: "AI/Composer/PromptInputEffort",
|
|
24
|
+
component: PromptInputEffort,
|
|
25
|
+
parameters: {
|
|
26
|
+
layout: "padded",
|
|
27
|
+
docs: {
|
|
28
|
+
description: {
|
|
29
|
+
component:
|
|
30
|
+
"A part of the [Composer](?path=/docs/ai-composer--docs) family — what the `effort` slot renders. An ordered reasoning-effort/budget scale. The order of `levels` IS the semantics: the indicator fills — by size ramp and solid-vs-hollow shape, never colour alone — up to the current level, and the level's name is also rendered as text. Built on Radix `RadioGroup` for arrow-key navigation and an announced current value; `levels` is entirely prop-driven.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
args: {
|
|
35
|
+
levels: LEVELS,
|
|
36
|
+
"aria-label": "Reasoning effort",
|
|
37
|
+
onValueChange: fn(),
|
|
38
|
+
},
|
|
39
|
+
tags: ["autodocs"],
|
|
40
|
+
} satisfies Meta<typeof PromptInputEffort>;
|
|
41
|
+
|
|
42
|
+
export default meta;
|
|
43
|
+
type Story = StoryObj<typeof meta>;
|
|
44
|
+
|
|
45
|
+
/** Uncontrolled — defaults to the first (lowest) level. */
|
|
46
|
+
export const Default: Story = {
|
|
47
|
+
play: async ({ canvas }) => {
|
|
48
|
+
await expect(canvas.getByRole("radio", { name: "Low", checked: true })).toBeInTheDocument();
|
|
49
|
+
await expect(canvas.getByText("Low")).toBeInTheDocument();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Controlled at the highest level — every square is filled. */
|
|
54
|
+
export const HighestLevel: Story = {
|
|
55
|
+
args: { value: "max" },
|
|
56
|
+
play: async ({ canvas }) => {
|
|
57
|
+
await expect(canvas.getByRole("radio", { name: "Max", checked: true })).toBeInTheDocument();
|
|
58
|
+
await expect(canvas.getByText("Max")).toBeInTheDocument();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Clicking a level moves the fill and announces the new name. */
|
|
63
|
+
export const SelectALevel: Story = {
|
|
64
|
+
play: async ({ canvas, args, userEvent }) => {
|
|
65
|
+
await userEvent.click(canvas.getByRole("radio", { name: "High" }));
|
|
66
|
+
await expect(args.onValueChange).toHaveBeenCalledWith("high");
|
|
67
|
+
await expect(canvas.getByRole("radio", { name: "High", checked: true })).toBeInTheDocument();
|
|
68
|
+
await expect(canvas.getByText("High")).toBeInTheDocument();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Only two levels — the size ramp still reads as an ordered low/high pair. */
|
|
73
|
+
export const TwoLevels: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
levels: [
|
|
76
|
+
{ id: "standard", label: "Standard" },
|
|
77
|
+
{ id: "extended", label: "Extended" },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* WCAG 2.5.8 (Target Size, Minimum) locking test (#161). The ramp bars'
|
|
84
|
+
* differing sizes ARE the meaning (the size ramp is the non-colour channel),
|
|
85
|
+
* so the fix must decouple the click/tap/focus target from the visible bar:
|
|
86
|
+
* every radio's own hit box is >=24x24 CSS px, while the visible bar
|
|
87
|
+
* (`[data-slot="prompt-input-effort-item-visual"]`) keeps its authored
|
|
88
|
+
* `effortRungForIndex` size (10, 14, 20, 24px for these 4 levels) — a fix
|
|
89
|
+
* that flattens the ramp to satisfy the target-size rule fails this test.
|
|
90
|
+
*/
|
|
91
|
+
export const TargetSize: Story = {
|
|
92
|
+
play: async ({ canvas }) => {
|
|
93
|
+
const radios = canvas.getAllByRole("radio");
|
|
94
|
+
expect(radios).toHaveLength(4);
|
|
95
|
+
|
|
96
|
+
for (const radio of radios) {
|
|
97
|
+
const rect = radio.getBoundingClientRect();
|
|
98
|
+
await expect(rect.width).toBeGreaterThanOrEqual(24);
|
|
99
|
+
await expect(rect.height).toBeGreaterThanOrEqual(24);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Adjacent targets don't overlap.
|
|
103
|
+
for (let i = 1; i < radios.length; i++) {
|
|
104
|
+
const previous = radios[i - 1].getBoundingClientRect();
|
|
105
|
+
const current = radios[i].getBoundingClientRect();
|
|
106
|
+
await expect(current.left).toBeGreaterThanOrEqual(previous.right);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The visible ramp is pixel-unchanged: the authored bar sizes still
|
|
110
|
+
// grow 10 -> 14 -> 20 -> 24px, low to high. The visible bar is a
|
|
111
|
+
// sibling of the (now enlarged) radio, both inside a shared frame.
|
|
112
|
+
const visibleWidths = radios.map((radio) => {
|
|
113
|
+
const visual = radio.parentElement?.querySelector(
|
|
114
|
+
'[data-slot="prompt-input-effort-item-visual"]',
|
|
115
|
+
);
|
|
116
|
+
if (!(visual instanceof HTMLElement)) {
|
|
117
|
+
throw new Error("expected a visible-bar element beside every radio");
|
|
118
|
+
}
|
|
119
|
+
return visual.getBoundingClientRect().width;
|
|
120
|
+
});
|
|
121
|
+
await expect(visibleWidths).toEqual([10, 14, 20, 24]);
|
|
122
|
+
},
|
|
123
|
+
};
|