@elabs-ai/components-ai 4.0.0 → 4.2.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/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. 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 = ({ className, isStreaming = false, children, ...props }: PlanProps) => {
40
- const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
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 className={cn("shadow-none", className)}>{children}</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
- <CollapsibleTrigger asChild>
124
- <Button
125
- className={cn("size-8", className)}
126
- data-slot="plan-trigger"
127
- size="icon"
128
- variant="ghost"
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
- <ChevronsUpDownIcon className="size-4" />
132
- <span className="sr-only">Toggle plan</span>
133
- </Button>
134
- </CollapsibleTrigger>
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
+ };