@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.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. 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
+ });