@elabs-ai/components-ai 4.1.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 (84) hide show
  1. package/README.md +2 -2
  2. package/dist/index.d.ts +43 -16
  3. package/dist/index.js +1297 -926
  4. package/dist/index.js.map +1 -1
  5. package/package.json +9 -9
  6. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  7. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  8. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  9. package/src/__contract__/image.contract.test.tsx +49 -0
  10. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  11. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  12. package/src/__contract__/message-form.contract.test.tsx +49 -0
  13. package/src/__contract__/message-table.contract.test.tsx +49 -0
  14. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  15. package/src/__contract__/persona.contract.test.tsx +49 -0
  16. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  17. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  18. package/src/_chat-shell-rail.tsx +2 -2
  19. package/src/_lazy-cjk.test.ts +43 -0
  20. package/src/_lazy-cjk.ts +73 -0
  21. package/src/_lazy-math.test.ts +63 -0
  22. package/src/_lazy-math.ts +90 -0
  23. package/src/_streamdown-i18n.ts +73 -21
  24. package/src/_streamdown-safety.ts +2 -2
  25. package/src/_theme-scope-store.test.ts +83 -0
  26. package/src/_theme-scope-store.ts +103 -0
  27. package/src/agent-event.tsx +1 -1
  28. package/src/agent.tsx +18 -13
  29. package/src/artifact.tsx +2 -2
  30. package/src/asset-preview.test.tsx +40 -0
  31. package/src/asset-preview.tsx +83 -12
  32. package/src/attachments.tsx +7 -4
  33. package/src/code-block.test.tsx +100 -1
  34. package/src/code-block.tsx +166 -103
  35. package/src/commit.tsx +30 -41
  36. package/src/confirmation.tsx +1 -1
  37. package/src/context-panel.tsx +1 -1
  38. package/src/conversation.stories.tsx +23 -0
  39. package/src/conversation.test.tsx +53 -0
  40. package/src/conversation.tsx +32 -7
  41. package/src/diff-view.test.tsx +52 -2
  42. package/src/diff-view.tsx +89 -34
  43. package/src/environment-variables.tsx +20 -37
  44. package/src/file-tree.test.tsx +21 -0
  45. package/src/file-tree.tsx +12 -2
  46. package/src/inline-citation.tsx +5 -5
  47. package/src/jsx-preview.tsx +151 -43
  48. package/src/markdown-view.test.tsx +7 -3
  49. package/src/markdown-view.tsx +8 -1
  50. package/src/message-form.stories.tsx +36 -3
  51. package/src/message-form.test.tsx +8 -2
  52. package/src/message-form.tsx +15 -7
  53. package/src/message-table.stories.tsx +2 -2
  54. package/src/message-table.test.tsx +7 -0
  55. package/src/message-table.tsx +8 -4
  56. package/src/message.test.tsx +73 -3
  57. package/src/message.tsx +36 -12
  58. package/src/model-provider-logo.test.tsx +57 -3
  59. package/src/model-provider-logo.tsx +45 -11
  60. package/src/open-in-chat.tsx +50 -29
  61. package/src/package-info.tsx +12 -12
  62. package/src/prompt-input-slash.stories.tsx +1 -1
  63. package/src/prompt-input.test.tsx +67 -1
  64. package/src/prompt-input.tsx +42 -4
  65. package/src/queue.tsx +4 -4
  66. package/src/reasoning.tsx +17 -6
  67. package/src/sandbox.tsx +3 -3
  68. package/src/schema-display.test.tsx +56 -0
  69. package/src/schema-display.tsx +68 -37
  70. package/src/session-header.tsx +1 -1
  71. package/src/snippet.test.tsx +6 -2
  72. package/src/snippet.tsx +14 -34
  73. package/src/speech-input.test.tsx +109 -0
  74. package/src/speech-input.tsx +31 -3
  75. package/src/stack-trace.tsx +20 -36
  76. package/src/streamdown-i18n.test.tsx +23 -0
  77. package/src/test-results.tsx +47 -27
  78. package/src/token-usage.tsx +6 -6
  79. package/src/tool.test.tsx +65 -0
  80. package/src/tool.tsx +59 -23
  81. package/src/transcription.tsx +1 -1
  82. package/src/voice-selector.tsx +5 -5
  83. package/src/web-preview.test.tsx +51 -1
  84. package/src/web-preview.tsx +39 -8
@@ -35,17 +35,17 @@ export const PackageInfoName = ({ className, children, ...props }: PackageInfoNa
35
35
  return (
36
36
  <div className={cn("flex items-center gap-2", className)} {...props}>
37
37
  <PackageIcon className="size-4 text-muted-foreground" />
38
- <span className="font-medium font-mono text-sm">{children ?? name}</span>
38
+ <span className="font-medium font-mono text-body">{children ?? name}</span>
39
39
  </div>
40
40
  );
41
41
  };
42
42
 
