@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/package-info.tsx
CHANGED
|
@@ -35,17 +35,17 @@ export const PackageInfoName = ({ className, children, ...props }: PackageInfoNa
|
|
|
35
35
|
return (
|
|
36
36
|
<div className={cn("flex items-center gap-2", className)} {...props}>
|
|
37
37
|
<PackageIcon className="size-4 text-muted-foreground" />
|
|
38
|
-
<span className="font-medium font-mono text-
|
|
38
|
+
<span className="font-medium font-mono text-body">{children ?? name}</span>
|
|
39
39
|
</div>
|
|
40
40
|
);
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
const changeTypeStyles: Record<ChangeType, string> = {
|
|
44
|
-
added: "bg-
|
|
45
|
-
major: "bg-
|
|
46
|
-
minor: "bg-
|
|
47
|
-
patch: "bg-
|
|
48
|
-
removed: "bg-
|
|
44
|
+
added: "bg-info/10 text-info-text",
|
|
45
|
+
major: "bg-destructive/10 text-destructive-text",
|
|
46
|
+
minor: "bg-warning/10 text-warning-text",
|
|
47
|
+
patch: "bg-success/10 text-success-text",
|
|
48
|
+
removed: "bg-muted text-muted-foreground",
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
const changeTypeIcons: Record<ChangeType, React.ReactNode> = {
|
|
@@ -71,7 +71,7 @@ export const PackageInfoChangeType = ({
|
|
|
71
71
|
|
|
72
72
|
return (
|
|
73
73
|
<Badge
|
|
74
|
-
className={cn("gap-1 text-
|
|
74
|
+
className={cn("gap-1 text-meta capitalize", changeTypeStyles[changeType], className)}
|
|
75
75
|
variant="secondary"
|
|
76
76
|
{...props}
|
|
77
77
|
>
|
|
@@ -93,7 +93,7 @@ export const PackageInfoVersion = ({ className, children, ...props }: PackageInf
|
|
|
93
93
|
return (
|
|
94
94
|
<div
|
|
95
95
|
className={cn(
|
|
96
|
-
"mt-2 flex items-center gap-2 font-mono text-muted-foreground text-
|
|
96
|
+
"mt-2 flex items-center gap-2 font-mono text-muted-foreground text-body",
|
|
97
97
|
className,
|
|
98
98
|
)}
|
|
99
99
|
{...props}
|
|
@@ -154,7 +154,7 @@ export const PackageInfoDescription = ({
|
|
|
154
154
|
children,
|
|
155
155
|
...props
|
|
156
156
|
}: PackageInfoDescriptionProps) => (
|
|
157
|
-
<p className={cn("mt-2 text-muted-foreground text-
|
|
157
|
+
<p className={cn("mt-2 text-muted-foreground text-body", className)} {...props}>
|
|
158
158
|
{children}
|
|
159
159
|
</p>
|
|
160
160
|
);
|
|
@@ -175,7 +175,7 @@ export const PackageInfoDependencies = ({
|
|
|
175
175
|
...props
|
|
176
176
|
}: PackageInfoDependenciesProps) => (
|
|
177
177
|
<div className={cn("space-y-2", className)} {...props}>
|
|
178
|
-
<span className="font-medium text-muted-foreground text-
|
|
178
|
+
<span className="font-medium text-muted-foreground text-meta uppercase tracking-wide">
|
|
179
179
|
Dependencies
|
|
180
180
|
</span>
|
|
181
181
|
<div className="space-y-1">{children}</div>
|
|
@@ -194,11 +194,11 @@ export const PackageInfoDependency = ({
|
|
|
194
194
|
children,
|
|
195
195
|
...props
|
|
196
196
|
}: PackageInfoDependencyProps) => (
|
|
197
|
-
<div className={cn("flex items-center justify-between text-
|
|
197
|
+
<div className={cn("flex items-center justify-between text-body", className)} {...props}>
|
|
198
198
|
{children ?? (
|
|
199
199
|
<>
|
|
200
200
|
<span className="font-mono text-muted-foreground">{name}</span>
|
|
201
|
-
{version && <span className="font-mono text-
|
|
201
|
+
{version && <span className="font-mono text-meta">{version}</span>}
|
|
202
202
|
</>
|
|
203
203
|
)}
|
|
204
204
|
</div>
|
|
@@ -28,7 +28,7 @@ function Field({ commands = COMMANDS }: { commands?: SlashCommand[] }) {
|
|
|
28
28
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
29
29
|
const [text, setText] = useState("");
|
|
30
30
|
return (
|
|
31
|
-
<div className="mx-auto w-[28rem]">
|
|
31
|
+
<div className="mx-auto w-full max-w-[28rem]">
|
|
32
32
|
<PromptInput onSubmit={() => undefined}>
|
|
33
33
|
<PromptInputSlash
|
|
34
34
|
commands={commands}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
3
3
|
import userEvent from "@testing-library/user-event";
|
|
4
4
|
|
|
5
5
|
import {
|
|
@@ -107,6 +107,72 @@ describe("PromptInput — empty submissions are blocked", () => {
|
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
describe("PromptInput — failed submit restores the composer (#1.9)", () => {
|
|
111
|
+
it("restores the typed text after a rejected async onSubmit", async () => {
|
|
112
|
+
const onSubmit = vi.fn(() => Promise.reject(new Error("network error")));
|
|
113
|
+
render(<Harness onSubmit={onSubmit} />);
|
|
114
|
+
|
|
115
|
+
const textarea = screen.getByPlaceholderText("Ask…") as HTMLTextAreaElement;
|
|
116
|
+
await userEvent.type(textarea, "hello{Enter}");
|
|
117
|
+
|
|
118
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
119
|
+
// form.reset() wipes the uncontrolled textarea before onSubmit even runs —
|
|
120
|
+
// a rejected promise must restore exactly what the user had typed.
|
|
121
|
+
await waitFor(() => expect(textarea.value).toBe("hello"));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("restores the typed text after a synchronous onSubmit throw", async () => {
|
|
125
|
+
const onSubmit = vi.fn(() => {
|
|
126
|
+
throw new Error("boom");
|
|
127
|
+
});
|
|
128
|
+
render(<Harness onSubmit={onSubmit} />);
|
|
129
|
+
|
|
130
|
+
const textarea = screen.getByPlaceholderText("Ask…") as HTMLTextAreaElement;
|
|
131
|
+
await userEvent.type(textarea, "hello{Enter}");
|
|
132
|
+
|
|
133
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
134
|
+
await waitFor(() => expect(textarea.value).toBe("hello"));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("PromptInput — in-flight guard blocks a duplicate submit (#1.9)", () => {
|
|
139
|
+
it("does not call onSubmit twice for a double Enter on an attachment-only message before the first submit settles", async () => {
|
|
140
|
+
// Attachments are only cleared once onSubmit resolves, so the submit
|
|
141
|
+
// control stays enabled (canSubmit = files.length > 0) while the first
|
|
142
|
+
// call is still pending — an in-flight ref, not the affordance, must
|
|
143
|
+
// stop a second Enter from re-sending the same attachment.
|
|
144
|
+
let resolveSubmit: (() => void) | undefined;
|
|
145
|
+
const onSubmit = vi.fn(
|
|
146
|
+
() =>
|
|
147
|
+
new Promise<void>((resolve) => {
|
|
148
|
+
resolveSubmit = resolve;
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
render(<Harness onSubmit={onSubmit} />);
|
|
152
|
+
|
|
153
|
+
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
|
|
154
|
+
fireEvent.change(screen.getByLabelText("Upload files"), { target: { files: [file] } });
|
|
155
|
+
|
|
156
|
+
const textarea = screen.getByPlaceholderText("Ask…");
|
|
157
|
+
// Both Enters fire back-to-back, with NO await between them — the guard
|
|
158
|
+
// that matters here is the synchronous `isSubmittingRef` set before
|
|
159
|
+
// `handleSubmit`'s first `await` (the blob→data-URL conversion), not
|
|
160
|
+
// React re-rendering the (still-canSubmit) affordance in between.
|
|
161
|
+
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
162
|
+
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
163
|
+
|
|
164
|
+
// `handleSubmit` is async — `onSubmit` itself isn't called until after the
|
|
165
|
+
// attachment's blob→data-URL conversion microtask, so wait for it rather
|
|
166
|
+
// than asserting synchronously.
|
|
167
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
168
|
+
// Let the pending `onSubmit` promise's resolution (and the resulting
|
|
169
|
+
// `clear()`/state update) settle inside `act` before the test ends.
|
|
170
|
+
await act(async () => {
|
|
171
|
+
resolveSubmit?.();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
110
176
|
describe("PromptInputSubmit — disabled affordance mirrors the guard", () => {
|
|
111
177
|
it("marks itself aria-disabled at rest and clears it after typing", async () => {
|
|
112
178
|
render(<Harness />);
|
package/src/prompt-input.tsx
CHANGED
|
@@ -556,6 +556,10 @@ export const PromptInput = ({
|
|
|
556
556
|
// Refs
|
|
557
557
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
558
558
|
const formRef = useRef<HTMLFormElement | null>(null);
|
|
559
|
+
// In-flight guard for handleSubmit (#1.9) — a ref, not state, so a second
|
|
560
|
+
// Enter/click during the same synchronous event-handling pass sees the
|
|
561
|
+
// updated value immediately (no waiting for a re-render).
|
|
562
|
+
const isSubmittingRef = useRef(false);
|
|
559
563
|
|
|
560
564
|
// ----- Local attachments (only used when no provider)
|
|
561
565
|
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
|
@@ -887,6 +891,14 @@ export const PromptInput = ({
|
|
|
887
891
|
async (event) => {
|
|
888
892
|
event.preventDefault();
|
|
889
893
|
|
|
894
|
+
// In-flight guard (#1.9): a second Enter/click fired while the first
|
|
895
|
+
// submission is still being processed (mid blob conversion, mid async
|
|
896
|
+
// `onSubmit`) must not fire a second one — otherwise a double-Enter
|
|
897
|
+
// submits the same attachments twice.
|
|
898
|
+
if (isSubmittingRef.current) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
890
902
|
const form = event.currentTarget;
|
|
891
903
|
const text = usingProvider
|
|
892
904
|
? controller.textInput.value
|
|
@@ -904,6 +916,23 @@ export const PromptInput = ({
|
|
|
904
916
|
return;
|
|
905
917
|
}
|
|
906
918
|
|
|
919
|
+
isSubmittingRef.current = true;
|
|
920
|
+
|
|
921
|
+
// The uncontrolled textarea's DOM value is about to be wiped by
|
|
922
|
+
// `form.reset()` below, before `onSubmit` has even run. Snapshot it so a
|
|
923
|
+
// sync throw / rejected promise can restore exactly what the user typed
|
|
924
|
+
// instead of losing it.
|
|
925
|
+
const textarea = usingProvider
|
|
926
|
+
? null
|
|
927
|
+
: form.querySelector<HTMLTextAreaElement>('textarea[name="message"]');
|
|
928
|
+
|
|
929
|
+
const restoreTextOnFailure = () => {
|
|
930
|
+
if (textarea) {
|
|
931
|
+
textarea.value = text;
|
|
932
|
+
setHasLocalText(text.trim().length > 0);
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
|
|
907
936
|
// Reset form immediately after capturing text to avoid race condition
|
|
908
937
|
// where user input during async blob conversion would be lost
|
|
909
938
|
if (!usingProvider) {
|
|
@@ -939,7 +968,9 @@ export const PromptInput = ({
|
|
|
939
968
|
controller.textInput.clear();
|
|
940
969
|
}
|
|
941
970
|
} catch {
|
|
942
|
-
// Don't clear on error - user may want to retry
|
|
971
|
+
// Don't clear on error - user may want to retry; restore the text
|
|
972
|
+
// the early reset above wiped.
|
|
973
|
+
restoreTextOnFailure();
|
|
943
974
|
}
|
|
944
975
|
} else {
|
|
945
976
|
// Sync function completed without throwing, clear inputs
|
|
@@ -949,7 +980,11 @@ export const PromptInput = ({
|
|
|
949
980
|
}
|
|
950
981
|
}
|
|
951
982
|
} catch {
|
|
952
|
-
// Don't clear on error - user may want to retry
|
|
983
|
+
// Don't clear on error - user may want to retry; restore the text the
|
|
984
|
+
// early reset above wiped.
|
|
985
|
+
restoreTextOnFailure();
|
|
986
|
+
} finally {
|
|
987
|
+
isSubmittingRef.current = false;
|
|
953
988
|
}
|
|
954
989
|
},
|
|
955
990
|
[usingProvider, controller, files, onSubmit, clear],
|
|
@@ -1583,7 +1618,10 @@ export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
|
|
|
1583
1618
|
export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (
|
|
1584
1619
|
// Content provided via children in props
|
|
1585
1620
|
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
|
|
1586
|
-
<h3
|
|
1621
|
+
<h3
|
|
1622
|
+
className={cn("mb-2 px-3 font-medium text-muted-foreground text-meta", className)}
|
|
1623
|
+
{...props}
|
|
1624
|
+
/>
|
|
1587
1625
|
);
|
|
1588
1626
|
|
|
1589
1627
|
export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
|
|
@@ -1596,7 +1634,7 @@ export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
|
|
|
1596
1634
|
|
|
1597
1635
|
export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
|
|
1598
1636
|
<div
|
|
1599
|
-
className={cn("flex items-center gap-2 px-3 py-2 text-
|
|
1637
|
+
className={cn("flex items-center gap-2 px-3 py-2 text-meta hover:bg-accent", className)}
|
|
1600
1638
|
{...props}
|
|
1601
1639
|
/>
|
|
1602
1640
|
);
|
package/src/queue.tsx
CHANGED
|
@@ -32,7 +32,7 @@ export type QueueItemProps = ComponentProps<"li">;
|
|
|
32
32
|
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
|
33
33
|
<li
|
|
34
34
|
className={cn(
|
|
35
|
-
"group flex flex-col gap-1 rounded-md px-3 py-1 text-
|
|
35
|
+
"group flex flex-col gap-1 rounded-md px-3 py-1 text-body transition-colors hover:bg-muted",
|
|
36
36
|
className,
|
|
37
37
|
)}
|
|
38
38
|
{...props}
|
|
@@ -90,7 +90,7 @@ export const QueueItemDescription = ({
|
|
|
90
90
|
}: QueueItemDescriptionProps) => (
|
|
91
91
|
<div
|
|
92
92
|
className={cn(
|
|
93
|
-
"ms-6 text-
|
|
93
|
+
"ms-6 text-meta",
|
|
94
94
|
completed ? "text-muted-foreground/40 line-through" : "text-muted-foreground",
|
|
95
95
|
className,
|
|
96
96
|
)}
|
|
@@ -141,7 +141,7 @@ export type QueueItemFileProps = ComponentProps<"span">;
|
|
|
141
141
|
|
|
142
142
|
export const QueueItemFile = ({ children, className, ...props }: QueueItemFileProps) => (
|
|
143
143
|
<span
|
|
144
|
-
className={cn("flex items-center gap-1 rounded border bg-muted px-2 py-1 text-
|
|
144
|
+
className={cn("flex items-center gap-1 rounded border bg-muted px-2 py-1 text-meta", className)}
|
|
145
145
|
{...props}
|
|
146
146
|
>
|
|
147
147
|
<PaperclipIcon size={12} />
|
|
@@ -177,7 +177,7 @@ export const QueueSectionTrigger = ({
|
|
|
177
177
|
<CollapsibleTrigger asChild>
|
|
178
178
|
<button
|
|
179
179
|
className={cn(
|
|
180
|
-
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-start font-medium text-muted-foreground text-
|
|
180
|
+
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-start font-medium text-muted-foreground text-body transition-colors hover:bg-muted",
|
|
181
181
|
className,
|
|
182
182
|
)}
|
|
183
183
|
type="button"
|
package/src/reasoning.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "@elabs-ai/components-ui";
|
|
10
10
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
11
11
|
import {
|
|
12
|
+
getStreamdownPluginsKey,
|
|
12
13
|
useStreamdownMermaidOptions,
|
|
13
14
|
useStreamdownPlugins,
|
|
14
15
|
useStreamdownTranslations,
|
|
@@ -155,12 +156,12 @@ const defaultGetThinkingMessage = (
|
|
|
155
156
|
t: (key: string, vars?: Record<string, string | number>) => string,
|
|
156
157
|
) => {
|
|
157
158
|
if (isStreaming || duration === 0) {
|
|
158
|
-
return <Shimmer duration={1}>
|
|
159
|
+
return <Shimmer duration={1}>{t("ai.reasoning.thinking")}</Shimmer>;
|
|
159
160
|
}
|
|
160
161
|
if (duration === undefined) {
|
|
161
162
|
return <p>{t("ai.reasoning.thoughtDefault")}</p>;
|
|
162
163
|
}
|
|
163
|
-
return <p>
|
|
164
|
+
return <p>{t("ai.reasoning.thoughtForDuration", { duration })}</p>;
|
|
164
165
|
};
|
|
165
166
|
|
|
166
167
|
export const ReasoningTrigger = memo(
|
|
@@ -171,7 +172,7 @@ export const ReasoningTrigger = memo(
|
|
|
171
172
|
return (
|
|
172
173
|
<CollapsibleTrigger
|
|
173
174
|
className={cn(
|
|
174
|
-
"flex w-full items-center gap-2 text-muted-foreground text-
|
|
175
|
+
"flex w-full items-center gap-2 text-muted-foreground text-body transition-colors hover:text-foreground",
|
|
175
176
|
className,
|
|
176
177
|
)}
|
|
177
178
|
{...props}
|
|
@@ -213,7 +214,10 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
|
|
|
213
214
|
const translations = useStreamdownTranslations();
|
|
214
215
|
// Brand-token-derived `code` plugin, not the package's static github-*
|
|
215
216
|
// default (#315 follow-up) — re-derives when the active theme changes.
|
|
216
|
-
|
|
217
|
+
// `math`/`cjk` are lazy-loaded off the raw text (#perf-5, see
|
|
218
|
+
// `_streamdown-i18n.ts`) — pass `children` so they load only when this
|
|
219
|
+
// reasoning block actually needs them.
|
|
220
|
+
const plugins = useStreamdownPlugins(typeof children === "string" ? children : "");
|
|
217
221
|
// The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
|
|
218
222
|
// render, including a missing `mermaid` optional peer (issue #33).
|
|
219
223
|
const mermaidOptions = useStreamdownMermaidOptions();
|
|
@@ -221,14 +225,21 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
|
|
|
221
225
|
return (
|
|
222
226
|
<CollapsibleContent
|
|
223
227
|
className={cn(
|
|
224
|
-
"mt-4 text-
|
|
228
|
+
"mt-4 text-body",
|
|
225
229
|
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)]",
|
|
226
230
|
className,
|
|
227
231
|
)}
|
|
228
232
|
{...props}
|
|
229
233
|
>
|
|
230
234
|
{typeof children === "string" ? (
|
|
231
|
-
<Streamdown
|
|
235
|
+
<Streamdown
|
|
236
|
+
// Forces a remount the first time the lazy math/cjk slot resolves —
|
|
237
|
+
// see `getStreamdownPluginsKey`'s doc in `_streamdown-i18n.ts` for why.
|
|
238
|
+
key={getStreamdownPluginsKey(plugins)}
|
|
239
|
+
plugins={plugins}
|
|
240
|
+
translations={translations}
|
|
241
|
+
mermaid={mermaidOptions}
|
|
242
|
+
>
|
|
232
243
|
{children}
|
|
233
244
|
</Streamdown>
|
|
234
245
|
) : (
|
package/src/sandbox.tsx
CHANGED
|
@@ -70,7 +70,7 @@ export const SandboxHeader = ({ className, title, state, ...props }: SandboxHead
|
|
|
70
70
|
>
|
|
71
71
|
<div className="flex items-center gap-2">
|
|
72
72
|
<Code className="size-4 text-muted-foreground" />
|
|
73
|
-
<span className="font-medium text-
|
|
73
|
+
<span className="font-medium text-body">{title}</span>
|
|
74
74
|
{getStatusBadge(state)}
|
|
75
75
|
</div>
|
|
76
76
|
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
|
@@ -135,7 +135,7 @@ export type SandboxTabsTriggerProps = ComponentProps<typeof TabsTrigger>;
|
|
|
135
135
|
export const SandboxTabsTrigger = ({ className, ...props }: SandboxTabsTriggerProps) => (
|
|
136
136
|
<TabsTrigger
|
|
137
137
|
className={cn(
|
|
138
|
-
"rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-
|
|
138
|
+
"rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-body transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
|
|
139
139
|
className,
|
|
140
140
|
)}
|
|
141
141
|
{...props}
|
|
@@ -145,5 +145,5 @@ export const SandboxTabsTrigger = ({ className, ...props }: SandboxTabsTriggerPr
|
|
|
145
145
|
export type SandboxTabContentProps = ComponentProps<typeof TabsContent>;
|
|
146
146
|
|
|
147
147
|
export const SandboxTabContent = ({ className, ...props }: SandboxTabContentProps) => (
|
|
148
|
-
<TabsContent className={cn("mt-0 text-
|
|
148
|
+
<TabsContent className={cn("mt-0 text-body", className)} {...props} />
|
|
149
149
|
);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema-display.test.tsx — XSS lock for `SchemaDisplayPath` (#1.1 review finding).
|
|
3
|
+
*
|
|
4
|
+
* `path` is untrusted tool/model output. It must never be interpolated into
|
|
5
|
+
* `dangerouslySetInnerHTML` — the `{param}` highlight has to be rendered as
|
|
6
|
+
* real `<span>` children, not raw HTML.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { render, screen } from "@testing-library/react";
|
|
10
|
+
import { SchemaDisplay, SchemaDisplayHeader, SchemaDisplayPath } from "./schema-display";
|
|
11
|
+
|
|
12
|
+
describe("SchemaDisplayPath — XSS", () => {
|
|
13
|
+
it("never injects HTML from the path, even when it contains markup", () => {
|
|
14
|
+
const maliciousPath = '/users/{id}"><img src=x onerror=alert(1)>';
|
|
15
|
+
|
|
16
|
+
const { container } = render(
|
|
17
|
+
<SchemaDisplay method="GET" path={maliciousPath}>
|
|
18
|
+
<SchemaDisplayHeader>
|
|
19
|
+
<SchemaDisplayPath />
|
|
20
|
+
</SchemaDisplayHeader>
|
|
21
|
+
</SchemaDisplay>,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// The dangerous markup must render as literal text, never as a live element.
|
|
25
|
+
expect(container.querySelector("img")).not.toBeInTheDocument();
|
|
26
|
+
expect(container.textContent).toContain(maliciousPath);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("highlights {param} segments as separate span children", () => {
|
|
30
|
+
render(
|
|
31
|
+
<SchemaDisplay method="GET" path="/users/{id}/posts/{postId}">
|
|
32
|
+
<SchemaDisplayHeader>
|
|
33
|
+
<SchemaDisplayPath />
|
|
34
|
+
</SchemaDisplayHeader>
|
|
35
|
+
</SchemaDisplay>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const idSpan = screen.getByText("{id}");
|
|
39
|
+
const postIdSpan = screen.getByText("{postId}");
|
|
40
|
+
expect(idSpan.tagName).toBe("SPAN");
|
|
41
|
+
expect(postIdSpan.tagName).toBe("SPAN");
|
|
42
|
+
expect(idSpan).toHaveClass("text-info-text");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("renders an explicit path with no param braces verbatim", () => {
|
|
46
|
+
render(
|
|
47
|
+
<SchemaDisplay method="POST" path="/users">
|
|
48
|
+
<SchemaDisplayHeader>
|
|
49
|
+
<SchemaDisplayPath />
|
|
50
|
+
</SchemaDisplayHeader>
|
|
51
|
+
</SchemaDisplay>,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(screen.getByText("/users")).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
});
|