@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
package/src/agent.tsx CHANGED
@@ -31,9 +31,9 @@ export const AgentHeader = memo(({ className, name, model, ...props }: AgentHead
31
31
  <div className={cn("flex w-full items-center justify-between gap-4 p-3", className)} {...props}>
32
32
  <div className="flex items-center gap-2">
33
33
  <BotIcon className="size-4 text-muted-foreground" />
34
- <span className="font-medium text-sm">{name}</span>
34
+ <span className="font-medium text-body">{name}</span>
35
35
  {model && (
36
- <Badge className="font-mono text-xs" variant="secondary">
36
+ <Badge className="font-mono text-meta" variant="secondary">
37
37
  {model}
38
38
  </Badge>
39
39
  )}
@@ -56,10 +56,10 @@ export const AgentInstructions = memo(
56
56
  const { t } = useLocale();
57
57
  return (
58
58
  <div className={cn("space-y-2", className)} {...props}>
59
- <span className="font-medium text-muted-foreground text-sm">
59
+ <span className="font-medium text-muted-foreground text-body">
60
60
  {t("ai.agent.instructions")}
61
61
  </span>
62
- <div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
62
+ <div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-body">
63
63
  <p>{children}</p>
64
64
  </div>
65
65
  </div>
@@ -73,7 +73,7 @@ export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => {
73
73
  const { t } = useLocale();
74
74
  return (
75
75
  <div className={cn("space-y-2", className)}>
76
- <span className="font-medium text-muted-foreground text-sm">{t("ai.agent.tools")}</span>
76
+ <span className="font-medium text-muted-foreground text-body">{t("ai.agent.tools")}</span>
77
77
  <Accordion className="rounded-md border" {...props} />
78
78
  </div>
79
79
  );
@@ -93,7 +93,7 @@ export const AgentTool = memo(({ className, tool, value, ...props }: AgentToolPr
93
93
 
94
94
  return (
95
95
  <AccordionItem className={cn("border-b last:border-b-0", className)} value={value} {...props}>
96
- <AccordionTrigger className="px-3 py-2 text-sm hover:no-underline">
96
+ <AccordionTrigger className="px-3 py-2 text-body hover:no-underline">
97
97
  {description ?? "No description"}
98
98
  </AccordionTrigger>
99
99
  <AccordionContent className="px-3 pb-3">
@@ -109,14 +109,19 @@ export type AgentOutputProps = ComponentProps<"div"> & {
109
109
  schema: string;
110
110
  };
111
111
 
112
- export const AgentOutput = memo(({ className, schema, ...props }: AgentOutputProps) => (
113
- <div className={cn("space-y-2", className)} {...props}>
114
- <span className="font-medium text-muted-foreground text-sm">Output Schema</span>
115
- <div className="rounded-md bg-muted/50">
116
- <CodeBlock code={schema} language="typescript" />
112
+ export const AgentOutput = memo(({ className, schema, ...props }: AgentOutputProps) => {
113
+ const { t } = useLocale();
114
+ return (
115
+ <div className={cn("space-y-2", className)} {...props}>
116
+ <span className="font-medium text-muted-foreground text-body">
117
+ {t("ai.agent.outputSchema")}
118
+ </span>
119
+ <div className="rounded-md bg-muted/50">
120
+ <CodeBlock code={schema} language="typescript" />
121
+ </div>
117
122
  </div>
118
- </div>
119
- ));
123
+ );
124
+ });
120
125
 
121
126
  Agent.displayName = "Agent";
122
127
  AgentHeader.displayName = "AgentHeader";
package/src/artifact.tsx CHANGED
@@ -86,13 +86,13 @@ export const ArtifactClose = ({
86
86
  export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
87
87
 
88
88
  export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
89
- <p className={cn("font-medium text-foreground text-sm", className)} {...props} />
89
+ <p className={cn("font-medium text-foreground text-body", className)} {...props} />
90
90
  );
91
91
 
92
92
  export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
93
93
 
94
94
  export const ArtifactDescription = ({ className, ...props }: ArtifactDescriptionProps) => (
95
- <p className={cn("text-muted-foreground text-sm", className)} {...props} />
95
+ <p className={cn("text-muted-foreground text-body", className)} {...props} />
96
96
  );
97
97
 
98
98
  export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
@@ -54,6 +54,46 @@ describe("AssetPreview (#193, research 04 §5 ASSET-4)", () => {
54
54
  });
55
55
  });
56
56
 
57
+ // The old parser split every line on a bare `,` — a comma, an escaped quote,
58
+ // or a newline INSIDE a quoted field corrupted the row/column count.
59
+ describe("AssetPreview CSV parsing — quoted fields (review #1)", () => {
60
+ it("keeps a comma inside a quoted field as part of that one cell", () => {
61
+ const asset: ContextAsset = {
62
+ id: "quoted-comma",
63
+ name: "quoted-comma.csv",
64
+ type: "csv",
65
+ content: 'name,note\n"Acme, Inc.",big account',
66
+ };
67
+ render(<AssetPreview asset={asset} />);
68
+ expect(screen.getByText("Acme, Inc.")).toBeInTheDocument();
69
+ expect(screen.getByText("1 row")).toBeInTheDocument();
70
+ });
71
+
72
+ it("unescapes a doubled quote inside a quoted field", () => {
73
+ const asset: ContextAsset = {
74
+ id: "escaped-quote",
75
+ name: "escaped-quote.csv",
76
+ type: "csv",
77
+ content: 'name,note\n"Say ""hi""",greeting',
78
+ };
79
+ render(<AssetPreview asset={asset} />);
80
+ expect(screen.getByText('Say "hi"')).toBeInTheDocument();
81
+ });
82
+
83
+ it("keeps a newline inside a quoted field from starting a new row", () => {
84
+ const asset: ContextAsset = {
85
+ id: "embedded-newline",
86
+ name: "embedded-newline.csv",
87
+ type: "csv",
88
+ content: 'name,note\n"multi\nline",x\nsecond,y',
89
+ };
90
+ render(<AssetPreview asset={asset} />);
91
+ // Two data rows total — the embedded newline must NOT read as a third.
92
+ expect(screen.getByText("2 rows")).toBeInTheDocument();
93
+ expect(screen.getByText("second")).toBeInTheDocument();
94
+ });
95
+ });
96
+
57
97
  /**
58
98
  * The seam that lets a format this package cannot parse (a PDF, a spreadsheet,
59
99
  * a video) show up in the rail without `@elabs-ai/components-ai`
@@ -30,6 +30,7 @@ import {
30
30
  TableHead,
31
31
  TableHeader,
32
32
  TableRow,
33
+ useLocale,
33
34
  } from "@elabs-ai/components-ui";
34
35
  import { cn } from "@elabs-ai/components-ui/lib/cn";
35
36
  import type { HTMLAttributes, ReactNode } from "react";
@@ -72,18 +73,87 @@ interface ParsedCsv {
72
73
  rows: string[][];
73
74
  }
74
75
 
75
- /** Minimal preview-grade CSV split (no quoted-comma handling — it's a glance, not a grid). */
76
+ /**
77
+ * A small RFC 4180-ish CSV tokenizer: handles quoted fields (commas and
78
+ * newlines inside quotes don't end the field/row) and the `""` escaped-quote
79
+ * convention. Single pass over the raw string — no line pre-split, so a
80
+ * quoted field spanning multiple lines survives intact. Still preview-grade
81
+ * (unquoted cells are trimmed for the glance table), not a full CSV grammar.
82
+ */
83
+ function parseCsvRows(content: string): string[][] {
84
+ const rows: string[][] = [];
85
+ let row: string[] = [];
86
+ let field = "";
87
+ let wasQuoted = false;
88
+ let inQuotes = false;
89
+
90
+ const endField = () => {
91
+ row.push(wasQuoted ? field : field.trim());
92
+ field = "";
93
+ wasQuoted = false;
94
+ };
95
+ const endRow = () => {
96
+ endField();
97
+ rows.push(row);
98
+ row = [];
99
+ };
100
+
101
+ for (let i = 0; i < content.length; i++) {
102
+ const ch = content[i];
103
+
104
+ if (inQuotes) {
105
+ if (ch === '"') {
106
+ if (content[i + 1] === '"') {
107
+ field += '"';
108
+ i += 1;
109
+ } else {
110
+ inQuotes = false;
111
+ }
112
+ } else {
113
+ field += ch;
114
+ }
115
+ continue;
116
+ }
117
+
118
+ if (ch === '"' && field === "") {
119
+ inQuotes = true;
120
+ wasQuoted = true;
121
+ continue;
122
+ }
123
+
124
+ if (ch === ",") {
125
+ endField();
126
+ continue;
127
+ }
128
+
129
+ if (ch === "\r") {
130
+ continue;
131
+ }
132
+
133
+ if (ch === "\n") {
134
+ endRow();
135
+ continue;
136
+ }
137
+
138
+ field += ch;
139
+ }
140
+
141
+ // A trailing field/row with no terminating newline.
142
+ if (field !== "" || row.length > 0) {
143
+ endRow();
144
+ }
145
+
146
+ return rows;
147
+ }
148
+
76
149
  function parseCsv(content: string): ParsedCsv {
77
- const lines = content
78
- .trim()
79
- .split(/\r?\n/)
80
- .filter((line) => line.length > 0);
81
- const [head = "", ...rest] = lines;
82
- const split = (line: string) => line.split(",").map((cell) => cell.trim());
83
- return { header: split(head), rows: rest.map(split) };
150
+ const rows = parseCsvRows(content.trim()).filter((r) => !(r.length === 1 && r[0] === ""));
151
+ const [header = [], ...rest] = rows;
152
+ return { header, rows: rest };
84
153
  }
85
154
 
86
155
  const CsvPreview = ({ content }: { content: string }) => {
156
+ const { t } = useLocale();
87
157
  const { header, rows } = useMemo(() => parseCsv(content), [content]);
88
158
  return (
89
159
  <div className="flex flex-col gap-2">
@@ -109,7 +179,7 @@ const CsvPreview = ({ content }: { content: string }) => {
109
179
  </TableBody>
110
180
  </Table>
111
181
  <p className="text-meta text-muted-foreground">
112
- {rows.length} {rows.length === 1 ? "row" : "rows"}
182
+ {t("ai.assetPreview.rowCount", { count: rows.length })}
113
183
  </p>
114
184
  </div>
115
185
  );
@@ -135,6 +205,7 @@ export const AssetPreview = ({
135
205
  className,
136
206
  ...props
137
207
  }: AssetPreviewProps) => {
208
+ const { t } = useLocale();
138
209
  const [mode, setMode] = useState<AssetPreviewMode>(defaultMode);
139
210
  const injectedRenderer = useAssetPreviewRenderer();
140
211
  const content = asset.content ?? "";
@@ -150,7 +221,7 @@ export const AssetPreview = ({
150
221
  if (injected !== null) {
151
222
  body = injected;
152
223
  } else if (asset.type !== "image" && content.length === 0) {
153
- body = <p className="text-body text-muted-foreground">No preview available…</p>;
224
+ body = <p className="text-body text-muted-foreground">{t("ai.assetPreview.noPreview")}</p>;
154
225
  } else if (showRaw) {
155
226
  body = <CodeBlock code={content} language={assetLanguage(asset)} wrap />;
156
227
  } else {
@@ -197,7 +268,7 @@ export const AssetPreview = ({
197
268
  className={cn(!showRaw && "bg-accent text-accent-foreground")}
198
269
  onClick={() => setMode("preview")}
199
270
  >
200
- Preview
271
+ {t("ai.assetPreview.preview")}
201
272
  </Button>
202
273
  <Button
203
274
  type="button"
@@ -207,7 +278,7 @@ export const AssetPreview = ({
207
278
  className={cn(showRaw && "bg-accent text-accent-foreground")}
208
279
  onClick={() => setMode("raw")}
209
280
  >
210
- Raw
281
+ {t("ai.assetPreview.raw")}
211
282
  </Button>
212
283
  </ArtifactActions>
213
284
  ) : null}
@@ -192,8 +192,8 @@ export const Attachment = ({ data, onRemove, className, children, ...props }: At
192
192
  variant === "inline" && [
193
193
  "flex h-8 cursor-pointer select-none items-center gap-1.5",
194
194
  "rounded-md border border-border px-1.5",
195
- "font-medium text-sm transition-colors duration-fast ease-standard motion-reduce:transition-none",
196
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
195
+ "font-medium text-body transition-colors duration-fast ease-standard motion-reduce:transition-none",
196
+ "hover:bg-accent/50 hover:text-accent-foreground",
197
197
  ],
198
198
  variant === "list" && [
199
199
  "flex w-full items-center gap-3 rounded-lg border p-3",
@@ -283,7 +283,7 @@ export const AttachmentInfo = ({
283
283
  <div className={cn("min-w-0 flex-1", className)} {...props}>
284
284
  <span className="block truncate">{label}</span>
285
285
  {showMediaType && data.mediaType && (
286
- <span className="block truncate text-muted-foreground text-xs">{data.mediaType}</span>
286
+ <span className="block truncate text-muted-foreground text-meta">{data.mediaType}</span>
287
287
  )}
288
288
  </div>
289
289
  );
@@ -385,7 +385,10 @@ export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
385
385
 
386
386
  export const AttachmentEmpty = ({ className, children, ...props }: AttachmentEmptyProps) => (
387
387
  <div
388
- className={cn("flex items-center justify-center p-4 text-muted-foreground text-sm", className)}
388
+ className={cn(
389
+ "flex items-center justify-center p-4 text-muted-foreground text-body",
390
+ className,
391
+ )}
389
392
  {...props}
390
393
  >
391
394
  {children ?? "No attachments"}
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import type { CSSProperties } from "react";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { render, waitFor } from "@testing-library/react";
7
- import { CodeBlock } from "./code-block";
7
+ import { CodeBlock, CodeBlockCopyButton, highlightCode } from "./code-block";
8
8
 
9
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
 
@@ -189,3 +189,102 @@ describe("CodeBlock isStreaming (#269, loading-states.md)", () => {
189
189
  expect(container.querySelector('[role="status"]')).toBeNull();
190
190
  });
191
191
  });
192
+
193
+ describe("CodeBlockCopyButton accessible name (a11y review)", () => {
194
+ it("has an aria-label so the icon-only button has an accessible name", () => {
195
+ const { container } = render(
196
+ <CodeBlock code="const a = 1;" language="tsx">
197
+ <CodeBlockCopyButton />
198
+ </CodeBlock>,
199
+ );
200
+ expect(container.querySelector("button")).toHaveAccessibleName("Copy");
201
+ });
202
+
203
+ it("lets a consumer override the label", () => {
204
+ const { container } = render(
205
+ <CodeBlock code="const a = 1;" language="tsx">
206
+ <CodeBlockCopyButton aria-label="Copy snippet" />
207
+ </CodeBlock>,
208
+ );
209
+ expect(container.querySelector("button")).toHaveAccessibleName("Copy snippet");
210
+ });
211
+ });
212
+
213
+ // ─── `highlightCode` awaited helper for the perf-fix regression locks below ──
214
+
215
+ function highlightAsync(
216
+ code: string,
217
+ language: Parameters<typeof highlightCode>[1],
218
+ el?: Element | null,
219
+ skipCache = false,
220
+ ): Promise<NonNullable<ReturnType<typeof highlightCode>>> {
221
+ return new Promise((resolve) => {
222
+ const cached = highlightCode(code, language, (result) => resolve(result), el, skipCache);
223
+ if (cached) resolve(cached);
224
+ });
225
+ }
226
+
227
+ const flattenTokenContent = (result: NonNullable<ReturnType<typeof highlightCode>>): string =>
228
+ result.tokens.map((line) => line.map((token) => token.content).join("")).join("\n");
229
+
230
+ describe("highlightCode cache key — no hash collision (perf review 1.4b/1.4c)", () => {
231
+ it("does not collide two >200-char strings sharing a length, prefix and suffix but differing in the middle", async () => {
232
+ const prefix = "a".repeat(150);
233
+ const suffix = "b".repeat(150);
234
+ // Same length, same first/last 100 chars — the OLD `length:first100:last100`
235
+ // cache key collided on exactly this shape (a mid-string edit), showing
236
+ // whichever of the two resolved second on top of BOTH code blocks.
237
+ const codeA = `${prefix}1${suffix}`;
238
+ const codeB = `${prefix}2${suffix}`;
239
+
240
+ await highlightAsync(codeA, "tsx");
241
+ await highlightAsync(codeB, "tsx");
242
+
243
+ // Re-requesting codeA must still return a SYNCHRONOUS cache hit for its
244
+ // OWN content — never codeB's.
245
+ const againA = highlightCode(codeA, "tsx");
246
+ expect(againA).not.toBeNull();
247
+ expect(flattenTokenContent(againA!)).toBe(codeA);
248
+ expect(flattenTokenContent(againA!)).not.toBe(codeB);
249
+ });
250
+ });
251
+
252
+ describe("highlightCode tokensCache is bounded (perf review 1.4a — LRU ~200 entries)", () => {
253
+ it("evicts the least-recently-used entry once the cache exceeds its cap", async () => {
254
+ const codes = Array.from({ length: 205 }, (_, i) => `const lruEvictionProbe_${i} = ${i};`);
255
+
256
+ for (const code of codes) {
257
+ // Sequential on purpose: each must actually populate the cache (and
258
+ // become the most-recently-used entry) before the next one runs.
259
+
260
+ await highlightAsync(code, "tsx");
261
+ }
262
+
263
+ // The very first entry inserted is the least-recently-used once 205 > the
264
+ // 200-entry cap — it must have been evicted, so re-requesting it is a
265
+ // cache MISS (highlightCode returns null synchronously and kicks off a
266
+ // fresh async highlight instead).
267
+ expect(highlightCode(codes[0] as string, "tsx")).toBeNull();
268
+
269
+ // The most recently inserted entry is still a synchronous cache hit.
270
+ expect(highlightCode(codes[204] as string, "tsx")).not.toBeNull();
271
+ });
272
+ });
273
+
274
+ describe("highlightCode skipCache (perf review 1.4a — never permanently cache mid-stream)", () => {
275
+ it("does not persist a highlight into the shared cache when skipCache is set", async () => {
276
+ const code = "const streamingSkipCacheProbe = 'a';";
277
+ await highlightAsync(code, "tsx", undefined, true);
278
+
279
+ // A later, non-streaming request for the EXACT same code is a cache
280
+ // MISS — the streaming pass never wrote it in.
281
+ expect(highlightCode(code, "tsx")).toBeNull();
282
+ });
283
+
284
+ it("still persists an ordinary (non-streaming) highlight", async () => {
285
+ const code = "const nonStreamingCacheProbe = 'b';";
286
+ await highlightAsync(code, "tsx");
287
+
288
+ expect(highlightCode(code, "tsx")).not.toBeNull();
289
+ });
290
+ });