@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
@@ -16,6 +16,7 @@ import type { UIMessage } from "ai";
16
16
  import {
17
17
  Conversation,
18
18
  ConversationContent,
19
+ ConversationDownload,
19
20
  ConversationEmptyState,
20
21
  messagesToMarkdown,
21
22
  } from "./conversation";
@@ -69,6 +70,46 @@ describe("Conversation — transcript region", () => {
69
70
  expect(screen.getByRole("log")).toHaveClass("extra");
70
71
  expect(screen.getByRole("log")).toHaveClass("flex-1");
71
72
  });
73
+
74
+ it('defaults to aria-live="polite" and no aria-busy (not streaming)', () => {
75
+ render(
76
+ <Conversation>
77
+ <ConversationContent>x</ConversationContent>
78
+ </Conversation>,
79
+ );
80
+ const log = screen.getByRole("log");
81
+ expect(log).toHaveAttribute("aria-live", "polite");
82
+ expect(log).not.toHaveAttribute("aria-busy");
83
+ });
84
+
85
+ it('suppresses aria-live and sets aria-busy while isStreaming (perf review §2 — role="log" must not announce every token)', () => {
86
+ render(
87
+ <Conversation isStreaming>
88
+ <ConversationContent>x</ConversationContent>
89
+ </Conversation>,
90
+ );
91
+ const log = screen.getByRole("log");
92
+ expect(log).toHaveAttribute("aria-live", "off");
93
+ expect(log).toHaveAttribute("aria-busy", "true");
94
+ });
95
+
96
+ it("returns to aria-live=polite once streaming settles, so the final content is announced", () => {
97
+ const { rerender } = render(
98
+ <Conversation isStreaming>
99
+ <ConversationContent>partial</ConversationContent>
100
+ </Conversation>,
101
+ );
102
+ expect(screen.getByRole("log")).toHaveAttribute("aria-live", "off");
103
+
104
+ rerender(
105
+ <Conversation isStreaming={false}>
106
+ <ConversationContent>final</ConversationContent>
107
+ </Conversation>,
108
+ );
109
+ const log = screen.getByRole("log");
110
+ expect(log).toHaveAttribute("aria-live", "polite");
111
+ expect(log).not.toHaveAttribute("aria-busy");
112
+ });
72
113
  });
73
114
 
74
115
  describe("ConversationEmptyState", () => {
@@ -108,6 +149,18 @@ describe("ConversationEmptyState", () => {
108
149
  });
109
150
  });
110
151
 
