@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.
- package/README.md +2 -2
- package/dist/index.d.ts +43 -16
- package/dist/index.js +1297 -926
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_streamdown-i18n.ts +73 -21
- package/src/_streamdown-safety.ts +2 -2
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.tsx +1 -1
- package/src/agent.tsx +18 -13
- package/src/artifact.tsx +2 -2
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/confirmation.tsx +1 -1
- package/src/context-panel.tsx +1 -1
- package/src/conversation.stories.tsx +23 -0
- package/src/conversation.test.tsx +53 -0
- package/src/conversation.tsx +32 -7
- package/src/diff-view.test.tsx +52 -2
- package/src/diff-view.tsx +89 -34
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.test.tsx +7 -3
- package/src/markdown-view.tsx +8 -1
- package/src/message-form.stories.tsx +36 -3
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.test.tsx +73 -3
- package/src/message.tsx +36 -12
- package/src/model-provider-logo.test.tsx +57 -3
- package/src/model-provider-logo.tsx +45 -11
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/prompt-input-slash.stories.tsx +1 -1
- package/src/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +17 -6
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +68 -37
- package/src/session-header.tsx +1 -1
- package/src/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +20 -36
- package/src/streamdown-i18n.test.tsx +23 -0
- package/src/test-results.tsx +47 -27
- package/src/token-usage.tsx +6 -6
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +59 -23
- package/src/transcription.tsx +1 -1
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- 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(
|
package/src/conversation.tsx
CHANGED
|
@@ -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-
|
|
72
|
-
{description && <p className="text-muted-foreground text-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
package/src/diff-view.test.tsx
CHANGED
|
@@ -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
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
|
160
|
-
|
|
158
|
+
function useSideTokens(
|
|
159
|
+
combinedCode: string,
|
|
161
160
|
language: BundledLanguage | undefined,
|
|
162
161
|
scopeEl: Element | null,
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
241
|
+
if (!language) return null;
|
|
201
242
|
const map = new Map<number, ThemedToken[]>();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
}, [
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
229
|
+
timeout,
|
|
238
230
|
copyFormat = "value",
|
|
239
231
|
children,
|
|
240
232
|
className,
|
|
233
|
+
"aria-label": ariaLabel,
|
|
241
234
|
...props
|
|
242
235
|
}: EnvironmentVariableCopyButtonProps) => {
|
|
243
|
-
const
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
onError?.(error as Error);
|
|
256
|
+
} else {
|
|
257
|
+
onError?.(new Error("Clipboard API not available"));
|
|
269
258
|
}
|
|
270
|
-
}, [getTextToCopy, onCopy, onError
|
|
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-
|
|
284
|
+
<Badge className={cn("text-meta", className)} variant="secondary" {...props}>
|
|
302
285
|
{children ?? "Required"}
|
|
303
286
|
</Badge>
|
|
304
287
|
);
|
package/src/file-tree.test.tsx
CHANGED
|
@@ -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-
|
|
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">
|
|
381
|
+
<p className="px-2 py-1 text-body text-muted-foreground">
|
|
382
|
+
{t("ai.fileTree.noAssetsProduced")}
|
|
383
|
+
</p>
|
|
374
384
|
) : (
|
|
375
385
|
assets.map((asset) => {
|
|
376
386
|
const Icon = assetIcon(asset);
|
package/src/inline-citation.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
293
|
-
{url && <p className="truncate break-all text-muted-foreground text-
|
|
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-
|
|
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-
|
|
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}
|