43
43
  const changeTypeStyles: Record<ChangeType, string> = {
44
- added: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
45
- major: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
46
- minor: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
47
- patch: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
48
- removed: "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
44
+ added: "bg-info/10 text-info-text",
45
+ major: "bg-destructive/10 text-destructive-text",
46
+ minor: "bg-warning/10 text-warning-text",
47
+ patch: "bg-success/10 text-success-text",
48
+ removed: "bg-muted text-muted-foreground",
49
49
  };
50
50
 
51
51
  const changeTypeIcons: Record<ChangeType, React.ReactNode> = {
@@ -71,7 +71,7 @@ export const PackageInfoChangeType = ({
71
71
 
72
72
  return (
73
73
  <Badge
74
- className={cn("gap-1 text-xs capitalize", changeTypeStyles[changeType], className)}
74
+ className={cn("gap-1 text-meta capitalize", changeTypeStyles[changeType], className)}
75
75
  variant="secondary"
76
76
  {...props}
77
77
  >
@@ -93,7 +93,7 @@ export const PackageInfoVersion = ({ className, children, ...props }: PackageInf
93
93
  return (
94
94
  <div
95
95
  className={cn(
96
- "mt-2 flex items-center gap-2 font-mono text-muted-foreground text-sm",
96
+ "mt-2 flex items-center gap-2 font-mono text-muted-foreground text-body",
97
97
  className,
98
98
  )}
99
99
  {...props}
@@ -154,7 +154,7 @@ export const PackageInfoDescription = ({
154
154
  children,
155
155
  ...props
156
156
  }: PackageInfoDescriptionProps) => (
157
- <p className={cn("mt-2 text-muted-foreground text-sm", className)} {...props}>
157
+ <p className={cn("mt-2 text-muted-foreground text-body", className)} {...props}>
158
158
  {children}
159
159
  </p>
160
160
  );
@@ -175,7 +175,7 @@ export const PackageInfoDependencies = ({
175
175
  ...props
176
176
  }: PackageInfoDependenciesProps) => (
177
177
  <div className={cn("space-y-2", className)} {...props}>
178
- <span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
178
+ <span className="font-medium text-muted-foreground text-meta uppercase tracking-wide">
179
179
  Dependencies
180
180
  </span>
181
181
  <div className="space-y-1">{children}</div>
@@ -194,11 +194,11 @@ export const PackageInfoDependency = ({
194
194
  children,
195
195
  ...props
196
196
  }: PackageInfoDependencyProps) => (
197
- <div className={cn("flex items-center justify-between text-sm", className)} {...props}>
197
+ <div className={cn("flex items-center justify-between text-body", className)} {...props}>
198
198
  {children ?? (
199
199
  <>
200
200
  <span className="font-mono text-muted-foreground">{name}</span>
201
- {version && <span className="font-mono text-xs">{version}</span>}
201
+ {version && <span className="font-mono text-meta">{version}</span>}
202
202
  </>
203
203
  )}
204
204
  </div>
@@ -28,7 +28,7 @@ function Field({ commands = COMMANDS }: { commands?: SlashCommand[] }) {
28
28
  const textareaRef = useRef<HTMLTextAreaElement>(null);
29
29
  const [text, setText] = useState("");
30
30
  return (
31
- <div className="mx-auto w-[28rem]">
31
+ <div className="mx-auto w-full max-w-[28rem]">
32
32
  <PromptInput onSubmit={() => undefined}>
33
33
  <PromptInputSlash
34
34
  commands={commands}
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
 
5
5
  import {
@@ -107,6 +107,72 @@ describe("PromptInput — empty submissions are blocked", () => {
107
107
  });
108
108
  });
109
109
 
110
+ describe("PromptInput — failed submit restores the composer (#1.9)", () => {
111
+ it("restores the typed text after a rejected async onSubmit", async () => {
112
+ const onSubmit = vi.fn(() => Promise.reject(new Error("network error")));
113
+ render(<Harness onSubmit={onSubmit} />);
114
+
115
+ const textarea = screen.getByPlaceholderText("Ask…") as HTMLTextAreaElement;
116
+ await userEvent.type(textarea, "hello{Enter}");
117
+
118
+ expect(onSubmit).toHaveBeenCalledTimes(1);
119
+ // form.reset() wipes the uncontrolled textarea before onSubmit even runs —
120
+ // a rejected promise must restore exactly what the user had typed.
121
+ await waitFor(() => expect(textarea.value).toBe("hello"));
122
+ });
123
+
124
+ it("restores the typed text after a synchronous onSubmit throw", async () => {
125
+ const onSubmit = vi.fn(() => {
126
+ throw new Error("boom");
127
+ });
128
+ render(<Harness onSubmit={onSubmit} />);
129
+
130
+ const textarea = screen.getByPlaceholderText("Ask…") as HTMLTextAreaElement;
131
+ await userEvent.type(textarea, "hello{Enter}");
132
+
133
+ expect(onSubmit).toHaveBeenCalledTimes(1);
134
+ await waitFor(() => expect(textarea.value).toBe("hello"));
135
+ });
136
+ });
137
+
138
+ describe("PromptInput — in-flight guard blocks a duplicate submit (#1.9)", () => {
139
+ it("does not call onSubmit twice for a double Enter on an attachment-only message before the first submit settles", async () => {
140
+ // Attachments are only cleared once onSubmit resolves, so the submit
141
+ // control stays enabled (canSubmit = files.length > 0) while the first
142
+ // call is still pending — an in-flight ref, not the affordance, must
143
+ // stop a second Enter from re-sending the same attachment.
144
+ let resolveSubmit: (() => void) | undefined;
145
+ const onSubmit = vi.fn(
146
+ () =>
147
+ new Promise<void>((resolve) => {
148
+ resolveSubmit = resolve;
149
+ }),
150
+ );
151
+ render(<Harness onSubmit={onSubmit} />);
152
+
153
+ const file = new File(["hello"], "notes.txt", { type: "text/plain" });
154
+ fireEvent.change(screen.getByLabelText("Upload files"), { target: { files: [file] } });
155
+
156
+ const textarea = screen.getByPlaceholderText("Ask…");
157
+ // Both Enters fire back-to-back, with NO await between them — the guard
158
+ // that matters here is the synchronous `isSubmittingRef` set before
159
+ // `handleSubmit`'s first `await` (the blob→data-URL conversion), not
160
+ // React re-rendering the (still-canSubmit) affordance in between.
161
+ fireEvent.keyDown(textarea, { key: "Enter" });
162
+ fireEvent.keyDown(textarea, { key: "Enter" });
163
+
164
+ // `handleSubmit` is async — `onSubmit` itself isn't called until after the
165
+ // attachment's blob→data-URL conversion microtask, so wait for it rather
166
+ // than asserting synchronously.
167
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
168
+ // Let the pending `onSubmit` promise's resolution (and the resulting
169
+ // `clear()`/state update) settle inside `act` before the test ends.
170
+ await act(async () => {
171
+ resolveSubmit?.();
172
+ });
173
+ });
174
+ });
175
+
110
176
  describe("PromptInputSubmit — disabled affordance mirrors the guard", () => {
111
177
  it("marks itself aria-disabled at rest and clears it after typing", async () => {
112
178
  render(<Harness />);
@@ -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
@@ -9,6 +9,7 @@ import {
9
9
  } from "@elabs-ai/components-ui";
10
10
  import { cn } from "@elabs-ai/components-ui/lib/cn";
11
11
  import {
12
+ getStreamdownPluginsKey,
12
13
  useStreamdownMermaidOptions,
13
14
  useStreamdownPlugins,
14
15
  useStreamdownTranslations,
@@ -155,12 +156,12 @@ const defaultGetThinkingMessage = (
155
156
  t: (key: string, vars?: Record<string, string | number>) => string,
156
157
  ) => {
157
158
  if (isStreaming || duration === 0) {
158
- return <Shimmer duration={1}>Thinking…</Shimmer>;
159
+ return <Shimmer duration={1}>{t("ai.reasoning.thinking")}</Shimmer>;
159
160
  }
160
161
  if (duration === undefined) {
161
162
  return <p>{t("ai.reasoning.thoughtDefault")}</p>;
162
163
  }
163
- return <p>Thought for {duration} seconds</p>;
164
+ return <p>{t("ai.reasoning.thoughtForDuration", { duration })}</p>;
164
165
  };
165
166
 
166
167
  export const ReasoningTrigger = memo(
@@ -171,7 +172,7 @@ export const ReasoningTrigger = memo(
171
172
  return (
172
173
  <CollapsibleTrigger
173
174
  className={cn(
174
- "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",
175
176
  className,
176
177
  )}
177
178
  {...props}
@@ -213,7 +214,10 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
213
214
  const translations = useStreamdownTranslations();
214
215
  // Brand-token-derived `code` plugin, not the package's static github-*
215
216
  // default (#315 follow-up) — re-derives when the active theme changes.
216
- 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 : "");
217
221
  // The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
218
222
  // render, including a missing `mermaid` optional peer (issue #33).
219
223
  const mermaidOptions = useStreamdownMermaidOptions();
@@ -221,14 +225,21 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
221
225
  return (
222
226
  <CollapsibleContent
223
227
  className={cn(
224
- "mt-4 text-sm",
228
+ "mt-4 text-body",
225
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)]",
226
230
  className,
227
231
  )}
228
232
  {...props}
229
233
  >
230
234
  {typeof children === "string" ? (
231
- <Streamdown plugins={plugins} translations={translations} mermaid={mermaidOptions}>
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
+ >
232
243
  {children}
233
244
  </Streamdown>
234
245
  ) : (
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
+ });