@elabs-ai/components-ai 4.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- package/src/web-preview.tsx +283 -0
package/src/queue.tsx
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@elabs-ai/components-ui";
|
|
4
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
5
|
+
import { ScrollArea } from "@elabs-ai/components-ui";
|
|
6
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
7
|
+
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
|
8
|
+
import type { ComponentProps } from "react";
|
|
9
|
+
|
|
10
|
+
export interface QueueMessagePart {
|
|
11
|
+
type: string;
|
|
12
|
+
text?: string;
|
|
13
|
+
url?: string;
|
|
14
|
+
filename?: string;
|
|
15
|
+
mediaType?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface QueueMessage {
|
|
19
|
+
id: string;
|
|
20
|
+
parts: QueueMessagePart[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QueueTodo {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
status?: "pending" | "completed";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type QueueItemProps = ComponentProps<"li">;
|
|
31
|
+
|
|
32
|
+
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
|
33
|
+
<li
|
|
34
|
+
className={cn(
|
|
35
|
+
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
|
|
43
|
+
completed?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const QueueItemIndicator = ({
|
|
47
|
+
completed = false,
|
|
48
|
+
className,
|
|
49
|
+
...props
|
|
50
|
+
}: QueueItemIndicatorProps) => (
|
|
51
|
+
<span
|
|
52
|
+
className={cn(
|
|
53
|
+
"mt-0.5 inline-block size-2.5 rounded-full border",
|
|
54
|
+
completed
|
|
55
|
+
? "border-muted-foreground/20 bg-muted-foreground/10"
|
|
56
|
+
: "border-muted-foreground/50",
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export type QueueItemContentProps = ComponentProps<"span"> & {
|
|
64
|
+
completed?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const QueueItemContent = ({
|
|
68
|
+
completed = false,
|
|
69
|
+
className,
|
|
70
|
+
...props
|
|
71
|
+
}: QueueItemContentProps) => (
|
|
72
|
+
<span
|
|
73
|
+
className={cn(
|
|
74
|
+
"line-clamp-1 grow break-words",
|
|
75
|
+
completed ? "text-muted-foreground/50 line-through" : "text-muted-foreground",
|
|
76
|
+
className,
|
|
77
|
+
)}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
|
|
83
|
+
completed?: boolean;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const QueueItemDescription = ({
|
|
87
|
+
completed = false,
|
|
88
|
+
className,
|
|
89
|
+
...props
|
|
90
|
+
}: QueueItemDescriptionProps) => (
|
|
91
|
+
<div
|
|
92
|
+
className={cn(
|
|
93
|
+
"ms-6 text-xs",
|
|
94
|
+
completed ? "text-muted-foreground/40 line-through" : "text-muted-foreground",
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
{...props}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
export type QueueItemActionsProps = ComponentProps<"div">;
|
|
102
|
+
|
|
103
|
+
export const QueueItemActions = ({ className, ...props }: QueueItemActionsProps) => (
|
|
104
|
+
<div className={cn("flex gap-1", className)} {...props} />
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
export type QueueItemActionProps = Omit<ComponentProps<typeof Button>, "variant" | "size">;
|
|
108
|
+
|
|
109
|
+
export const QueueItemAction = ({ className, ...props }: QueueItemActionProps) => (
|
|
110
|
+
<Button
|
|
111
|
+
className={cn(
|
|
112
|
+
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
|
|
113
|
+
className,
|
|
114
|
+
)}
|
|
115
|
+
size="icon"
|
|
116
|
+
type="button"
|
|
117
|
+
variant="ghost"
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
export type QueueItemAttachmentProps = ComponentProps<"div">;
|
|
123
|
+
|
|
124
|
+
export const QueueItemAttachment = ({ className, ...props }: QueueItemAttachmentProps) => (
|
|
125
|
+
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
export type QueueItemImageProps = ComponentProps<"img">;
|
|
129
|
+
|
|
130
|
+
export const QueueItemImage = ({ className, ...props }: QueueItemImageProps) => (
|
|
131
|
+
<img
|
|
132
|
+
alt=""
|
|
133
|
+
className={cn("h-8 w-8 rounded border object-cover", className)}
|
|
134
|
+
height={32}
|
|
135
|
+
width={32}
|
|
136
|
+
{...props}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
export type QueueItemFileProps = ComponentProps<"span">;
|
|
141
|
+
|
|
142
|
+
export const QueueItemFile = ({ children, className, ...props }: QueueItemFileProps) => (
|
|
143
|
+
<span
|
|
144
|
+
className={cn("flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs", className)}
|
|
145
|
+
{...props}
|
|
146
|
+
>
|
|
147
|
+
<PaperclipIcon size={12} />
|
|
148
|
+
<span className="max-w-[100px] truncate">{children}</span>
|
|
149
|
+
</span>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
export type QueueListProps = ComponentProps<typeof ScrollArea>;
|
|
153
|
+
|
|
154
|
+
export const QueueList = ({ children, className, ...props }: QueueListProps) => (
|
|
155
|
+
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
|
|
156
|
+
<div className="max-h-40 pe-4">
|
|
157
|
+
<ul>{children}</ul>
|
|
158
|
+
</div>
|
|
159
|
+
</ScrollArea>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// QueueSection - collapsible section container
|
|
163
|
+
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
|
|
164
|
+
|
|
165
|
+
export const QueueSection = ({ className, defaultOpen = true, ...props }: QueueSectionProps) => (
|
|
166
|
+
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// QueueSectionTrigger - section header/trigger
|
|
170
|
+
export type QueueSectionTriggerProps = ComponentProps<"button">;
|
|
171
|
+
|
|
172
|
+
export const QueueSectionTrigger = ({
|
|
173
|
+
children,
|
|
174
|
+
className,
|
|
175
|
+
...props
|
|
176
|
+
}: QueueSectionTriggerProps) => (
|
|
177
|
+
<CollapsibleTrigger asChild>
|
|
178
|
+
<button
|
|
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-sm transition-colors hover:bg-muted",
|
|
181
|
+
className,
|
|
182
|
+
)}
|
|
183
|
+
type="button"
|
|
184
|
+
{...props}
|
|
185
|
+
>
|
|
186
|
+
{children}
|
|
187
|
+
</button>
|
|
188
|
+
</CollapsibleTrigger>
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// QueueSectionLabel - label content with icon and count
|
|
192
|
+
export type QueueSectionLabelProps = ComponentProps<"span"> & {
|
|
193
|
+
count?: number;
|
|
194
|
+
label: string;
|
|
195
|
+
icon?: React.ReactNode;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const QueueSectionLabel = ({
|
|
199
|
+
count,
|
|
200
|
+
label,
|
|
201
|
+
icon,
|
|
202
|
+
className,
|
|
203
|
+
...props
|
|
204
|
+
}: QueueSectionLabelProps) => (
|
|
205
|
+
<span className={cn("flex items-center gap-2", className)} {...props}>
|
|
206
|
+
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
|
207
|
+
{icon}
|
|
208
|
+
<span>
|
|
209
|
+
{count} {label}
|
|
210
|
+
</span>
|
|
211
|
+
</span>
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// QueueSectionContent - collapsible content area
|
|
215
|
+
export type QueueSectionContentProps = ComponentProps<typeof CollapsibleContent>;
|
|
216
|
+
|
|
217
|
+
export const QueueSectionContent = ({ className, ...props }: QueueSectionContentProps) => (
|
|
218
|
+
<CollapsibleContent
|
|
219
|
+
className={cn(
|
|
220
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 data-[state=closed]:fade-out-0 data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)] outline-none",
|
|
221
|
+
className,
|
|
222
|
+
)}
|
|
223
|
+
{...props}
|
|
224
|
+
/>
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
export type QueueProps = ComponentProps<"div">;
|
|
228
|
+
|
|
229
|
+
export const Queue = ({ className, ...props }: QueueProps) => (
|
|
230
|
+
<div
|
|
231
|
+
className={cn(
|
|
232
|
+
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
|
|
233
|
+
className,
|
|
234
|
+
)}
|
|
235
|
+
{...props}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
3
|
+
import { Reasoning, ReasoningContent, ReasoningTrigger } from "./reasoning";
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "AI/Reasoning",
|
|
6
|
+
component: Reasoning,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
} satisfies Meta<typeof Reasoning>;
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof meta>;
|
|
11
|
+
export const Default: Story = {
|
|
12
|
+
render: () => (
|
|
13
|
+
<Reasoning defaultOpen className="max-w-prose">
|
|
14
|
+
<ReasoningTrigger />
|
|
15
|
+
<ReasoningContent>
|
|
16
|
+
{"I checked CI for the last 7 days, grouped by service, then summarized the failures."}
|
|
17
|
+
</ReasoningContent>
|
|
18
|
+
</Reasoning>
|
|
19
|
+
),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** While the model is still thinking: the trigger shimmers and the panel auto-opens. */
|
|
23
|
+
export const Streaming: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<Reasoning defaultOpen isStreaming className="max-w-prose">
|
|
26
|
+
<ReasoningTrigger />
|
|
27
|
+
<ReasoningContent>
|
|
28
|
+
{"Querying the warehouse for Q3 orders, then joining against the returns table"}
|
|
29
|
+
</ReasoningContent>
|
|
30
|
+
</Reasoning>
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A structured live ledger instead of markdown.
|
|
36
|
+
*
|
|
37
|
+
* `ReasoningContent` takes `ReactNode`: a **string** is parsed as streamed
|
|
38
|
+
* markdown, anything else renders as-is — so per-step status can live inside the
|
|
39
|
+
* disclosure without being handed to a markdown parser.
|
|
40
|
+
*/
|
|
41
|
+
export const StructuredLedger: Story = {
|
|
42
|
+
render: () => (
|
|
43
|
+
<Reasoning defaultOpen className="max-w-prose">
|
|
44
|
+
<ReasoningTrigger />
|
|
45
|
+
<ReasoningContent>
|
|
46
|
+
<ol className="flex flex-col gap-2">
|
|
47
|
+
{[
|
|
48
|
+
["done", "Resolved the schema for orders + returns"],
|
|
49
|
+
["done", "Ran the Q3 aggregation (1.2M rows)"],
|
|
50
|
+
["active", "Reconciling against the finance ledger"],
|
|
51
|
+
["pending", "Drafting the summary"],
|
|
52
|
+
].map(([state, label]) => (
|
|
53
|
+
<li className="flex items-start gap-2 text-body" key={label}>
|
|
54
|
+
<span
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
className={cn(
|
|
57
|
+
"mt-1.5 size-1.5 shrink-0 rounded-full",
|
|
58
|
+
state === "done" && "bg-primary",
|
|
59
|
+
state === "active" && "bg-info",
|
|
60
|
+
state === "pending" && "bg-muted-foreground/40",
|
|
61
|
+
)}
|
|
62
|
+
/>
|
|
63
|
+
<span className={state === "pending" ? "text-muted-foreground" : undefined}>
|
|
64
|
+
{label}
|
|
65
|
+
{/* The dot is colour-only, so carry the status in text too —
|
|
66
|
+
this story is an exemplar consumers copy (WCAG 1.4.1). */}
|
|
67
|
+
<span className="sr-only">
|
|
68
|
+
{state === "done"
|
|
69
|
+
? " (done)"
|
|
70
|
+
: state === "active"
|
|
71
|
+
? " (in progress)"
|
|
72
|
+
: " (pending)"}
|
|
73
|
+
</span>
|
|
74
|
+
</span>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ol>
|
|
78
|
+
</ReasoningContent>
|
|
79
|
+
</Reasoning>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import { Reasoning, ReasoningContent, ReasoningTrigger } from "./reasoning";
|
|
5
|
+
|
|
6
|
+
const open = (children: React.ReactNode) =>
|
|
7
|
+
render(
|
|
8
|
+
<Reasoning defaultOpen isStreaming={false}>
|
|
9
|
+
<ReasoningTrigger />
|
|
10
|
+
<ReasoningContent>{children}</ReasoningContent>
|
|
11
|
+
</Reasoning>,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
describe("ReasoningContent — markdown or nodes", () => {
|
|
15
|
+
it("renders a string child as markdown — syntax is consumed", () => {
|
|
16
|
+
const { container } = open("**bold** reasoning");
|
|
17
|
+
// Streamdown wraps tokens in styled <span>s for the streaming animation, so
|
|
18
|
+
// don't assert a tag. The contract is that the markdown was PARSED: the
|
|
19
|
+
// `**` delimiters are gone and only the text remains. The node-path test
|
|
20
|
+
// below asserts the exact opposite, which is what pins the branch.
|
|
21
|
+
expect(container.textContent).not.toContain("**");
|
|
22
|
+
expect(screen.getByText("bold")).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("performs structural markdown transforms on a string child", () => {
|
|
26
|
+
open("- first\n- second");
|
|
27
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders a non-string child as-is, never through the markdown parser", () => {
|
|
31
|
+
// The consumer's case: a structured live reasoning ledger (timeline,
|
|
32
|
+
// per-step status) inside the disclosure. `children: string` made this
|
|
33
|
+
// impossible; handing JSX to Streamdown would be worse.
|
|
34
|
+
open(
|
|
35
|
+
<ol data-testid="ledger">
|
|
36
|
+
<li>Fetch schema</li>
|
|
37
|
+
<li>Join tables</li>
|
|
38
|
+
</ol>,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(screen.getByTestId("ledger")).toBeInTheDocument();
|
|
42
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not markdown-parse text inside a node child", () => {
|
|
46
|
+
open(<span data-testid="raw">**not bold**</span>);
|
|
47
|
+
expect(screen.getByTestId("raw").textContent).toBe("**not bold**");
|
|
48
|
+
expect(screen.queryByText("not bold")).not.toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
4
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
|
|
5
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
|
+
import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
|
|
7
|
+
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
|
8
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
9
|
+
import {
|
|
10
|
+
createContext,
|
|
11
|
+
memo,
|
|
12
|
+
useCallback,
|
|
13
|
+
useContext,
|
|
14
|
+
useEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from "react";
|
|
19
|
+
import { Streamdown } from "streamdown";
|
|
20
|
+
|
|
21
|
+
import { Shimmer } from "./shimmer";
|
|
22
|
+
|
|
23
|
+
interface ReasoningContextValue {
|
|
24
|
+
isStreaming: boolean;
|
|
25
|
+
isOpen: boolean;
|
|
26
|
+
setIsOpen: (open: boolean) => void;
|
|
27
|
+
duration: number | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
|
31
|
+
|
|
32
|
+
export const useReasoning = () => {
|
|
33
|
+
const context = useContext(ReasoningContext);
|
|
34
|
+
if (!context) {
|
|
35
|
+
throw new Error("Reasoning components must be used within Reasoning");
|
|
36
|
+
}
|
|
37
|
+
return context;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
|
41
|
+
isStreaming?: boolean;
|
|
42
|
+
open?: boolean;
|
|
43
|
+
defaultOpen?: boolean;
|
|
44
|
+
onOpenChange?: (open: boolean) => void;
|
|
45
|
+
duration?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const AUTO_CLOSE_DELAY = 1000;
|
|
49
|
+
const MS_IN_S = 1000;
|
|
50
|
+
|
|
51
|
+
export const Reasoning = memo(
|
|
52
|
+
({
|
|
53
|
+
className,
|
|
54
|
+
isStreaming = false,
|
|
55
|
+
open,
|
|
56
|
+
defaultOpen,
|
|
57
|
+
onOpenChange,
|
|
58
|
+
duration: durationProp,
|
|
59
|
+
children,
|
|
60
|
+
...props
|
|
61
|
+
}: ReasoningProps) => {
|
|
62
|
+
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
|
63
|
+
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
|
64
|
+
const isExplicitlyClosed = defaultOpen === false;
|
|
65
|
+
|
|
66
|
+
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
|
67
|
+
defaultProp: resolvedDefaultOpen,
|
|
68
|
+
onChange: onOpenChange,
|
|
69
|
+
prop: open,
|
|
70
|
+
});
|
|
71
|
+
const [duration, setDuration] = useControllableState<number | undefined>({
|
|
72
|
+
defaultProp: undefined,
|
|
73
|
+
prop: durationProp,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const hasEverStreamedRef = useRef(isStreaming);
|
|
77
|
+
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
|
78
|
+
const startTimeRef = useRef<number | null>(null);
|
|
79
|
+
|
|
80
|
+
// Track when streaming starts and compute duration
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (isStreaming) {
|
|
83
|
+
hasEverStreamedRef.current = true;
|
|
84
|
+
if (startTimeRef.current === null) {
|
|
85
|
+
startTimeRef.current = Date.now();
|
|
86
|
+
}
|
|
87
|
+
} else if (startTimeRef.current !== null) {
|
|
88
|
+
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
|
89
|
+
startTimeRef.current = null;
|
|
90
|
+
}
|
|
91
|
+
}, [isStreaming, setDuration]);
|
|
92
|
+
|
|
93
|
+
// Auto-open when streaming starts (unless explicitly closed)
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
|
96
|
+
setIsOpen(true);
|
|
97
|
+
}
|
|
98
|
+
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
|
99
|
+
|
|
100
|
+
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (hasEverStreamedRef.current && !isStreaming && isOpen && !hasAutoClosed) {
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
setIsOpen(false);
|
|
105
|
+
setHasAutoClosed(true);
|
|
106
|
+
}, AUTO_CLOSE_DELAY);
|
|
107
|
+
|
|
108
|
+
return () => clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
|
111
|
+
|
|
112
|
+
const handleOpenChange = useCallback(
|
|
113
|
+
(newOpen: boolean) => {
|
|
114
|
+
setIsOpen(newOpen);
|
|
115
|
+
},
|
|
116
|
+
[setIsOpen],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const contextValue = useMemo(
|
|
120
|
+
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
|
121
|
+
[duration, isOpen, isStreaming, setIsOpen],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<ReasoningContext.Provider value={contextValue}>
|
|
126
|
+
<Collapsible
|
|
127
|
+
className={cn("not-prose mb-4", className)}
|
|
128
|
+
onOpenChange={handleOpenChange}
|
|
129
|
+
open={isOpen}
|
|
130
|
+
{...props}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</Collapsible>
|
|
134
|
+
</ReasoningContext.Provider>
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
|
140
|
+
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
|
144
|
+
if (isStreaming || duration === 0) {
|
|
145
|
+
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
|
146
|
+
}
|
|
147
|
+
if (duration === undefined) {
|
|
148
|
+
return <p>Thought for a few seconds</p>;
|
|
149
|
+
}
|
|
150
|
+
return <p>Thought for {duration} seconds</p>;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const ReasoningTrigger = memo(
|
|
154
|
+
({
|
|
155
|
+
className,
|
|
156
|
+
children,
|
|
157
|
+
getThinkingMessage = defaultGetThinkingMessage,
|
|
158
|
+
...props
|
|
159
|
+
}: ReasoningTriggerProps) => {
|
|
160
|
+
const { isStreaming, isOpen, duration } = useReasoning();
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<CollapsibleTrigger
|
|
164
|
+
className={cn(
|
|
165
|
+
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
|
166
|
+
className,
|
|
167
|
+
)}
|
|
168
|
+
{...props}
|
|
169
|
+
>
|
|
170
|
+
{children ?? (
|
|
171
|
+
<>
|
|
172
|
+
<BrainIcon className="size-4" />
|
|
173
|
+
{getThinkingMessage(isStreaming, duration)}
|
|
174
|
+
<ChevronDownIcon
|
|
175
|
+
className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
|
|
176
|
+
/>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
</CollapsibleTrigger>
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
export type ReasoningContentProps = Omit<ComponentProps<typeof CollapsibleContent>, "children"> & {
|
|
185
|
+
/**
|
|
186
|
+
* The reasoning body.
|
|
187
|
+
*
|
|
188
|
+
* A **string** is rendered as streamed markdown (Streamdown) — the default for
|
|
189
|
+
* model reasoning text. Any other node renders **as-is**, so a structured live
|
|
190
|
+
* ledger (a timeline, per-step status) can live inside the disclosure without
|
|
191
|
+
* being handed to a markdown parser. Mirrors the `getThinkingMessage` /
|
|
192
|
+
* `children` override seam on `ReasoningTrigger`.
|
|
193
|
+
*
|
|
194
|
+
* Note an **array** of strings is not a `string` — `{["a", "b"]}` renders raw.
|
|
195
|
+
* Pass one string if you want it parsed as markdown.
|
|
196
|
+
*/
|
|
197
|
+
children: ReactNode;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => {
|
|
201
|
+
// Streamdown's own chrome (code copy, table menus) reads the locale seam (#310).
|
|
202
|
+
const translations = useStreamdownTranslations();
|
|
203
|
+
// Brand-token-derived `code` plugin, not the package's static github-*
|
|
204
|
+
// default (#315 follow-up) — re-derives when the active theme changes.
|
|
205
|
+
const plugins = useStreamdownPlugins();
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<CollapsibleContent
|
|
209
|
+
className={cn(
|
|
210
|
+
"mt-4 text-sm",
|
|
211
|
+
"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
|
+
className,
|
|
213
|
+
)}
|
|
214
|
+
{...props}
|
|
215
|
+
>
|
|
216
|
+
{typeof children === "string" ? (
|
|
217
|
+
<Streamdown plugins={plugins} translations={translations}>
|
|
218
|
+
{children}
|
|
219
|
+
</Streamdown>
|
|
220
|
+
) : (
|
|
221
|
+
children
|
|
222
|
+
)}
|
|
223
|
+
</CollapsibleContent>
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
Reasoning.displayName = "Reasoning";
|
|
228
|
+
ReasoningTrigger.displayName = "ReasoningTrigger";
|
|
229
|
+
ReasoningContent.displayName = "ReasoningContent";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import {
|
|
3
|
+
Sandbox,
|
|
4
|
+
SandboxContent,
|
|
5
|
+
SandboxHeader,
|
|
6
|
+
SandboxTabContent,
|
|
7
|
+
SandboxTabs,
|
|
8
|
+
SandboxTabsBar,
|
|
9
|
+
SandboxTabsList,
|
|
10
|
+
SandboxTabsTrigger,
|
|
11
|
+
} from "./sandbox";
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
title: "AI/Sandbox",
|
|
15
|
+
component: Sandbox,
|
|
16
|
+
parameters: { layout: "padded" },
|
|
17
|
+
} satisfies Meta<typeof Sandbox>;
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<Sandbox className="max-w-lg">
|
|
24
|
+
<SandboxHeader title="Python sandbox" state="output-available" />
|
|
25
|
+
<SandboxContent>
|
|
26
|
+
<SandboxTabs defaultValue="output">
|
|
27
|
+
<SandboxTabsBar>
|
|
28
|
+
<SandboxTabsList>
|
|
29
|
+
<SandboxTabsTrigger value="output">Output</SandboxTabsTrigger>
|
|
30
|
+
</SandboxTabsList>
|
|
31
|
+
</SandboxTabsBar>
|
|
32
|
+
<SandboxTabContent value="output" className="p-3">
|
|
33
|
+
3 rows reconciled.
|
|
34
|
+
</SandboxTabContent>
|
|
35
|
+
</SandboxTabs>
|
|
36
|
+
</SandboxContent>
|
|
37
|
+
</Sandbox>
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// LOADING — the run's output hasn't arrived yet (loading-states.md `loading`).
|
|
42
|
+
// `SandboxContent` renders a layout-shaped skeleton while `SandboxHeader`
|
|
43
|
+
// keeps rendering as passed, so the run's identity stays visible.
|
|
44
|
+
export const Loading: Story = {
|
|
45
|
+
render: () => (
|
|
46
|
+
<Sandbox className="max-w-lg" loading>
|
|
47
|
+
<SandboxHeader title="Python sandbox" state="input-streaming" />
|
|
48
|
+
<SandboxContent>
|
|
49
|
+
<SandboxTabs defaultValue="output">
|
|
50
|
+
<SandboxTabsBar>
|
|
51
|
+
<SandboxTabsList>
|
|
52
|
+
<SandboxTabsTrigger value="output">Output</SandboxTabsTrigger>
|
|
53
|
+
</SandboxTabsList>
|
|
54
|
+
</SandboxTabsBar>
|
|
55
|
+
<SandboxTabContent value="output" className="p-3">
|
|
56
|
+
This content is not shown while loading.
|
|
57
|
+
</SandboxTabContent>
|
|
58
|
+
</SandboxTabs>
|
|
59
|
+
</SandboxContent>
|
|
60
|
+
</Sandbox>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { Sandbox, SandboxContent, SandboxHeader } from "./sandbox";
|
|
4
|
+
|
|
5
|
+
describe("Sandbox", () => {
|
|
6
|
+
it("renders its header and content", () => {
|
|
7
|
+
render(
|
|
8
|
+
<Sandbox>
|
|
9
|
+
<SandboxHeader title="Python sandbox" state="output-available" />
|
|
10
|
+
<SandboxContent>Real output</SandboxContent>
|
|
11
|
+
</Sandbox>,
|
|
12
|
+
);
|
|
13
|
+
expect(screen.getByText("Python sandbox")).toBeInTheDocument();
|
|
14
|
+
expect(screen.getByText("Real output")).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("Sandbox loading (#269, loading-states.md)", () => {
|
|
19
|
+
it("renders a skeleton in SandboxContent while loading — header keeps rendering", () => {
|
|
20
|
+
const { container } = render(
|
|
21
|
+
<Sandbox loading>
|
|
22
|
+
<SandboxHeader title="Python sandbox" state="input-streaming" />
|
|
23
|
+
<SandboxContent>Real output — should not render while loading</SandboxContent>
|
|
24
|
+
</Sandbox>,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByText("Python sandbox")).toBeInTheDocument();
|
|
27
|
+
expect(screen.queryByText(/Real output/)).not.toBeInTheDocument();
|
|
28
|
+
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
|
29
|
+
const statuses = container.querySelectorAll('[role="status"]');
|
|
30
|
+
expect(statuses).toHaveLength(1);
|
|
31
|
+
expect(statuses[0]).toHaveAttribute("aria-live", "polite");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders the real content once loading clears", () => {
|
|
35
|
+
render(
|
|
36
|
+
<Sandbox>
|
|
37
|
+
<SandboxContent>Real output</SandboxContent>
|
|
38
|
+
</Sandbox>,
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByText("Real output")).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
});
|