@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
@@ -556,6 +556,10 @@ export const PromptInput = ({
556
556
  // Refs
557
557
  const inputRef = useRef<HTMLInputElement | null>(null);
558
558
  const formRef = useRef<HTMLFormElement | null>(null);
559
+ // In-flight guard for handleSubmit (#1.9) — a ref, not state, so a second
560
+ // Enter/click during the same synchronous event-handling pass sees the
561
+ // updated value immediately (no waiting for a re-render).
562
+ const isSubmittingRef = useRef(false);
559
563
 
560
564
  // ----- Local attachments (only used when no provider)
561
565
  const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
@@ -887,6 +891,14 @@ export const PromptInput = ({
887
891
  async (event) => {
888
892
  event.preventDefault();
889
893
 
894
+ // In-flight guard (#1.9): a second Enter/click fired while the first
895
+ // submission is still being processed (mid blob conversion, mid async
896
+ // `onSubmit`) must not fire a second one — otherwise a double-Enter
897
+ // submits the same attachments twice.
898
+ if (isSubmittingRef.current) {
899
+ return;
900
+ }
901
+
890
902
  const form = event.currentTarget;
891
903
  const text = usingProvider
892
904
  ? controller.textInput.value
@@ -904,6 +916,23 @@ export const PromptInput = ({
904
916
  return;
905
917
  }
906
918
 
919
+ isSubmittingRef.current = true;
920
+
921
+ // The uncontrolled textarea's DOM value is about to be wiped by
922
+ // `form.reset()` below, before `onSubmit` has even run. Snapshot it so a
923
+ // sync throw / rejected promise can restore exactly what the user typed
924
+ // instead of losing it.
925
+ const textarea = usingProvider
926
+ ? null
927
+ : form.querySelector<HTMLTextAreaElement>('textarea[name="message"]');
928
+
929
+ const restoreTextOnFailure = () => {
930
+ if (textarea) {
931
+ textarea.value = text;
932
+ setHasLocalText(text.trim().length > 0);
933
+ }
934
+ };
935
+
907
936
  // Reset form immediately after capturing text to avoid race condition
908
937
  // where user input during async blob conversion would be lost
909
938
  if (!usingProvider) {
@@ -939,7 +968,9 @@ export const PromptInput = ({
939
968
  controller.textInput.clear();
940
969
  }
941
970
  } catch {
942
- // Don't clear on error - user may want to retry
971
+ // Don't clear on error - user may want to retry; restore the text
972
+ // the early reset above wiped.
973
+ restoreTextOnFailure();
943
974
  }
944
975
  } else {
945
976
  // Sync function completed without throwing, clear inputs
@@ -949,7 +980,11 @@ export const PromptInput = ({
949
980
  }
950
981
  }
951
982
  } catch {
952
- // Don't clear on error - user may want to retry
983
+ // Don't clear on error - user may want to retry; restore the text the
984
+ // early reset above wiped.
985
+ restoreTextOnFailure();
986
+ } finally {
987
+ isSubmittingRef.current = false;
953
988
  }
954
989
  },
955
990
  [usingProvider, controller, files, onSubmit, clear],
@@ -1583,7 +1618,10 @@ export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
1583
1618
  export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (
1584
1619
  // Content provided via children in props
1585
1620
  // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
1586
- <h3 className={cn("mb-2 px-3 font-medium text-muted-foreground text-xs", className)} {...props} />
1621
+ <h3
1622
+ className={cn("mb-2 px-3 font-medium text-muted-foreground text-meta", className)}
1623
+ {...props}
1624
+ />
1587
1625
  );
1588
1626
 
1589
1627
  export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
@@ -1596,7 +1634,7 @@ export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
1596
1634
 
1597
1635
  export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
1598
1636
  <div
1599
- className={cn("flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent", className)}
1637
+ className={cn("flex items-center gap-2 px-3 py-2 text-meta hover:bg-accent", className)}
1600
1638
  {...props}
1601
1639
  />
1602
1640
  );
package/src/queue.tsx CHANGED
@@ -32,7 +32,7 @@ export type QueueItemProps = ComponentProps<"li">;
32
32
  export const QueueItem = ({ className, ...props }: QueueItemProps) => (
33
33
  <li
34
34
  className={cn(
35
- "group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
35
+ "group flex flex-col gap-1 rounded-md px-3 py-1 text-body transition-colors hover:bg-muted",
36
36
  className,
37
37
  )}
38
38
  {...props}
@@ -90,7 +90,7 @@ export const QueueItemDescription = ({
90
90
  }: QueueItemDescriptionProps) => (
91
91
  <div
92
92
  className={cn(
93
- "ms-6 text-xs",
93
+ "ms-6 text-meta",
94
94
  completed ? "text-muted-foreground/40 line-through" : "text-muted-foreground",
95
95
  className,
96
96
  )}
@@ -141,7 +141,7 @@ export type QueueItemFileProps = ComponentProps<"span">;
141
141
 
142
142
  export const QueueItemFile = ({ children, className, ...props }: QueueItemFileProps) => (
143
143
  <span
144
- className={cn("flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs", className)}
144
+ className={cn("flex items-center gap-1 rounded border bg-muted px-2 py-1 text-meta", className)}
145
145
  {...props}
146
146
  >
147
147
  <PaperclipIcon size={12} />
@@ -177,7 +177,7 @@ export const QueueSectionTrigger = ({
177
177
  <CollapsibleTrigger asChild>
178
178
  <button
179
179
  className={cn(
180
- "group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-start font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
180
+ "group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-start font-medium text-muted-foreground text-body transition-colors hover:bg-muted",
181
181
  className,
182
182
  )}
183
183
  type="button"
package/src/reasoning.tsx CHANGED
@@ -1,9 +1,19 @@
1
1
  "use client";
2
2
 
3
3
  import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
4
+ import {
5
+ Collapsible,
6
+ CollapsibleContent,
7
+ CollapsibleTrigger,
8
+ useLocale,
9
+ } from "@elabs-ai/components-ui";
5
10
  import { cn } from "@elabs-ai/components-ui/lib/cn";
6
- import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
11
+ import {
12
+ getStreamdownPluginsKey,
13
+ useStreamdownMermaidOptions,
14
+ useStreamdownPlugins,
15
+ useStreamdownTranslations,
16
+ } from "./_streamdown-i18n";
7
17
  import { BrainIcon, ChevronDownIcon } from "lucide-react";
8
18
  import type { ComponentProps, ReactNode } from "react";
9
19
  import {
@@ -140,29 +150,29 @@ export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> &
140
150
  getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
141
151
  };
142
152
 
143
- const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
153
+ const defaultGetThinkingMessage = (
154
+ isStreaming: boolean,
155
+ duration: number | undefined,
156
+ t: (key: string, vars?: Record<string, string | number>) => string,
157
+ ) => {
144
158
  if (isStreaming || duration === 0) {
145
- return <Shimmer duration={1}>Thinking...</Shimmer>;
159
+ return <Shimmer duration={1}>{t("ai.reasoning.thinking")}</Shimmer>;
146
160
  }
147
161
  if (duration === undefined) {
148
- return <p>Thought for a few seconds</p>;
162
+ return <p>{t("ai.reasoning.thoughtDefault")}</p>;
149
163
  }
150
- return <p>Thought for {duration} seconds</p>;
164
+ return <p>{t("ai.reasoning.thoughtForDuration", { duration })}</p>;
151
165
  };
152
166
 
153
167
  export const ReasoningTrigger = memo(
154
- ({
155
- className,
156
- children,
157
- getThinkingMessage = defaultGetThinkingMessage,
158
- ...props
159
- }: ReasoningTriggerProps) => {
168
+ ({ className, children, getThinkingMessage, ...props }: ReasoningTriggerProps) => {
160
169
  const { isStreaming, isOpen, duration } = useReasoning();
170
+ const { t } = useLocale();
161
171
 
162
172
  return (
163
173
  <CollapsibleTrigger
164
174
  className={cn(
165
- "flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
175
+ "flex w-full items-center gap-2 text-muted-foreground text-body transition-colors hover:text-foreground",
166
176
  className,
167
177
  )}
168
178
  {...props}
@@ -170,7 +180,9 @@ export const ReasoningTrigger = memo(
170
180
  {children ?? (
171
181
  <>
172
182
  <BrainIcon className="size-4" />
173
- {getThinkingMessage(isStreaming, duration)}
183
+ {getThinkingMessage
184
+ ? getThinkingMessage(isStreaming, duration)
185
+ : defaultGetThinkingMessage(isStreaming, duration, t)}
174
186
  <ChevronDownIcon
175
187
  className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
176
188
  />
@@ -202,19 +214,32 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
202
214
  const translations = useStreamdownTranslations();
203
215
  // Brand-token-derived `code` plugin, not the package's static github-*
204
216
  // default (#315 follow-up) — re-derives when the active theme changes.
205
- const plugins = useStreamdownPlugins();
217
+ // `math`/`cjk` are lazy-loaded off the raw text (#perf-5, see
218
+ // `_streamdown-i18n.ts`) — pass `children` so they load only when this
219
+ // reasoning block actually needs them.
220
+ const plugins = useStreamdownPlugins(typeof children === "string" ? children : "");
221
+ // The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
222
+ // render, including a missing `mermaid` optional peer (issue #33).
223
+ const mermaidOptions = useStreamdownMermaidOptions();
206
224
 
207
225
  return (
208
226
  <CollapsibleContent
209
227
  className={cn(
210
- "mt-4 text-sm",
228
+ "mt-4 text-body",
211
229
  "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)]",
212
230
  className,
213
231
  )}
214
232
  {...props}
215
233
  >
216
234
  {typeof children === "string" ? (
217
- <Streamdown plugins={plugins} translations={translations}>
235
+ <Streamdown
236
+ // Forces a remount the first time the lazy math/cjk slot resolves —
237
+ // see `getStreamdownPluginsKey`'s doc in `_streamdown-i18n.ts` for why.
238
+ key={getStreamdownPluginsKey(plugins)}
239
+ plugins={plugins}
240
+ translations={translations}
241
+ mermaid={mermaidOptions}
242
+ >
218
243
  {children}
219
244
  </Streamdown>
220
245
  ) : (
@@ -13,7 +13,15 @@ import {
13
13
  const meta = {
14
14
  title: "AI/Sandbox",
15
15
  component: Sandbox,
16
- parameters: { layout: "padded" },
16
+ parameters: {
17
+ layout: "padded",
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "A collapsible, tabbed view of a code-execution tool part — code in, output out, read-only. The real Monaco editing surface is `Editor/CodeEditor`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `Sandbox` is the only place that knows whether the run body has arrived, so `SandboxContent` renders a layout-shaped skeleton while `loading`.",
22
+ },
23
+ },
24
+ },
17
25
  } satisfies Meta<typeof Sandbox>;
18
26
  export default meta;
19
27
  type Story = StoryObj<typeof meta>;
package/src/sandbox.tsx CHANGED
@@ -70,7 +70,7 @@ export const SandboxHeader = ({ className, title, state, ...props }: SandboxHead
70
70
  >
71
71
  <div className="flex items-center gap-2">
72
72
  <Code className="size-4 text-muted-foreground" />
73
- <span className="font-medium text-sm">{title}</span>
73
+ <span className="font-medium text-body">{title}</span>
74
74
  {getStatusBadge(state)}
75
75
  </div>
76
76
  <ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
@@ -135,7 +135,7 @@ export type SandboxTabsTriggerProps = ComponentProps<typeof TabsTrigger>;
135
135
  export const SandboxTabsTrigger = ({ className, ...props }: SandboxTabsTriggerProps) => (
136
136
  <TabsTrigger
137
137
  className={cn(
138
- "rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-sm transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
138
+ "rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-body transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
139
139
  className,
140
140
  )}
141
141
  {...props}
@@ -145,5 +145,5 @@ export const SandboxTabsTrigger = ({ className, ...props }: SandboxTabsTriggerPr
145
145
  export type SandboxTabContentProps = ComponentProps<typeof TabsContent>;
146
146
 
147
147
  export const SandboxTabContent = ({ className, ...props }: SandboxTabContentProps) => (
148
- <TabsContent className={cn("mt-0 text-sm", className)} {...props} />
148
+ <TabsContent className={cn("mt-0 text-body", className)} {...props} />
149
149
  );
@@ -0,0 +1,56 @@
1
+ /**
2
+ * schema-display.test.tsx — XSS lock for `SchemaDisplayPath` (#1.1 review finding).
3
+ *
4
+ * `path` is untrusted tool/model output. It must never be interpolated into
5
+ * `dangerouslySetInnerHTML` — the `{param}` highlight has to be rendered as
6
+ * real `<span>` children, not raw HTML.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { render, screen } from "@testing-library/react";
10
+ import { SchemaDisplay, SchemaDisplayHeader, SchemaDisplayPath } from "./schema-display";
11
+
12
+ describe("SchemaDisplayPath — XSS", () => {
13
+ it("never injects HTML from the path, even when it contains markup", () => {
14
+ const maliciousPath = '/users/{id}"><img src=x onerror=alert(1)>';
15
+
16
+ const { container } = render(
17
+ <SchemaDisplay method="GET" path={maliciousPath}>
18
+ <SchemaDisplayHeader>
19
+ <SchemaDisplayPath />
20
+ </SchemaDisplayHeader>
21
+ </SchemaDisplay>,
22
+ );
23
+
24
+ // The dangerous markup must render as literal text, never as a live element.
25
+ expect(container.querySelector("img")).not.toBeInTheDocument();
26
+ expect(container.textContent).toContain(maliciousPath);
27
+ });
28
+
29
+ it("highlights {param} segments as separate span children", () => {
30
+ render(
31
+ <SchemaDisplay method="GET" path="/users/{id}/posts/{postId}">
32
+ <SchemaDisplayHeader>
33
+ <SchemaDisplayPath />
34
+ </SchemaDisplayHeader>
35
+ </SchemaDisplay>,
36
+ );
37
+
38
+ const idSpan = screen.getByText("{id}");
39
+ const postIdSpan = screen.getByText("{postId}");
40
+ expect(idSpan.tagName).toBe("SPAN");
41
+ expect(postIdSpan.tagName).toBe("SPAN");
42
+ expect(idSpan).toHaveClass("text-info-text");
43
+ });
44
+
45
+ it("renders an explicit path with no param braces verbatim", () => {
46
+ render(
47
+ <SchemaDisplay method="POST" path="/users">
48
+ <SchemaDisplayHeader>
49
+ <SchemaDisplayPath />
50
+ </SchemaDisplayHeader>
51
+ </SchemaDisplay>,
52
+ );
53
+
54
+ expect(screen.getByText("/users")).toBeInTheDocument();
55
+ });
56
+ });
@@ -2,10 +2,11 @@
2
2
 
3
3
  import { Badge } from "@elabs-ai/components-ui";
4
4
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
5
+ import { useLocale } from "@elabs-ai/components-ui";
5
6
  import { cn } from "@elabs-ai/components-ui/lib/cn";
6
7
  import { ChevronRightIcon } from "lucide-react";
7
- import type { ComponentProps, HTMLAttributes } from "react";
8
- import { createContext, useContext, useMemo } from "react";
8
+ import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
9
+ import { createContext, Fragment, useContext, useMemo } from "react";
9
10
 
10
11
  type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
11
12
 
@@ -41,13 +42,36 @@ const SchemaDisplayContext = createContext<SchemaDisplayContextType>({
41
42
  });
42
43
 
43
44
  const methodStyles: Record<HttpMethod, string> = {
44
- DELETE: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
45
- GET: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
46
- PATCH: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
47
- POST: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
48
- PUT: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
45
+ DELETE: "border-destructive/40 bg-destructive/10 text-destructive-text",
46
+ GET: "border-success/40 bg-success/10 text-success-text",
47
+ PATCH: "border-warning/40 bg-warning/10 text-warning-text",
48
+ POST: "border-info/40 bg-info/10 text-info-text",
49
+ PUT: "border-warning/40 bg-warning/10 text-warning-text",
49
50
  };
50
51
 
52
+ /** Splits a `{param}` path into plain-text and parameter segments for safe rendering. */
53
+ function splitSchemaPath(path: string): Array<{ text: string; isParam: boolean }> {
54
+ const parts: Array<{ text: string; isParam: boolean }> = [];
55
+ const regex = /\{([^}]+)\}/g;
56
+ let lastIndex = 0;
57
+ let match: RegExpExecArray | null = regex.exec(path);
58
+
59
+ while (match !== null) {
60
+ if (match.index > lastIndex) {
61
+ parts.push({ isParam: false, text: path.slice(lastIndex, match.index) });
62
+ }
63
+ parts.push({ isParam: true, text: match[0] });
64
+ lastIndex = match.index + match[0].length;
65
+ match = regex.exec(path);
66
+ }
67
+
68
+ if (lastIndex < path.length) {
69
+ parts.push({ isParam: false, text: path.slice(lastIndex) });
70
+ }
71
+
72
+ return parts;
73
+ }
74
+
51
75
  export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>;
52
76
 
53
77
  export const SchemaDisplayHeader = ({
@@ -71,7 +95,7 @@ export const SchemaDisplayMethod = ({
71
95
 
72
96
  return (
73
97
  <Badge
74
- className={cn("font-mono text-xs", methodStyles[method], className)}
98
+ className={cn("font-mono text-meta", methodStyles[method], className)}
75
99
  variant="secondary"
76
100
  {...props}
77
101
  >
@@ -85,19 +109,26 @@ export type SchemaDisplayPathProps = HTMLAttributes<HTMLSpanElement>;
85
109
  export const SchemaDisplayPath = ({ className, children, ...props }: SchemaDisplayPathProps) => {
86
110
  const { path } = useContext(SchemaDisplayContext);
87
111
 
88
- // Highlight path parameters
89
- const highlightedPath = path.replaceAll(
90
- /\{([^}]+)\}/g,
91
- '<span class="text-blue-600 dark:text-blue-400">{$1}</span>',
112
+ // Highlight path parameters as real elements — the path is untrusted
113
+ // (tool/model output) and must never reach dangerouslySetInnerHTML.
114
+ const pathNodes = useMemo<ReactNode>(
115
+ () =>
116
+ splitSchemaPath(path).map((part, index) =>
117
+ part.isParam ? (
118
+ <span className="text-info-text" key={`${part.text}-${index}`}>
119
+ {part.text}
120
+ </span>
121
+ ) : (
122
+ <Fragment key={`${part.text}-${index}`}>{part.text}</Fragment>
123
+ ),
124
+ ),
125
+ [path],
92
126
  );
93
127
 
94
128
  return (
95
- <span
96
- className={cn("font-mono text-sm", className)}
97
- // oxlint-disable-next-line eslint-plugin-react(no-danger)
98
- dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
99
- {...props}
100
- />
129
+ <span className={cn("font-mono text-body", className)} {...props}>
130
+ {children ?? pathNodes}
131
+ </span>
101
132
  );
102
133
  };
103
134
 
@@ -111,7 +142,7 @@ export const SchemaDisplayDescription = ({
111
142
  const { description } = useContext(SchemaDisplayContext);
112
143
 
113
144
  return (
114
- <p className={cn("border-b px-4 py-3 text-muted-foreground text-sm", className)} {...props}>
145
+ <p className={cn("border-b px-4 py-3 text-muted-foreground text-body", className)} {...props}>
115
146
  {children ?? description}
116
147
  </p>
117
148
  );
@@ -142,25 +173,25 @@ export const SchemaDisplayParameter = ({
142
173
  }: SchemaDisplayParameterProps) => (
143
174
  <div className={cn("px-4 py-3 ps-10", className)} {...props}>
144
175
  <div className="flex items-center gap-2">
145
- <span className="font-mono text-sm">{name}</span>
146
- <Badge className="text-xs" variant="outline">
176
+ <span className="font-mono text-body">{name}</span>
177
+ <Badge className="text-meta" variant="outline">
147
178
  {type}
148
179
  </Badge>
149
180
  {location && (
150
- <Badge className="text-xs" variant="secondary">
181
+ <Badge className="text-meta" variant="secondary">
151
182
  {location}
152
183
  </Badge>
153
184
  )}
154
185
  {required && (
155
186
  <Badge
156
- className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
187
+ className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
157
188
  variant="secondary"
158
189
  >
159
190
  required
160
191
  </Badge>
161
192
  )}
162
193
  </div>
163
- {description && <p className="mt-1 text-muted-foreground text-sm">{description}</p>}
194
+ {description && <p className="mt-1 text-muted-foreground text-body">{description}</p>}
164
195
  </div>
165
196
  );
166
197
 
@@ -172,13 +203,14 @@ export const SchemaDisplayParameters = ({
172
203
  ...props
173
204
  }: SchemaDisplayParametersProps) => {
174
205
  const { parameters } = useContext(SchemaDisplayContext);
206
+ const { t } = useLocale();
175
207
 
176
208
  return (
177
209
  <Collapsible className={cn(className)} defaultOpen {...props}>
178
210
  <CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
179
211
  <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
180
- <span className="font-medium text-sm">Parameters</span>
181
- <Badge className="ms-auto text-xs" variant="secondary">
212
+ <span className="font-medium text-body">{t("ai.schemaDisplay.parameters")}</span>
213
+ <Badge className="ms-auto text-meta" variant="secondary">
182
214
  {parameters?.length}
183
215
  </Badge>
184
216
  </CollapsibleTrigger>
@@ -222,13 +254,13 @@ export const SchemaDisplayProperty = ({
222
254
  style={{ paddingLeft }}
223
255
  >
224
256
  <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
225
- <span className="font-mono text-sm">{name}</span>
226
- <Badge className="text-xs" variant="outline">
257
+ <span className="font-mono text-body">{name}</span>
258
+ <Badge className="text-meta" variant="outline">
227
259
  {type}
228
260
  </Badge>
229
261
  {required && (
230
262
  <Badge
231
- className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
263
+ className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
232
264
  variant="secondary"
233
265
  >
234
266
  required
@@ -237,7 +269,7 @@ export const SchemaDisplayProperty = ({
237
269
  </CollapsibleTrigger>
238
270
  {description && (
239
271
  <p
240
- className="pb-2 text-muted-foreground text-sm"
272
+ className="pb-2 text-muted-foreground text-body"
241
273
  style={{ paddingLeft: paddingLeft + 24 }}
242
274
  >
243
275
  {description}
@@ -260,20 +292,20 @@ export const SchemaDisplayProperty = ({
260
292
  <div className="flex items-center gap-2">
261
293
  {/* Spacer for alignment */}
262
294
  <span className="size-4" />
263
- <span className="font-mono text-sm">{name}</span>
264
- <Badge className="text-xs" variant="outline">
295
+ <span className="font-mono text-body">{name}</span>
296
+ <Badge className="text-meta" variant="outline">
265
297
  {type}
266
298
  </Badge>
267
299
  {required && (
268
300
  <Badge
269
- className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
301
+ className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
270
302
  variant="secondary"
271
303
  >
272
304
  required
273
305
  </Badge>
274
306
  )}
275
307
  </div>
276
- {description && <p className="mt-1 ps-6 text-muted-foreground text-sm">{description}</p>}
308
+ {description && <p className="mt-1 ps-6 text-muted-foreground text-body">{description}</p>}
277
309
  </div>
278
310
  );
279
311
  };
@@ -286,12 +318,13 @@ export const SchemaDisplayRequest = ({
286
318
  ...props
287
319
  }: SchemaDisplayRequestProps) => {
288
320
  const { requestBody } = useContext(SchemaDisplayContext);
321
+ const { t } = useLocale();
289
322
 
290
323
  return (
291
324
  <Collapsible className={cn(className)} defaultOpen {...props}>
292
325
  <CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
293
326
  <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
294
- <span className="font-medium text-sm">Request Body</span>
327
+ <span className="font-medium text-body">{t("ai.schemaDisplay.requestBody")}</span>
295
328
  </CollapsibleTrigger>
296
329
  <CollapsibleContent>
297
330
  <div className="border-t">
@@ -313,12 +346,13 @@ export const SchemaDisplayResponse = ({
313
346
  ...props
314
347
  }: SchemaDisplayResponseProps) => {
315
348
  const { responseBody } = useContext(SchemaDisplayContext);
349
+ const { t } = useLocale();
316
350
 
317
351
  return (
318
352
  <Collapsible className={cn(className)} defaultOpen {...props}>
319
353
  <CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
320
354
  <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
321
- <span className="font-medium text-sm">Response</span>
355
+ <span className="font-medium text-body">{t("ai.schemaDisplay.response")}</span>
322
356
  </CollapsibleTrigger>
323
357
  <CollapsibleContent>
324
358
  <div className="border-t">
@@ -404,7 +438,7 @@ export const SchemaDisplayExample = ({
404
438
  ...props
405
439
  }: SchemaDisplayExampleProps) => (
406
440
  <pre
407
- className={cn("mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-sm", className)}
441
+ className={cn("mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-body", className)}
408
442
  {...props}
409
443
  >
410
444
  {children}
@@ -8,7 +8,15 @@ const meta = {
8
8
  title: "AI/SelectionToolbar",
9
9
  component: SelectionToolbar,
10
10
  tags: ["autodocs"],
11
- parameters: { layout: "padded" },
11
+ parameters: {
12
+ layout: "padded",
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "The small toolbar that floats over a TEXT SELECTION in a transcript, offering Quote. A dense secondary control row is `Layout/Toolbar`, the row above a list or table is `Layout/ViewToolbar`, and formatting chrome for a markdown source pane is `Editor/MarkdownToolbar` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Positioned with a Popover virtual anchor at the selection rect; presentational only.",
17
+ },
18
+ },
19
+ },
12
20
  } satisfies Meta<typeof SelectionToolbar>;
13
21
  export default meta;
14
22
  type Story = StoryObj<typeof meta>;