152
+ describe("ConversationDownload — accessible name (accessibility.md)", () => {
153
+ it("names the icon-only download control", () => {
154
+ render(<ConversationDownload messages={messages} />);
155
+ expect(screen.getByRole("button", { name: "Download conversation" })).toBeInTheDocument();
156
+ });
157
+
158
+ it("lets a caller override the accessible name", () => {
159
+ render(<ConversationDownload aria-label="Export chat" messages={messages} />);
160
+ expect(screen.getByRole("button", { name: "Export chat" })).toBeInTheDocument();
161
+ });
162
+ });
163
+
111
164
  describe("messagesToMarkdown — the download serializer", () => {
112
165
  it("labels each message by role and joins every text part", () => {
113
166
  expect(messagesToMarkdown(messages)).toBe(
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button, downloadBlob } from "@elabs-ai/components-ui";
3
+ import { Button, downloadBlob, useLocale } from "@elabs-ai/components-ui";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import type { UIMessage } from "ai";
6
6
  import { ArrowDownIcon, DownloadIcon } from "lucide-react";
@@ -8,14 +8,27 @@ import type { ComponentProps } from "react";
8
8
  import { useCallback } from "react";
9
9
  import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
10
10
 
11
- export type ConversationProps = ComponentProps<typeof StickToBottom>;
11
+ export type ConversationProps = ComponentProps<typeof StickToBottom> & {
12
+ /**
13
+ * The assistant response is still arriving (loading-states.md
14
+ * `isStreaming`). `role="log"` otherwise announces every incoming token to
15
+ * assistive tech as it streams in — deafening. While `true`, `aria-live`
16
+ * is suppressed (`"off"`) and `aria-busy` is set; once streaming ends,
17
+ * `aria-live` returns to `"polite"` so the settled content is what
18
+ * actually gets announced.
19
+ * @default false
20
+ */
21
+ isStreaming?: boolean;
22
+ };
12
23
 
13
- export const Conversation = ({ className, ...props }: ConversationProps) => (
24
+ export const Conversation = ({ className, isStreaming = false, ...props }: ConversationProps) => (
14
25
  <StickToBottom
15
26
  className={cn("relative flex-1 overflow-y-hidden", className)}
16
27
  initial="smooth"
17
28
  resize="smooth"
18
29
  role="log"
30
+ aria-live={isStreaming ? "off" : "polite"}
31
+ aria-busy={isStreaming || undefined}
19
32
  {...props}
20
33
  />
21
34
  );
@@ -68,8 +81,8 @@ export const ConversationEmptyState = ({
68
81
  <>
69
82
  {icon && <div className="text-muted-foreground">{icon}</div>}
70
83
  <div className="space-y-1">
71
- <h3 className="font-medium text-sm">{title}</h3>
72
- {description && <p className="text-muted-foreground text-sm">{description}</p>}
84
+ <h3 className="font-medium text-body">{title}</h3>
85
+ {description && <p className="text-muted-foreground text-body">{description}</p>}
73
86
  </div>
74
87
  {actions ? <div className="mt-1 flex items-center gap-2">{actions}</div> : null}
75
88
  </>
@@ -84,6 +97,7 @@ export const ConversationScrollButton = ({
84
97
  ...props
85
98
  }: ConversationScrollButtonProps) => {
86
99
  const { isAtBottom, scrollToBottom } = useStickToBottomContext();
100
+ const { t } = useLocale();
87
101
 
88
102
  const handleScrollToBottom = useCallback(() => {
89
103
  scrollToBottom();
@@ -93,9 +107,16 @@ export const ConversationScrollButton = ({
93
107
  !isAtBottom && (
94
108
  <Button
95
109
  className={cn(
96
- "absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
110
+ // Was `dark:bg-background dark:hover:bg-muted` — a hardcoded-dark
111
+ // branch invisible to any other registered theme (styling-and-tokens.md).
112
+ // `bg-background` already matches the `outline` variant's own default;
113
+ // the quieter `hover:bg-muted` (in place of `outline`'s default
114
+ // `hover:bg-accent`) now applies in every theme, not only the two
115
+ // shipped ones.
116
+ "absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full bg-background hover:bg-muted",
97
117
  className,
98
118
  )}
119
+ aria-label={t("ai.turnStatus.scrollToBottom")}
99
120
  onClick={handleScrollToBottom}
100
121
  size="icon"
101
122
  type="button"
@@ -138,6 +159,7 @@ export const ConversationDownload = ({
138
159
  children,
139
160
  ...props
140
161
  }: ConversationDownloadProps) => {
162
+ const { t } = useLocale();
141
163
  const handleDownload = useCallback(() => {
142
164
  const markdown = messagesToMarkdown(messages, formatMessage);
143
165
  downloadBlob(new Blob([markdown], { type: "text/markdown" }), filename);
@@ -146,9 +168,12 @@ export const ConversationDownload = ({
146
168
  return (
147
169
  <Button
148
170
  className={cn(
149
- "absolute top-4 end-4 rounded-full dark:bg-background dark:hover:bg-muted",
171
+ // See `ConversationScrollButton` — was a hardcoded-dark-only branch;
172
+ // `bg-background hover:bg-muted` now applies in every theme.
173
+ "absolute top-4 end-4 rounded-full bg-background hover:bg-muted",
150
174
  className,
151
175
  )}
176
+ aria-label={t("ai.conversation.download")}
152
177
  onClick={handleDownload}
153
178
  size="icon"
154
179
  type="button"
@@ -1,5 +1,5 @@
1
- import { describe, expect, it } from "vitest";
2
- import { render, screen, within } from "@testing-library/react";
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { render, screen, waitFor, within } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import { type DiffLine } from "@elabs-ai/components-ui";
5
5
  import { DiffView } from "./diff-view";
@@ -162,6 +162,56 @@ describe("DiffView — pager (absorbs CodexDiff)", () => {
162
162
  });
163
163
  });
164
164
 
165
+ describe("DiffView — old/new sides tokenize SEPARATELY (perf review §2 — a deleted comment opener must not swallow the added side)", () => {
166
+ afterEach(() => {
167
+ document.documentElement.removeAttribute("data-theme");
168
+ for (const token of [
169
+ "--code-foreground",
170
+ "--code-comment",
171
+ "--code-keyword",
172
+ "--code-number",
173
+ ]) {
174
+ document.documentElement.style.removeProperty(token);
175
+ }
176
+ });
177
+
178
+ it("does not let an unterminated block comment on the del side swallow the add side's tokens", async () => {
179
+ // Distinct resolved colors per scope (comment vs. keyword vs. number) so
180
+ // a merged/degenerate single-token line is distinguishable from a real,
181
+ // multi-token breakdown — see the module doc comment on `useDiffTokens`.
182
+ document.documentElement.setAttribute("data-theme", "light");
183
+ document.documentElement.style.setProperty("--code-foreground", "oklch(0.1 0 0)");
184
+ document.documentElement.style.setProperty("--code-comment", "oklch(0.5 0 0)");
185
+ document.documentElement.style.setProperty("--code-keyword", "oklch(0.3 0.2 260)");
186
+ document.documentElement.style.setProperty("--code-number", "oklch(0.4 0.2 140)");
187
+
188
+ const lines: DiffLine[] = [
189
+ { type: "del", oldNumber: 1, text: "/* unterminated" },
190
+ { type: "add", newNumber: 1, text: "const value = 1;" },
191
+ ];
192
+
193
+ const { container } = renderDiffView(<DiffView language="tsx" lines={lines} />);
194
+
195
+ // Combining old+new into ONE document (the bug) makes Shiki carry the
196
+ // del line's still-open `/*` across the line boundary, so the ENTIRE add
197
+ // line becomes one comment-colored token. Tokenized as its own document,
198
+ // `const value = 1;` is real code and splits into several distinctly
199
+ // colored spans.
200
+ await waitFor(() => {
201
+ const addLineText = container.querySelector(
202
+ '[data-diff-type="add"] [data-slot="diff-view-line-text"]',
203
+ );
204
+ const spans = addLineText?.querySelectorAll("span[style]") ?? [];
205
+ expect(spans.length).toBeGreaterThan(1);
206
+ });
207
+
208
+ const addLineText = container.querySelector(
209
+ '[data-diff-type="add"] [data-slot="diff-view-line-text"]',
210
+ );
211
+ expect(addLineText?.textContent).toContain("const value = 1;");
212
+ });
213
+ });
214
+
165
215
  describe("DiffView — contextLines collapsing", () => {
166
216
  const longContext: DiffLine[] = Array.from({ length: 10 }, (_, i) => ({
167
217
  type: "context" as const,
package/src/diff-view.tsx CHANGED
@@ -59,6 +59,7 @@ import { cva } from "class-variance-authority";
59
59
  import type { BundledLanguage, ThemedToken } from "shiki";
60
60
  import { highlightCode } from "./code-block";
61
61
  import { Shimmer } from "./shimmer";
62
+ import { getThemeScope } from "./_theme-scope-store";
62
63
 
63
64
  // ─── Public types ───────────────────────────────────────────────────────────
64
65
  //
@@ -139,41 +140,27 @@ const MARKER_TONE: Record<"add" | "del" | "context", string> = {
139
140
  context: "text-muted-foreground",
140
141
  };
141
142
 
142
- // ─── Theme scope (mirrors code-block.tsx's private helper — not a fork of it) ──
143
-
144
- const getThemeScope = (el: Element | null): Element | null =>
145
- el?.closest("[data-theme]") ??
146
- (typeof document !== "undefined" ? document.documentElement : null);
143
+ // `getThemeScope` (nearest `[data-theme]` ancestor, defaulting to `<html>`)
144
+ // comes from the shared `_theme-scope-store` module — the exact same helper
145
+ // `code-block.tsx` uses, not a second copy of it.
147
146
 
148
147
  // ─── Intra-line syntax highlighting ────────────────────────────────────────
149
148
 
150
149
  type TokenizedResult = NonNullable<ReturnType<typeof highlightCode>>;
151
150
 
152
151
  /**
153
- * Highlights only the lines that are real source (`add` / `del` / `context`)
154
- * as one combined document, so Shiki sees genuine surrounding context instead
155
- * of tokenizing each line in isolation, then maps the result back onto the
156
- * original line indices. `hunk` / `meta` lines are diff headers, not code, and
157
- * are excluded from the document entirely.
152
+ * Highlights ONE side's combined document (see `useDiffTokens` below) and
153
+ * returns the raw Shiki result once it resolves. `isStreaming` is forwarded
154
+ * to `highlightCode`'s `skipCache` — a streaming diff re-tokenizes a growing
155
+ * document on every line, and every intermediate value is never seen again,
156
+ * so permanently caching it is pure waste (perf review 1.4a/§3.2).
158
157
  */
159
- function useDiffTokens(
160
- lines: DiffLine[],
158
+ function useSideTokens(
159
+ combinedCode: string,
161
160
  language: BundledLanguage | undefined,
162
161
  scopeEl: Element | null,
163
- ): Map<number, ThemedToken[]> | null {
164
- const codeIndices = useMemo(
165
- () =>
166
- lines
167
- .map((line, index) => ({ line, index }))
168
- .filter(({ line }) => line.type !== "hunk" && line.type !== "meta")
169
- .map(({ index }) => index),
170
- [lines],
171
- );
172
- const combinedCode = useMemo(
173
- () => codeIndices.map((index) => lines[index]?.text ?? "").join("\n"),
174
- [codeIndices, lines],
175
- );
176
-
162
+ isStreaming: boolean,
163
+ ): TokenizedResult | null {
177
164
  const [result, setResult] = useState<TokenizedResult | null>(null);
178
165
 
179
166
  useEffect(() => {
@@ -189,22 +176,90 @@ function useDiffTokens(
189
176
  if (!cancelled) setResult(r);
190
177
  },
191
178
  scopeEl,
179
+ isStreaming,
192
180
  );
193
181
  if (cached) setResult(cached);
194
182
  return () => {
195
183
  cancelled = true;
196
184
  };
197
- }, [combinedCode, language, scopeEl]);
185
+ }, [combinedCode, language, scopeEl, isStreaming]);
186
+
187
+ return result;
188
+ }
189
+
190
+ /**
191
+ * Highlights the OLD file (`del` + `context` lines) and the NEW file (`add` +
192
+ * `context` lines) as two SEPARATE Shiki documents, then maps each back onto
193
+ * the original line indices. `hunk` / `meta` lines are diff headers, not
194
+ * code, and are excluded from both documents.
195
+ *
196
+ * The two sides used to be combined into ONE document (every real line,
197
+ * `del`/`add`/`context` alike, joined in original order). That let a
198
+ * still-open construct on one side leak its tokenizer STATE across the
199
+ * boundary onto the other — a deleted, unterminated `/*` opened a block
200
+ * comment that swallowed the immediately-following ADDED line as commented-out
201
+ * text, even though the two lines never coexist in either real version of the
202
+ * file. Tokenizing old/new as their own documents means Shiki only ever sees
203
+ * genuine same-version context around a construct.
204
+ */
205
+ function useDiffTokens(
206
+ lines: DiffLine[],
207
+ language: BundledLanguage | undefined,
208
+ scopeEl: Element | null,
209
+ isStreaming: boolean,
210
+ ): Map<number, ThemedToken[]> | null {
211
+ const oldIndices = useMemo(
212
+ () =>
213
+ lines
214
+ .map((line, index) => ({ line, index }))
215
+ .filter(({ line }) => line.type === "del" || line.type === "context")
216
+ .map(({ index }) => index),
217
+ [lines],
218
+ );
219
+ const newIndices = useMemo(
220
+ () =>
221
+ lines
222
+ .map((line, index) => ({ line, index }))
223
+ .filter(({ line }) => line.type === "add" || line.type === "context")
224
+ .map(({ index }) => index),
225
+ [lines],
226
+ );
227
+
228
+ const oldCode = useMemo(
229
+ () => oldIndices.map((index) => lines[index]?.text ?? "").join("\n"),
230
+ [oldIndices, lines],
231
+ );
232
+ const newCode = useMemo(
233
+ () => newIndices.map((index) => lines[index]?.text ?? "").join("\n"),
234
+ [newIndices, lines],
235
+ );
236
+
237
+ const oldResult = useSideTokens(oldCode, language, scopeEl, isStreaming);
238
+ const newResult = useSideTokens(newCode, language, scopeEl, isStreaming);
198
239
 
199
240
  return useMemo(() => {
200
- if (!language || !result) return null;
241
+ if (!language) return null;
201
242
  const map = new Map<number, ThemedToken[]>();
202
- codeIndices.forEach((originalIndex, i) => {
203
- const tokenLine = result.tokens[i];
204
- if (tokenLine) map.set(originalIndex, tokenLine);
205
- });
243
+ // `del` lines exist ONLY in the old-file document.
244
+ if (oldResult) {
245
+ oldIndices.forEach((originalIndex, i) => {
246
+ if (lines[originalIndex]?.type !== "del") return;
247
+ const tokenLine = oldResult.tokens[i];
248
+ if (tokenLine) map.set(originalIndex, tokenLine);
249
+ });
250
+ }
251
+ // `add` AND `context` lines come from the new-file document — a `context`
252
+ // line reads identically in both versions, and picking one consistently
253
+ // (rather than whichever side happened to resolve last) keeps its color
254
+ // stable across re-highlights.
255
+ if (newResult) {
256
+ newIndices.forEach((originalIndex, i) => {
257
+ const tokenLine = newResult.tokens[i];
258
+ if (tokenLine) map.set(originalIndex, tokenLine);
259
+ });
260
+ }
206
261
  return map;
207
- }, [codeIndices, result, language]);
262
+ }, [oldIndices, newIndices, oldResult, newResult, language, lines]);
208
263
  }
209
264
 
210
265
  /** Renders pre-highlighted tokens; colour only (a diff row doesn't need bold/italic/underline). */
@@ -573,7 +628,7 @@ export const DiffView = forwardRef<HTMLDivElement, DiffViewProps>(function DiffV
573
628
  () => (maxLines ? lines.slice(0, maxLines) : lines),
574
629
  [lines, maxLines],
575
630
  );
576
- const tokens = useDiffTokens(clippedLines, language, scopeEl);
631
+ const tokens = useDiffTokens(clippedLines, language, scopeEl, isStreaming);
577
632
  const { rows, expand } = useDiffRows(clippedLines, contextLines);
578
633
 
579
634
  const RowComponent = variant === "split" ? SplitRow : InlineRow;
@@ -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);
@@ -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}