@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
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-
|
|
34
|
+
<span className="font-medium text-body">{name}</span>
|
|
35
35
|
{model && (
|
|
36
|
-
<Badge className="font-mono text-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
<div className="
|
|
116
|
-
<
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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`
|
package/src/asset-preview.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
{
|
|
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">
|
|
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
|
-
|
|
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
|
-
|
|
281
|
+
{t("ai.assetPreview.raw")}
|
|
211
282
|
</Button>
|
|
212
283
|
</ArtifactActions>
|
|
213
284
|
) : null}
|
package/src/attachments.tsx
CHANGED
|
@@ -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-
|
|
196
|
-
"hover:bg-accent hover:text-accent-foreground
|
|
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-
|
|
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(
|
|
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"}
|
package/src/code-block.test.tsx
CHANGED
|
@@ -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
|
+
});
|