@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
@@ -4,18 +4,10 @@ import { Badge } from "@elabs-ai/components-ui";
4
4
  import { Button } from "@elabs-ai/components-ui";
5
5
  import { Switch } from "@elabs-ai/components-ui";
6
6
  import { cn } from "@elabs-ai/components-ui/lib/cn";
7
- import { useLocale } from "@elabs-ai/components-ui";
7
+ import { useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
8
8
  import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react";
9
9
  import type { ComponentProps, HTMLAttributes } from "react";
10
- import {
11
- createContext,
12
- useCallback,
13
- useContext,
14
- useEffect,
15
- useMemo,
16
- useRef,
17
- useState,
18
- } from "react";
10
+ import { createContext, useCallback, useContext, useMemo, useState } from "react";
19
11
 
20
12
  interface EnvironmentVariablesContextType {
21
13
  showValues: boolean;
@@ -86,7 +78,7 @@ export const EnvironmentVariablesTitle = ({
86
78
  children,
87
79
  ...props
88
80
  }: EnvironmentVariablesTitleProps) => (
89
- <h3 className={cn("font-medium text-sm", className)} {...props}>
81
+ <h3 className={cn("font-medium text-body", className)} {...props}>
90
82
  {children ?? "Environment Variables"}
91
83
  </h3>
92
84
  );
@@ -102,7 +94,7 @@ export const EnvironmentVariablesToggle = ({
102
94
 
103
95
  return (
104
96
  <div className={cn("flex items-center gap-2", className)}>
105
- <span className="text-muted-foreground text-xs">
97
+ <span className="text-muted-foreground text-meta">
106
98
  {showValues ? <EyeIcon size={14} /> : <EyeOffIcon size={14} />}
107
99
  </span>
108
100
  <Switch
@@ -159,7 +151,7 @@ export const EnvironmentVariableName = ({
159
151
  const { name } = useContext(EnvironmentVariableContext);
160
152
 
161
153
  return (
162
- <span className={cn("font-mono text-sm", className)} {...props}>
154
+ <span className={cn("font-mono text-body", className)} {...props}>
163
155
  {children ?? name}
164
156
  </span>
165
157
  );
@@ -180,7 +172,7 @@ export const EnvironmentVariableValue = ({
180
172
  return (
181
173
  <span
182
174
  className={cn(
183
- "font-mono text-muted-foreground text-sm",
175
+ "font-mono text-muted-foreground text-body",
184
176
  !showValues && "select-none",
185
177
  className,
186
178
  )}
@@ -234,15 +226,19 @@ export type EnvironmentVariableCopyButtonProps = ComponentProps<typeof Button> &
234
226
  export const EnvironmentVariableCopyButton = ({
235
227
  onCopy,
236
228
  onError,
237
- timeout = 2000,
229
+ timeout,
238
230
  copyFormat = "value",
239
231
  children,
240
232
  className,
233
+ "aria-label": ariaLabel,
241
234
  ...props
242
235
  }: EnvironmentVariableCopyButtonProps) => {
243
- const [isCopied, setIsCopied] = useState(false);
244
- const timeoutRef = useRef<number>(0);
236
+ const { t } = useLocale();
245
237
  const { name, value } = useContext(EnvironmentVariableContext);
238
+ // Shared implementation (`@elabs-ai/components-ui`) instead of a private
239
+ // copy of the same copy-to-clipboard state machine (issue-workflow.md
240
+ // dedupe finding) — see `CodeBlockCopyButton` for the reference usage.
241
+ const { copied: isCopied, copy } = useCopyToClipboard({ resetAfterMs: timeout ?? 2000 });
246
242
 
247
243
  const getTextToCopy = useCallback((): string => {
248
244
  const formatMap = {
@@ -254,32 +250,19 @@ export const EnvironmentVariableCopyButton = ({
254
250
  }, [name, value, copyFormat]);
255
251
 
256
252
  const copyToClipboard = useCallback(async () => {
257
- if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
258
- onError?.(new Error("Clipboard API not available"));
259
- return;
260
- }
261
-
262
- try {
263
- await navigator.clipboard.writeText(getTextToCopy());
264
- setIsCopied(true);
253
+ const ok = await copy(getTextToCopy());
254
+ if (ok) {
265
255
  onCopy?.();
266
- timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
267
- } catch (error) {
268
- onError?.(error as Error);
256
+ } else {
257
+ onError?.(new Error("Clipboard API not available"));
269
258
  }
270
- }, [getTextToCopy, onCopy, onError, timeout]);
271
-
272
- useEffect(
273
- () => () => {
274
- window.clearTimeout(timeoutRef.current);
275
- },
276
- [],
277
- );
259
+ }, [copy, getTextToCopy, onCopy, onError]);
278
260
 
279
261
  const Icon = isCopied ? CheckIcon : CopyIcon;
280
262
 
281
263
  return (
282
264
  <Button
265
+ aria-label={ariaLabel ?? t("copy")}
283
266
  className={cn("size-6 shrink-0", className)}
284
267
  onClick={copyToClipboard}
285
268
  size="icon"
@@ -298,7 +281,7 @@ export const EnvironmentVariableRequired = ({
298
281
  children,
299
282
  ...props
300
283
  }: EnvironmentVariableRequiredProps) => (
301
- <Badge className={cn("text-xs", className)} variant="secondary" {...props}>
284
+ <Badge className={cn("text-meta", className)} variant="secondary" {...props}>
302
285
  {children ?? "Required"}
303
286
  </Badge>
304
287
  );
@@ -25,6 +25,27 @@ describe("FileTree folder icons", () => {
25
25
  });
26
26
  });
27
27
 
28
+ describe("FileTreeFolder expand/collapse control (accessibility.md — icon-only controls need aria-label)", () => {
29
+ it("names the chevron toggle, and flips the name with expanded state", async () => {
30
+ const user = userEvent.setup();
31
+ render(
32
+ <FileTree defaultExpanded={new Set()}>
33
+ <FileTreeFolder path="src" name="src">
34
+ <FileTreeFile path="src/index.ts" name="index.ts" />
35
+ </FileTreeFolder>
36
+ </FileTree>,
37
+ );
38
+
39
+ const toggle = screen.getByRole("button", { name: "Expand src" });
40
+ expect(toggle).toBeInTheDocument();
41
+
42
+ await user.click(toggle);
43
+
44
+ expect(screen.getByRole("button", { name: "Collapse src" })).toBeInTheDocument();
45
+ expect(screen.queryByRole("button", { name: "Expand src" })).not.toBeInTheDocument();
46
+ });
47
+ });
48
+
28
49
  describe("FileTree variant axis (#193, research 04 §4 ASSET-1)", () => {
29
50
  it("keeps the IDE look by default (code variant — non-breaking)", () => {
30
51
  const { container } = render(
package/src/file-tree.tsx CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  CollapsibleContent,
6
6
  CollapsibleTrigger,
7
7
  fileIconFor,
8
+ useLocale,
8
9
  } from "@elabs-ai/components-ui";
9
10
  import { cn } from "@elabs-ai/components-ui/lib/cn";
10
11
  import { cva, type VariantProps } from "class-variance-authority";
@@ -50,7 +51,7 @@ const FileTreeContext = createContext<FileTreeContextType>({
50
51
  const fileTreeVariants = cva("", {
51
52
  variants: {
52
53
  variant: {
53
- code: "rounded-lg border bg-background font-mono text-sm",
54
+ code: "rounded-lg border bg-background font-mono text-body",
54
55
  document: "text-body",
55
56
  },
56
57
  },
@@ -158,6 +159,7 @@ export const FileTreeFolder = ({
158
159
  children,
159
160
  ...props
160
161
  }: FileTreeFolderProps) => {
162
+ const { t } = useLocale();
161
163
  const { expandedPaths, togglePath, selectedPath, onSelect } = useContext(FileTreeContext);
162
164
  const isExpanded = expandedPaths.has(path);
163
165
  const isSelected = selectedPath === path;
@@ -184,10 +186,15 @@ export const FileTreeFolder = ({
184
186
  >
185
187
  <CollapsibleTrigger asChild>
186
188
  <button
189
+ aria-label={t(
190
+ isExpanded ? "ai.fileTree.collapseFolder" : "ai.fileTree.expandFolder",
191
+ { name },
192
+ )}
187
193
  className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0"
188
194
  type="button"
189
195
  >
190
196
  <ChevronRightIcon
197
+ aria-hidden="true"
191
198
  className={cn(
192
199
  "size-4 shrink-0 text-muted-foreground transition-transform",
193
200
  isExpanded && "rotate-90",
@@ -353,6 +360,7 @@ export type ProducedAssetTreeProps = Omit<
353
360
  */
354
361
  export const ProducedAssetTree = forwardRef<HTMLDivElement, ProducedAssetTreeProps>(
355
362
  function ProducedAssetTree({ assets, selectedId, onSelect, ...props }, ref) {
363
+ const { t } = useLocale();
356
364
  const assetsById = useMemo(() => new Map(assets.map((asset) => [asset.id, asset])), [assets]);
357
365
  const handleSelect = useCallback(
358
366
  (path: string) => {
@@ -370,7 +378,9 @@ export const ProducedAssetTree = forwardRef<HTMLDivElement, ProducedAssetTreePro
370
378
  {...props}
371
379
  >
372
380
  {assets.length === 0 ? (
373
- <p className="px-2 py-1 text-body text-muted-foreground">No assets produced yet.</p>
381
+ <p className="px-2 py-1 text-body text-muted-foreground">
382
+ {t("ai.fileTree.noAssetsProduced")}
383
+ </p>
374
384
  ) : (
375
385
  assets.map((asset) => {
376
386
  const Icon = assetIcon(asset);
package/src/gallery.tsx CHANGED
@@ -317,10 +317,11 @@ function GalleryTilt({ className, children }: { className?: string; children: Re
317
317
  }
318
318
 
319
319
  function GalleryEmpty() {
320
+ const { t } = useLocale();
320
321
  return (
321
322
  <div className="flex min-h-32 flex-col items-center justify-center gap-2 p-6 text-center text-muted-foreground">
322
323
  <ImageOff className="size-6" aria-hidden="true" />
323
- <p className="text-body">No images</p>
324
+ <p className="text-body">{t("ai.gallery.noImages")}</p>
324
325
  </div>
325
326
  );
326
327
  }
@@ -485,6 +486,7 @@ function GalleryDetailPanel({
485
486
  index: number;
486
487
  renderMeta?: GalleryProps["renderMeta"];
487
488
  }) {
489
+ const { t } = useLocale();
488
490
  const custom = renderMeta?.(image, index);
489
491
  if (custom != null) {
490
492
  return <div className="flex flex-col gap-4">{custom}</div>;
@@ -494,7 +496,7 @@ function GalleryDetailPanel({
494
496
  const hasBody = Boolean(image.description) || hasMeta;
495
497
 
496
498
  if (!hasBody) {
497
- return <p className="text-body text-muted-foreground">No details</p>;
499
+ return <p className="text-body text-muted-foreground">{t("ai.gallery.noDetails")}</p>;
498
500
  }
499
501
 
500
502
  return (
package/src/index.ts CHANGED
@@ -20,11 +20,13 @@
20
20
  * See ATTRIBUTION.md and .claude/rules/attribution.md.
21
21
  */
22
22
  export * from "./agent";
23
+ export * from "./agent-event";
23
24
  export * from "./agent-timeline";
24
25
  export * from "./artifact";
25
26
  export * from "./asset-preview";
26
27
  export * from "./attachments";
27
28
  export * from "./audio-player";
29
+ export * from "./audio-visualizer";
28
30
  export * from "./canvas";
29
31
  export * from "./chain-of-thought";
30
32
  export * from "./chat-greeting";
@@ -34,10 +36,10 @@ export * from "./commit";
34
36
  export * from "./composer";
35
37
  export * from "./confirmation";
36
38
  export * from "./connection";
37
- export * from "./context";
38
39
  export * from "./context-panel";
39
40
  export * from "./controls";
40
41
  export * from "./conversation";
42
+ export * from "./diff-view";
41
43
  export * from "./edge";
42
44
  export * from "./environment-variables";
43
45
  export * from "./file-tree";
@@ -45,13 +47,13 @@ export * from "./gallery";
45
47
  export * from "./grouped-parts";
46
48
  export * from "./image";
47
49
  export * from "./inline-citation";
48
- export * from "./interactive-terminal";
49
50
  export * from "./jsx-preview";
50
51
  // Mermaid loads on first diagram render, not in the entry chunk. `preloadMermaid`
51
52
  // warms it early when a surface is known to render diagrams.
52
53
  export { createLazyMermaidPlugin, lazyMermaid, preloadMermaid } from "./_lazy-mermaid";
53
54
  export * from "./markdown-view";
54
55
  export * from "./message";
56
+ export * from "./message-compare";
55
57
  export * from "./message-edit";
56
58
  export * from "./message-feedback";
57
59
  export * from "./message-form";
@@ -59,21 +61,27 @@ export * from "./message-form-spec";
59
61
  export * from "./message-table";
60
62
  export * from "./message-table-spec";
61
63
  export * from "./mic-selector";
62
- export * from "./model-selector";
64
+ export * from "./model-provider-logo";
63
65
  export * from "./motion-config";
64
66
  export * from "./node";
65
67
  export * from "./open-in-chat";
66
68
  export * from "./package-info";
67
69
  export * from "./panel";
68
70
  export * from "./part-groups";
71
+ export * from "./permission-mode-select";
69
72
  export * from "./persona";
70
73
  export * from "./plan";
71
74
  export * from "./prompt-input";
75
+ export * from "./prompt-input-effort";
76
+ export * from "./prompt-input-mode";
77
+ export * from "./prompt-input-slash";
72
78
  export * from "./queue";
73
79
  export * from "./reasoning";
74
80
  export * from "./sandbox";
75
81
  export * from "./schema-display";
76
82
  export * from "./selection-toolbar";
83
+ export * from "./session-header";
84
+ export * from "./session-status-bar";
77
85
  export * from "./shimmer";
78
86
  export * from "./snippet";
79
87
  export * from "./sources";
@@ -81,12 +89,14 @@ export * from "./speech-input";
81
89
  export * from "./stack-trace";
82
90
  export * from "./suggestion";
83
91
  export * from "./task";
84
- export * from "./terminal";
85
92
  export * from "./test-results";
93
+ export * from "./token-usage";
86
94
  export * from "./tool";
87
95
  export * from "./tool-result-card";
88
96
  export * from "./toolbar";
89
97
  export * from "./transcription";
98
+ export * from "./turn-status";
99
+ export * from "./use-audio-level";
90
100
  export * from "./voice-selector";
91
101
  export * from "./web-preview";
92
102
 
@@ -208,7 +208,7 @@ export const InlineCitationCarouselIndex = ({
208
208
  return (
209
209
  <div
210
210
  className={cn(
211
- "flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
211
+ "flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-meta",
212
212
  className,
213
213
  )}
214
214
  {...props}
@@ -289,10 +289,10 @@ export const InlineCitationSource = ({
289
289
  ...props
290
290
  }: InlineCitationSourceProps) => (
291
291
  <div className={cn("space-y-1", className)} {...props}>
292
- {title && <h4 className="truncate font-medium text-sm leading-tight">{title}</h4>}
293
- {url && <p className="truncate break-all text-muted-foreground text-xs">{url}</p>}
292
+ {title && <h4 className="truncate font-medium text-body leading-tight">{title}</h4>}
293
+ {url && <p className="truncate break-all text-muted-foreground text-meta">{url}</p>}
294
294
  {description && (
295
- <p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">{description}</p>
295
+ <p className="line-clamp-3 text-muted-foreground text-body leading-relaxed">{description}</p>
296
296
  )}
297
297
  {children}
298
298
  </div>
@@ -306,7 +306,7 @@ export const InlineCitationQuote = ({
306
306
  ...props
307
307
  }: InlineCitationQuoteProps) => (
308
308
  <blockquote
309
- className={cn("border-muted border-s-2 ps-3 text-muted-foreground text-sm italic", className)}
309
+ className={cn("border-muted border-s-2 ps-3 text-muted-foreground text-body italic", className)}
310
310
  {...props}
311
311
  >
312
312
  {children}
@@ -18,7 +18,7 @@ function Stat({ label, value, delta }: { label: string; value: string; delta?: s
18
18
  <div className="rounded-lg border bg-card p-4 text-card-foreground">
19
19
  <div className="text-meta text-muted-foreground">{label}</div>
20
20
  <div className="text-title tabular-nums">{value}</div>
21
- {delta ? <div className="text-caption text-primary">{delta}</div> : null}
21
+ {delta ? <div className="text-caption text-primary-text">{delta}</div> : null}
22
22
  </div>
23
23
  );
24
24
  }
@@ -50,7 +50,7 @@ const DASHBOARD =
50
50
  "</div>";
51
51
 
52
52
  const meta = {
53
- title: "AI/JSX Preview",
53
+ title: "AI/JSXPreview",
54
54
  component: JSXPreview,
55
55
  parameters: { layout: "padded" },
56
56
  } satisfies Meta<typeof JSXPreview>;
@@ -46,7 +46,23 @@ interface JSXPreviewContextValue {
46
46
 
47
47
  const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
48
48
 
49
- const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
49
+ /**
50
+ * HTML elements that must never render from agent/model-authored JSX: script
51
+ * execution, style/link/meta/base injection, and embeddable third-party
52
+ * surfaces (iframe/object/embed) or a form that could post data out. Merged
53
+ * with `react-jsx-parser`'s own default (`script`) via `blacklistedTags`.
54
+ */
55
+ const JSX_PREVIEW_BLACKLISTED_TAGS = [
56
+ "script",
57
+ "style",
58
+ "iframe",
59
+ "form",
60
+ "object",
61
+ "embed",
62
+ "link",
63
+ "meta",
64
+ "base",
65
+ ];
50
66
 
51
67
  export const useJSXPreview = () => {
52
68
  const context = useContext(JSXPreviewContext);
@@ -56,37 +72,101 @@ export const useJSXPreview = () => {
56
72
  return context;
57
73
  };
58
74
 
59
- const matchJsxTag = (code: string) => {
60
- if (code.trim() === "") {
61
- return null;
62
- }
75
+ const TAG_NAME_START = /[a-zA-Z]/;
76
+ const TAG_NAME_CHAR = /[a-zA-Z0-9]/;
63
77
 
64
- const match = code.match(TAG_REGEX);
78
+ interface JsxTagMatch {
79
+ tagName: string;
80
+ type: "opening" | "closing" | "self-closing";
81
+ endIndex: number;
82
+ }
65
83
 
66
- if (!match || match.index === undefined) {
67
- return null;
68
- }
84
+ /**
85
+ * Finds the next complete JSX tag at or after `fromIndex`, tracking brace
86
+ * depth and quoted strings so a `>` inside an attribute expression (e.g.
87
+ * `onClick={() => foo()}`) is never mistaken for the tag's own closing
88
+ * bracket (the previous plain regex did exactly that). Walks the ORIGINAL
89
+ * string by absolute index — no per-iteration substring slicing — so one
90
+ * pass over the whole input is linear.
91
+ */
92
+ function findNextJsxTag(code: string, fromIndex: number): JsxTagMatch | null {
93
+ const len = code.length;
94
+ let i = fromIndex;
95
+
96
+ while (i < len) {
97
+ if (code[i] !== "<") {
98
+ i++;
99
+ continue;
100
+ }
69
101
 
70
- const [fullMatch = "", tagName = "", attributes = "", selfClosing] = match;
102
+ let j = i + 1;
103
+ const closing = code[j] === "/";
104
+ if (closing) j++;
71
105
 
72
- let type: "self-closing" | "closing" | "opening";
73
- if (selfClosing) {
74
- type = "self-closing";
75
- } else if (fullMatch.startsWith("</")) {
76
- type = "closing";
77
- } else {
78
- type = "opening";
106
+ const nameStart = j;
107
+ if (!TAG_NAME_START.test(code[nameStart] ?? "")) {
108
+ i++;
109
+ continue;
110
+ }
111
+ j++;
112
+ while (j < len && TAG_NAME_CHAR.test(code[j] ?? "")) {
113
+ j++;
114
+ }
115
+ const tagName = code.slice(nameStart, j);
116
+
117
+ let depth = 0;
118
+ let quote: string | null = null;
119
+ let k = j;
120
+ let closedAt = -1;
121
+ let selfClosing = false;
122
+
123
+ while (k < len) {
124
+ const ch = code[k];
125
+ if (quote) {
126
+ if (ch === quote) quote = null;
127
+ k++;
128
+ continue;
129
+ }
130
+ if (ch === '"' || ch === "'") {
131
+ quote = ch;
132
+ k++;
133
+ continue;
134
+ }
135
+ if (ch === "{") {
136
+ depth++;
137
+ k++;
138
+ continue;
139
+ }
140
+ if (ch === "}") {
141
+ depth = Math.max(0, depth - 1);
142
+ k++;
143
+ continue;
144
+ }
145
+ if (depth === 0 && ch === ">") {
146
+ let p = k - 1;
147
+ while (p > j && code[p] === " ") p--;
148
+ selfClosing = code[p] === "/";
149
+ closedAt = k + 1;
150
+ break;
151
+ }
152
+ k++;
153
+ }
154
+
155
+ // No closing '>' yet for this tag at this depth — it is incomplete, not
156
+ // a match; the caller treats everything from here on as trailing text.
157
+ if (closedAt === -1) {
158
+ return null;
159
+ }
160
+
161
+ return {
162
+ endIndex: closedAt,
163
+ tagName,
164
+ type: closing ? "closing" : selfClosing ? "self-closing" : "opening",
165
+ };
79
166
  }
80
167
 
81
- return {
82
- attributes: attributes.trim(),
83
- endIndex: match.index + fullMatch.length,
84
- startIndex: match.index,
85
- tag: fullMatch,
86
- tagName,
87
- type,
88
- };
89
- };
168
+ return null;
169
+ }
90
170
 
91
171
  const stripIncompleteTag = (text: string) => {
92
172
  // Find the last '<' that isn't part of a complete tag
@@ -106,32 +186,28 @@ const stripIncompleteTag = (text: string) => {
106
186
 
107
187
  const completeJsxTag = (code: string) => {
108
188
  const stack: string[] = [];
109
- let result = "";
110
- let currentPosition = 0;
189
+ let position = 0;
111
190
 
112
- while (currentPosition < code.length) {
113
- const match = matchJsxTag(code.slice(currentPosition));
191
+ while (position < code.length) {
192
+ const match = findNextJsxTag(code, position);
114
193
  if (!match) {
115
- // No more tags found, strip any trailing incomplete tag
116
- result += stripIncompleteTag(code.slice(currentPosition));
194
+ // No more complete tags ahead; everything past `position` is trailing
195
+ // text (possibly a mid-typed tag), handled by stripIncompleteTag below.
117
196
  break;
118
197
  }
119
- const { tagName, type, endIndex } = match;
120
198
 
121
- // Include any text content before this tag
122
- result += code.slice(currentPosition, currentPosition + endIndex);
123
-
124
- if (type === "opening") {
125
- stack.push(tagName);
126
- } else if (type === "closing") {
199
+ if (match.type === "opening") {
200
+ stack.push(match.tagName);
201
+ } else if (match.type === "closing") {
127
202
  stack.pop();
128
203
  }
129
204
 
130
- currentPosition += endIndex;
205
+ position = match.endIndex;
131
206
  }
132
207
 
133
208
  return (
134
- result +
209
+ code.slice(0, position) +
210
+ stripIncompleteTag(code.slice(position)) +
135
211
  [...stack]
136
212
  .reverse()
137
213
  .map((tag) => `</${tag}>`)
@@ -139,6 +215,35 @@ const completeJsxTag = (code: string) => {
139
215
  );
140
216
  };
141
217
 
218
+ /** How often `completeJsxTag` may re-run while content is still streaming in. */
219
+ const STREAM_RECOMPUTE_THROTTLE_MS = 80;
220
+
221
+ /**
222
+ * Tag-balances `jsx` like `completeJsxTag`, but while `isStreaming` is true it
223
+ * skips a fresh (expensive, whole-string) recompute more often than once per
224
+ * `STREAM_RECOMPUTE_THROTTLE_MS` — otherwise every incoming token re-scans the
225
+ * entire accumulated string from scratch. Settled input (`isStreaming` false)
226
+ * always recomputes immediately so the final render is exact.
227
+ */
228
+ function useCompletedJsx(jsx: string, isStreaming: boolean): string {
229
+ const cacheRef = useRef({ jsx, result: completeJsxTag(jsx), time: 0 });
230
+
231
+ return useMemo(() => {
232
+ const cache = cacheRef.current;
233
+ if (jsx === cache.jsx) return cache.result;
234
+
235
+ const now = Date.now();
236
+ if (isStreaming && now - cache.time < STREAM_RECOMPUTE_THROTTLE_MS) {
237
+ return cache.result;
238
+ }
239
+
240
+ const result = completeJsxTag(jsx);
241
+ cacheRef.current = { jsx, result, time: now };
242
+ return result;
243
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- cacheRef is a stable ref, not reactive state
244
+ }, [jsx, isStreaming]);
245
+ }
246
+
142
247
  export type JSXPreviewProps = ComponentProps<"div"> & {
143
248
  jsx: string;
144
249
  /** Content is arriving incrementally — build up, suppress transient errors. */
@@ -177,8 +282,9 @@ export const JSXPreview = memo(
177
282
 
178
283
  // The tag-balanced best-effort of the current input. When it differs from the
179
284
  // raw jsx, the input ends mid-tag → it is INCOMPLETE (still streaming / being
180
- // typed), not invalid. This is the timer-free "not-ready" signal.
181
- const completed = useMemo(() => completeJsxTag(jsx), [jsx]);
285
+ // typed), not invalid. This is the timer-free "not-ready" signal. Throttled
286
+ // while streaming so every incoming token doesn't re-scan the whole string.
287
+ const completed = useCompletedJsx(jsx, isStreaming);
182
288
  const isIncomplete = jsx.trim() !== "" && completed !== jsx;
183
289
 
184
290
  const processedJsx = useMemo(() => {
@@ -293,7 +399,9 @@ export const JSXPreviewContent = memo(({ className, ...props }: JSXPreviewConten
293
399
  return (
294
400
  <div className={cn("jsx-preview-content", className)} {...props}>
295
401
  <JsxParser
402
+ allowUnknownElements={false}
296
403
  bindings={bindings}
404
+ blacklistedTags={JSX_PREVIEW_BLACKLISTED_TAGS}
297
405
  components={components}
298
406
  jsx={displayJsx}
299
407
  onError={handleError}