@elabs-ai/components-ai 4.0.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 +62 -12
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- 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/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +65 -4
- 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.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- 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 +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -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 +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.test.tsx +0 -55
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
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
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
Collapsible,
|
|
6
|
+
CollapsibleContent,
|
|
7
|
+
CollapsibleTrigger,
|
|
8
|
+
useLocale,
|
|
9
|
+
} from "@elabs-ai/components-ui";
|
|
5
10
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
getStreamdownPluginsKey,
|
|
13
|
+
useStreamdownMermaidOptions,
|
|
14
|
+
useStreamdownPlugins,
|
|
15
|
+
useStreamdownTranslations,
|
|
16
|
+
} from "./_streamdown-i18n";
|
|
7
17
|
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
|
8
18
|
import type { ComponentProps, ReactNode } from "react";
|
|
9
19
|
import {
|
|
@@ -140,29 +150,29 @@ export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> &
|
|
|
140
150
|
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
|
141
151
|
};
|
|
142
152
|
|
|
143
|
-
const defaultGetThinkingMessage = (
|
|
153
|
+
const defaultGetThinkingMessage = (
|
|
154
|
+
isStreaming: boolean,
|
|
155
|
+
duration: number | undefined,
|
|
156
|
+
t: (key: string, vars?: Record<string, string | number>) => string,
|
|
157
|
+
) => {
|
|
144
158
|
if (isStreaming || duration === 0) {
|
|
145
|
-
return <Shimmer duration={1}>
|
|
159
|
+
return <Shimmer duration={1}>{t("ai.reasoning.thinking")}</Shimmer>;
|
|
146
160
|
}
|
|
147
161
|
if (duration === undefined) {
|
|
148
|
-
return <p>
|
|
162
|
+
return <p>{t("ai.reasoning.thoughtDefault")}</p>;
|
|
149
163
|
}
|
|
150
|
-
return <p>
|
|
164
|
+
return <p>{t("ai.reasoning.thoughtForDuration", { duration })}</p>;
|
|
151
165
|
};
|
|
152
166
|
|
|
153
167
|
export const ReasoningTrigger = memo(
|
|
154
|
-
({
|
|
155
|
-
className,
|
|
156
|
-
children,
|
|
157
|
-
getThinkingMessage = defaultGetThinkingMessage,
|
|
158
|
-
...props
|
|
159
|
-
}: ReasoningTriggerProps) => {
|
|
168
|
+
({ className, children, getThinkingMessage, ...props }: ReasoningTriggerProps) => {
|
|
160
169
|
const { isStreaming, isOpen, duration } = useReasoning();
|
|
170
|
+
const { t } = useLocale();
|
|
161
171
|
|
|
162
172
|
return (
|
|
163
173
|
<CollapsibleTrigger
|
|
164
174
|
className={cn(
|
|
165
|
-
"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",
|
|
166
176
|
className,
|
|
167
177
|
)}
|
|
168
178
|
{...props}
|
|
@@ -170,7 +180,9 @@ export const ReasoningTrigger = memo(
|
|
|
170
180
|
{children ?? (
|
|
171
181
|
<>
|
|
172
182
|
<BrainIcon className="size-4" />
|
|
173
|
-
{getThinkingMessage
|
|
183
|
+
{getThinkingMessage
|
|
184
|
+
? getThinkingMessage(isStreaming, duration)
|
|
185
|
+
: defaultGetThinkingMessage(isStreaming, duration, t)}
|
|
174
186
|
<ChevronDownIcon
|
|
175
187
|
className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
|
|
176
188
|
/>
|
|
@@ -202,19 +214,32 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
|
|
|
202
214
|
const translations = useStreamdownTranslations();
|
|
203
215
|
// Brand-token-derived `code` plugin, not the package's static github-*
|
|
204
216
|
// default (#315 follow-up) — re-derives when the active theme changes.
|
|
205
|
-
|
|
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 : "");
|
|
221
|
+
// The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
|
|
222
|
+
// render, including a missing `mermaid` optional peer (issue #33).
|
|
223
|
+
const mermaidOptions = useStreamdownMermaidOptions();
|
|
206
224
|
|
|
207
225
|
return (
|
|
208
226
|
<CollapsibleContent
|
|
209
227
|
className={cn(
|
|
210
|
-
"mt-4 text-
|
|
228
|
+
"mt-4 text-body",
|
|
211
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)]",
|
|
212
230
|
className,
|
|
213
231
|
)}
|
|
214
232
|
{...props}
|
|
215
233
|
>
|
|
216
234
|
{typeof children === "string" ? (
|
|
217
|
-
<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
|
+
>
|
|
218
243
|
{children}
|
|
219
244
|
</Streamdown>
|
|
220
245
|
) : (
|
package/src/sandbox.stories.tsx
CHANGED
|
@@ -13,7 +13,15 @@ import {
|
|
|
13
13
|
const meta = {
|
|
14
14
|
title: "AI/Sandbox",
|
|
15
15
|
component: Sandbox,
|
|
16
|
-
parameters: {
|
|
16
|
+
parameters: {
|
|
17
|
+
layout: "padded",
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"A collapsible, tabbed view of a code-execution tool part — code in, output out, read-only. The real Monaco editing surface is `Editor/CodeEditor`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `Sandbox` is the only place that knows whether the run body has arrived, so `SandboxContent` renders a layout-shaped skeleton while `loading`.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
17
25
|
} satisfies Meta<typeof Sandbox>;
|
|
18
26
|
export default meta;
|
|
19
27
|
type Story = StoryObj<typeof meta>;
|
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
|
+
});
|
package/src/schema-display.tsx
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { Badge } from "@elabs-ai/components-ui";
|
|
4
4
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
5
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
5
6
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
7
|
import { ChevronRightIcon } from "lucide-react";
|
|
7
|
-
import type { ComponentProps, HTMLAttributes } from "react";
|
|
8
|
-
import { createContext, useContext, useMemo } from "react";
|
|
8
|
+
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
|
9
|
+
import { createContext, Fragment, useContext, useMemo } from "react";
|
|
9
10
|
|
|
10
11
|
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
11
12
|
|
|
@@ -41,13 +42,36 @@ const SchemaDisplayContext = createContext<SchemaDisplayContextType>({
|
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
const methodStyles: Record<HttpMethod, string> = {
|
|
44
|
-
DELETE: "
|
|
45
|
-
GET: "
|
|
46
|
-
PATCH: "
|
|
47
|
-
POST: "
|
|
48
|
-
PUT: "
|
|
45
|
+
DELETE: "border-destructive/40 bg-destructive/10 text-destructive-text",
|
|
46
|
+
GET: "border-success/40 bg-success/10 text-success-text",
|
|
47
|
+
PATCH: "border-warning/40 bg-warning/10 text-warning-text",
|
|
48
|
+
POST: "border-info/40 bg-info/10 text-info-text",
|
|
49
|
+
PUT: "border-warning/40 bg-warning/10 text-warning-text",
|
|
49
50
|
};
|
|
50
51
|
|
|
52
|
+
/** Splits a `{param}` path into plain-text and parameter segments for safe rendering. */
|
|
53
|
+
function splitSchemaPath(path: string): Array<{ text: string; isParam: boolean }> {
|
|
54
|
+
const parts: Array<{ text: string; isParam: boolean }> = [];
|
|
55
|
+
const regex = /\{([^}]+)\}/g;
|
|
56
|
+
let lastIndex = 0;
|
|
57
|
+
let match: RegExpExecArray | null = regex.exec(path);
|
|
58
|
+
|
|
59
|
+
while (match !== null) {
|
|
60
|
+
if (match.index > lastIndex) {
|
|
61
|
+
parts.push({ isParam: false, text: path.slice(lastIndex, match.index) });
|
|
62
|
+
}
|
|
63
|
+
parts.push({ isParam: true, text: match[0] });
|
|
64
|
+
lastIndex = match.index + match[0].length;
|
|
65
|
+
match = regex.exec(path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (lastIndex < path.length) {
|
|
69
|
+
parts.push({ isParam: false, text: path.slice(lastIndex) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return parts;
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>;
|
|
52
76
|
|
|
53
77
|
export const SchemaDisplayHeader = ({
|
|
@@ -71,7 +95,7 @@ export const SchemaDisplayMethod = ({
|
|
|
71
95
|
|
|
72
96
|
return (
|
|
73
97
|
<Badge
|
|
74
|
-
className={cn("font-mono text-
|
|
98
|
+
className={cn("font-mono text-meta", methodStyles[method], className)}
|
|
75
99
|
variant="secondary"
|
|
76
100
|
{...props}
|
|
77
101
|
>
|
|
@@ -85,19 +109,26 @@ export type SchemaDisplayPathProps = HTMLAttributes<HTMLSpanElement>;
|
|
|
85
109
|
export const SchemaDisplayPath = ({ className, children, ...props }: SchemaDisplayPathProps) => {
|
|
86
110
|
const { path } = useContext(SchemaDisplayContext);
|
|
87
111
|
|
|
88
|
-
// Highlight path parameters
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
// Highlight path parameters as real elements — the path is untrusted
|
|
113
|
+
// (tool/model output) and must never reach dangerouslySetInnerHTML.
|
|
114
|
+
const pathNodes = useMemo<ReactNode>(
|
|
115
|
+
() =>
|
|
116
|
+
splitSchemaPath(path).map((part, index) =>
|
|
117
|
+
part.isParam ? (
|
|
118
|
+
<span className="text-info-text" key={`${part.text}-${index}`}>
|
|
119
|
+
{part.text}
|
|
120
|
+
</span>
|
|
121
|
+
) : (
|
|
122
|
+
<Fragment key={`${part.text}-${index}`}>{part.text}</Fragment>
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
[path],
|
|
92
126
|
);
|
|
93
127
|
|
|
94
128
|
return (
|
|
95
|
-
<span
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
|
|
99
|
-
{...props}
|
|
100
|
-
/>
|
|
129
|
+
<span className={cn("font-mono text-body", className)} {...props}>
|
|
130
|
+
{children ?? pathNodes}
|
|
131
|
+
</span>
|
|
101
132
|
);
|
|
102
133
|
};
|
|
103
134
|
|
|
@@ -111,7 +142,7 @@ export const SchemaDisplayDescription = ({
|
|
|
111
142
|
const { description } = useContext(SchemaDisplayContext);
|
|
112
143
|
|
|
113
144
|
return (
|
|
114
|
-
<p className={cn("border-b px-4 py-3 text-muted-foreground text-
|
|
145
|
+
<p className={cn("border-b px-4 py-3 text-muted-foreground text-body", className)} {...props}>
|
|
115
146
|
{children ?? description}
|
|
116
147
|
</p>
|
|
117
148
|
);
|
|
@@ -142,25 +173,25 @@ export const SchemaDisplayParameter = ({
|
|
|
142
173
|
}: SchemaDisplayParameterProps) => (
|
|
143
174
|
<div className={cn("px-4 py-3 ps-10", className)} {...props}>
|
|
144
175
|
<div className="flex items-center gap-2">
|
|
145
|
-
<span className="font-mono text-
|
|
146
|
-
<Badge className="text-
|
|
176
|
+
<span className="font-mono text-body">{name}</span>
|
|
177
|
+
<Badge className="text-meta" variant="outline">
|
|
147
178
|
{type}
|
|
148
179
|
</Badge>
|
|
149
180
|
{location && (
|
|
150
|
-
<Badge className="text-
|
|
181
|
+
<Badge className="text-meta" variant="secondary">
|
|
151
182
|
{location}
|
|
152
183
|
</Badge>
|
|
153
184
|
)}
|
|
154
185
|
{required && (
|
|
155
186
|
<Badge
|
|
156
|
-
className="bg-
|
|
187
|
+
className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
|
|
157
188
|
variant="secondary"
|
|
158
189
|
>
|
|
159
190
|
required
|
|
160
191
|
</Badge>
|
|
161
192
|
)}
|
|
162
193
|
</div>
|
|
163
|
-
{description && <p className="mt-1 text-muted-foreground text-
|
|
194
|
+
{description && <p className="mt-1 text-muted-foreground text-body">{description}</p>}
|
|
164
195
|
</div>
|
|
165
196
|
);
|
|
166
197
|
|
|
@@ -172,13 +203,14 @@ export const SchemaDisplayParameters = ({
|
|
|
172
203
|
...props
|
|
173
204
|
}: SchemaDisplayParametersProps) => {
|
|
174
205
|
const { parameters } = useContext(SchemaDisplayContext);
|
|
206
|
+
const { t } = useLocale();
|
|
175
207
|
|
|
176
208
|
return (
|
|
177
209
|
<Collapsible className={cn(className)} defaultOpen {...props}>
|
|
178
210
|
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
|
|
179
211
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
180
|
-
<span className="font-medium text-
|
|
181
|
-
<Badge className="ms-auto text-
|
|
212
|
+
<span className="font-medium text-body">{t("ai.schemaDisplay.parameters")}</span>
|
|
213
|
+
<Badge className="ms-auto text-meta" variant="secondary">
|
|
182
214
|
{parameters?.length}
|
|
183
215
|
</Badge>
|
|
184
216
|
</CollapsibleTrigger>
|
|
@@ -222,13 +254,13 @@ export const SchemaDisplayProperty = ({
|
|
|
222
254
|
style={{ paddingLeft }}
|
|
223
255
|
>
|
|
224
256
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
225
|
-
<span className="font-mono text-
|
|
226
|
-
<Badge className="text-
|
|
257
|
+
<span className="font-mono text-body">{name}</span>
|
|
258
|
+
<Badge className="text-meta" variant="outline">
|
|
227
259
|
{type}
|
|
228
260
|
</Badge>
|
|
229
261
|
{required && (
|
|
230
262
|
<Badge
|
|
231
|
-
className="bg-
|
|
263
|
+
className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
|
|
232
264
|
variant="secondary"
|
|
233
265
|
>
|
|
234
266
|
required
|
|
@@ -237,7 +269,7 @@ export const SchemaDisplayProperty = ({
|
|
|
237
269
|
</CollapsibleTrigger>
|
|
238
270
|
{description && (
|
|
239
271
|
<p
|
|
240
|
-
className="pb-2 text-muted-foreground text-
|
|
272
|
+
className="pb-2 text-muted-foreground text-body"
|
|
241
273
|
style={{ paddingLeft: paddingLeft + 24 }}
|
|
242
274
|
>
|
|
243
275
|
{description}
|
|
@@ -260,20 +292,20 @@ export const SchemaDisplayProperty = ({
|
|
|
260
292
|
<div className="flex items-center gap-2">
|
|
261
293
|
{/* Spacer for alignment */}
|
|
262
294
|
<span className="size-4" />
|
|
263
|
-
<span className="font-mono text-
|
|
264
|
-
<Badge className="text-
|
|
295
|
+
<span className="font-mono text-body">{name}</span>
|
|
296
|
+
<Badge className="text-meta" variant="outline">
|
|
265
297
|
{type}
|
|
266
298
|
</Badge>
|
|
267
299
|
{required && (
|
|
268
300
|
<Badge
|
|
269
|
-
className="bg-
|
|
301
|
+
className="border-destructive/40 bg-destructive/10 text-destructive-text text-meta"
|
|
270
302
|
variant="secondary"
|
|
271
303
|
>
|
|
272
304
|
required
|
|
273
305
|
</Badge>
|
|
274
306
|
)}
|
|
275
307
|
</div>
|
|
276
|
-
{description && <p className="mt-1 ps-6 text-muted-foreground text-
|
|
308
|
+
{description && <p className="mt-1 ps-6 text-muted-foreground text-body">{description}</p>}
|
|
277
309
|
</div>
|
|
278
310
|
);
|
|
279
311
|
};
|
|
@@ -286,12 +318,13 @@ export const SchemaDisplayRequest = ({
|
|
|
286
318
|
...props
|
|
287
319
|
}: SchemaDisplayRequestProps) => {
|
|
288
320
|
const { requestBody } = useContext(SchemaDisplayContext);
|
|
321
|
+
const { t } = useLocale();
|
|
289
322
|
|
|
290
323
|
return (
|
|
291
324
|
<Collapsible className={cn(className)} defaultOpen {...props}>
|
|
292
325
|
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
|
|
293
326
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
294
|
-
<span className="font-medium text-
|
|
327
|
+
<span className="font-medium text-body">{t("ai.schemaDisplay.requestBody")}</span>
|
|
295
328
|
</CollapsibleTrigger>
|
|
296
329
|
<CollapsibleContent>
|
|
297
330
|
<div className="border-t">
|
|
@@ -313,12 +346,13 @@ export const SchemaDisplayResponse = ({
|
|
|
313
346
|
...props
|
|
314
347
|
}: SchemaDisplayResponseProps) => {
|
|
315
348
|
const { responseBody } = useContext(SchemaDisplayContext);
|
|
349
|
+
const { t } = useLocale();
|
|
316
350
|
|
|
317
351
|
return (
|
|
318
352
|
<Collapsible className={cn(className)} defaultOpen {...props}>
|
|
319
353
|
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/50">
|
|
320
354
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
321
|
-
<span className="font-medium text-
|
|
355
|
+
<span className="font-medium text-body">{t("ai.schemaDisplay.response")}</span>
|
|
322
356
|
</CollapsibleTrigger>
|
|
323
357
|
<CollapsibleContent>
|
|
324
358
|
<div className="border-t">
|
|
@@ -404,7 +438,7 @@ export const SchemaDisplayExample = ({
|
|
|
404
438
|
...props
|
|
405
439
|
}: SchemaDisplayExampleProps) => (
|
|
406
440
|
<pre
|
|
407
|
-
className={cn("mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-
|
|
441
|
+
className={cn("mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-body", className)}
|
|
408
442
|
{...props}
|
|
409
443
|
>
|
|
410
444
|
{children}
|
|
@@ -8,7 +8,15 @@ const meta = {
|
|
|
8
8
|
title: "AI/SelectionToolbar",
|
|
9
9
|
component: SelectionToolbar,
|
|
10
10
|
tags: ["autodocs"],
|
|
11
|
-
parameters: {
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: "padded",
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component:
|
|
16
|
+
"The small toolbar that floats over a TEXT SELECTION in a transcript, offering Quote. A dense secondary control row is `Layout/Toolbar`, the row above a list or table is `Layout/ViewToolbar`, and formatting chrome for a markdown source pane is `Editor/MarkdownToolbar` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Positioned with a Popover virtual anchor at the selection rect; presentational only.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
12
20
|
} satisfies Meta<typeof SelectionToolbar>;
|
|
13
21
|
export default meta;
|
|
14
22
|
type Story = StoryObj<typeof meta>;
|