@truefoundry/agent-ui-sdk 0.0.1

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 (79) hide show
  1. package/README.md +634 -0
  2. package/dist/index.d.ts +718 -0
  3. package/dist/index.js +2736 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +83 -0
  6. package/src/atoms/AskUserPrompt.tsx +102 -0
  7. package/src/atoms/AttachmentCard.tsx +92 -0
  8. package/src/atoms/AttachmentPickerButton.tsx +19 -0
  9. package/src/atoms/AttachmentPreviewDialog.tsx +34 -0
  10. package/src/atoms/BranchIndicator.tsx +41 -0
  11. package/src/atoms/CodeBlockHeader.tsx +44 -0
  12. package/src/atoms/ComposerShell.tsx +112 -0
  13. package/src/atoms/Markdown.test.tsx +28 -0
  14. package/src/atoms/Markdown.tsx +288 -0
  15. package/src/atoms/McpAuthPrompt.tsx +60 -0
  16. package/src/atoms/MessageActionBar.tsx +60 -0
  17. package/src/atoms/MessageBubble.tsx +80 -0
  18. package/src/atoms/MessageErrorBanner.tsx +26 -0
  19. package/src/atoms/MessageIndicator.tsx +23 -0
  20. package/src/atoms/OpenUIBlock.test.tsx +37 -0
  21. package/src/atoms/OpenUIBlock.tsx +50 -0
  22. package/src/atoms/ReasoningCard.tsx +52 -0
  23. package/src/atoms/SandboxArtifactList.tsx +63 -0
  24. package/src/atoms/ScrollToBottomButton.tsx +34 -0
  25. package/src/atoms/Skeletons.tsx +32 -0
  26. package/src/atoms/ThreadListMisc.tsx +76 -0
  27. package/src/atoms/ThreadListRow.tsx +81 -0
  28. package/src/atoms/ThreadShell.tsx +93 -0
  29. package/src/atoms/Toast.tsx +60 -0
  30. package/src/atoms/ToolApprovalBar.tsx +92 -0
  31. package/src/atoms/ToolCallCard.tsx +186 -0
  32. package/src/atoms/ToolGroupCard.tsx +52 -0
  33. package/src/atoms/UserMessageActionBar.tsx +62 -0
  34. package/src/atoms/WelcomeScreen.tsx +22 -0
  35. package/src/atoms/lib/cn.ts +6 -0
  36. package/src/atoms/primitives/Avatar.tsx +57 -0
  37. package/src/atoms/primitives/Button.tsx +73 -0
  38. package/src/atoms/primitives/Collapsible.tsx +32 -0
  39. package/src/atoms/primitives/Dialog.tsx +152 -0
  40. package/src/atoms/primitives/IconButton.tsx +43 -0
  41. package/src/atoms/primitives/Skeleton.tsx +17 -0
  42. package/src/atoms/primitives/Tooltip.tsx +58 -0
  43. package/src/containers/AskUserContainer.tsx +49 -0
  44. package/src/containers/AssistantMessageContainer.test.tsx +56 -0
  45. package/src/containers/AssistantMessageContainer.tsx +128 -0
  46. package/src/containers/AssistantTextContainer.test.tsx +111 -0
  47. package/src/containers/AssistantTextContainer.tsx +54 -0
  48. package/src/containers/AttachmentsContainer.tsx +83 -0
  49. package/src/containers/ComposerContainer.tsx +66 -0
  50. package/src/containers/ErrorToasterContainer.tsx +76 -0
  51. package/src/containers/McpAuthContainer.test.tsx +100 -0
  52. package/src/containers/McpAuthContainer.tsx +22 -0
  53. package/src/containers/MessageImageContainer.tsx +37 -0
  54. package/src/containers/ReasoningContainer.tsx +32 -0
  55. package/src/containers/RuntimeHarness.tsx +39 -0
  56. package/src/containers/Thread.tsx +9 -0
  57. package/src/containers/ThreadContainer.test.tsx +52 -0
  58. package/src/containers/ThreadContainer.tsx +87 -0
  59. package/src/containers/ThreadListContainer.tsx +63 -0
  60. package/src/containers/ToolCallContainer.test.tsx +135 -0
  61. package/src/containers/ToolCallContainer.tsx +167 -0
  62. package/src/containers/ToolGroupContainer.test.tsx +56 -0
  63. package/src/containers/ToolGroupContainer.tsx +24 -0
  64. package/src/containers/UserEditComposerContainer.test.tsx +52 -0
  65. package/src/containers/UserEditComposerContainer.tsx +86 -0
  66. package/src/containers/UserMessageContainer.test.tsx +99 -0
  67. package/src/containers/UserMessageContainer.tsx +26 -0
  68. package/src/containers/nestedApprovalBridge.ts +20 -0
  69. package/src/containers/useAttachmentPreviewSrc.ts +42 -0
  70. package/src/hooks/useComposerBusyState.test.ts +78 -0
  71. package/src/hooks/useComposerBusyState.ts +69 -0
  72. package/src/index.ts +193 -0
  73. package/src/openui.css +3 -0
  74. package/src/testSetup.ts +42 -0
  75. package/src/theme/SlotsProvider.test.tsx +64 -0
  76. package/src/theme/SlotsProvider.tsx +41 -0
  77. package/src/theme/defaultSlots.ts +131 -0
  78. package/src/theme/tokens.ts +98 -0
  79. package/src/theme/useClassDarkMode.ts +24 -0
@@ -0,0 +1,288 @@
1
+ import { Children, isValidElement, useMemo, type ReactNode } from "react";
2
+ import ReactMarkdown, { type Components } from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+
5
+ import { cn } from "./lib/cn.js";
6
+ import { CodeBlockHeader } from "./CodeBlockHeader.js";
7
+ import { OpenUIBlock } from "./OpenUIBlock.js";
8
+ import { SandboxArtifactList, type SandboxArtifactLink } from "./SandboxArtifactList.js";
9
+
10
+ function extractCodeText(node: ReactNode): string {
11
+ if (typeof node === "string") return node;
12
+ if (typeof node === "number") return String(node);
13
+ if (Array.isArray(node)) return node.map(extractCodeText).join("");
14
+ if (isValidElement(node)) {
15
+ return extractCodeText((node.props as { children?: ReactNode }).children);
16
+ }
17
+ return "";
18
+ }
19
+
20
+ const SANDBOX_ARTIFACT_TOKEN = "sandbox_artifact";
21
+
22
+ /**
23
+ * By the time this runs, `[Label](path)` has already been parsed into a real
24
+ * `<a href="path">Label</a>` element by react-markdown — the raw path text is
25
+ * gone from any flattened string, only present as the anchor's `href` prop.
26
+ * So detection walks `children` as React elements, not a flattened string.
27
+ */
28
+ /**
29
+ * The gateway emits sandbox artifact links as a fenced code block tagged
30
+ * ```sandbox_artifact(s)``` containing raw `[label](path)` markdown-link text
31
+ * (never parsed into real anchors, since it's inside a code fence).
32
+ */
33
+ function parseSandboxArtifactCodeBlock(code: string): SandboxArtifactLink[] | null {
34
+ const links: SandboxArtifactLink[] = [];
35
+ for (const match of code.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)) {
36
+ links.push({ label: match[1]!, path: match[2]! });
37
+ }
38
+ return links.length > 0 ? links : null;
39
+ }
40
+
41
+ function parseSandboxArtifactParagraph(children: ReactNode): SandboxArtifactLink[] | null {
42
+ const items = Children.toArray(children);
43
+ if (items.length === 0) return null;
44
+
45
+ const first = items[0];
46
+ if (typeof first !== "string") return null;
47
+ const stripped = first.replace(/^\s*/, "");
48
+ if (!stripped.startsWith(SANDBOX_ARTIFACT_TOKEN)) return null;
49
+ if (!/^\s*$/.test(stripped.slice(SANDBOX_ARTIFACT_TOKEN.length))) return null;
50
+
51
+ const links: SandboxArtifactLink[] = [];
52
+ for (let i = 1; i < items.length; i++) {
53
+ const item = items[i];
54
+ if (typeof item === "string") {
55
+ const trimmed = item.trim();
56
+ if (trimmed === "" || trimmed === SANDBOX_ARTIFACT_TOKEN) continue;
57
+ return null;
58
+ }
59
+ if (isValidElement(item) && typeof (item.props as { href?: unknown }).href === "string") {
60
+ links.push({
61
+ label: extractCodeText((item.props as { children?: ReactNode }).children),
62
+ path: (item.props as { href: string }).href,
63
+ });
64
+ continue;
65
+ }
66
+ return null;
67
+ }
68
+ return links.length > 0 ? links : null;
69
+ }
70
+
71
+ function createMarkdownComponents(
72
+ isStreaming?: boolean,
73
+ onDownloadArtifact?: (path: string) => void | Promise<unknown>,
74
+ ): Components {
75
+ return {
76
+ h1: ({ className, ...props }) => (
77
+ <h1
78
+ className={cn(
79
+ "aui-md-h1 mt-5 mb-2 scroll-m-20 text-xl font-semibold first:mt-0 last:mb-0",
80
+ className,
81
+ )}
82
+ {...props}
83
+ />
84
+ ),
85
+ h2: ({ className, ...props }) => (
86
+ <h2
87
+ className={cn(
88
+ "aui-md-h2 mt-5 mb-2 scroll-m-20 text-lg font-semibold first:mt-0 last:mb-0",
89
+ className,
90
+ )}
91
+ {...props}
92
+ />
93
+ ),
94
+ h3: ({ className, ...props }) => (
95
+ <h3
96
+ className={cn(
97
+ "aui-md-h3 mt-4 mb-1.5 scroll-m-20 text-base font-semibold first:mt-0 last:mb-0",
98
+ className,
99
+ )}
100
+ {...props}
101
+ />
102
+ ),
103
+ h4: ({ className, ...props }) => (
104
+ <h4
105
+ className={cn(
106
+ "aui-md-h4 mt-3.5 mb-1 scroll-m-20 text-base font-medium first:mt-0 last:mb-0",
107
+ className,
108
+ )}
109
+ {...props}
110
+ />
111
+ ),
112
+ h5: ({ className, ...props }) => (
113
+ <h5
114
+ className={cn("aui-md-h5 mt-3 mb-1 text-sm font-semibold first:mt-0 last:mb-0", className)}
115
+ {...props}
116
+ />
117
+ ),
118
+ h6: ({ className, ...props }) => (
119
+ <h6
120
+ className={cn("aui-md-h6 mt-3 mb-1 text-sm font-medium first:mt-0 last:mb-0", className)}
121
+ {...props}
122
+ />
123
+ ),
124
+ p: ({ className, children, ...props }) => {
125
+ const artifacts = parseSandboxArtifactParagraph(children);
126
+ if (artifacts != null) {
127
+ return <SandboxArtifactList artifacts={artifacts} onDownload={onDownloadArtifact} />;
128
+ }
129
+ return (
130
+ <p className={cn("aui-md-p my-3 leading-relaxed first:mt-0 last:mb-0", className)} {...props}>
131
+ {children}
132
+ </p>
133
+ );
134
+ },
135
+ a: ({ className, ...props }) => (
136
+ <a
137
+ className={cn(
138
+ "aui-md-a text-primary hover:text-primary/80 underline underline-offset-2",
139
+ className,
140
+ )}
141
+ target="_blank"
142
+ rel="noopener noreferrer"
143
+ {...props}
144
+ />
145
+ ),
146
+ blockquote: ({ className, ...props }) => (
147
+ <blockquote
148
+ className={cn(
149
+ "aui-md-blockquote border-muted-foreground/30 text-muted-foreground my-3 border-s-2 ps-4",
150
+ className,
151
+ )}
152
+ {...props}
153
+ />
154
+ ),
155
+ ul: ({ className, ...props }) => (
156
+ <ul
157
+ className={cn(
158
+ "aui-md-ul marker:text-muted-foreground my-3 ms-5 list-disc [&>li]:mt-1",
159
+ className,
160
+ )}
161
+ {...props}
162
+ />
163
+ ),
164
+ ol: ({ className, ...props }) => (
165
+ <ol
166
+ className={cn(
167
+ "aui-md-ol marker:text-muted-foreground my-3 ms-5 list-decimal [&>li]:mt-1",
168
+ className,
169
+ )}
170
+ {...props}
171
+ />
172
+ ),
173
+ hr: ({ className, ...props }) => (
174
+ <hr className={cn("aui-md-hr border-muted-foreground/20 my-3", className)} {...props} />
175
+ ),
176
+ table: ({ className, ...props }) => (
177
+ <table
178
+ className={cn(
179
+ "aui-md-table my-3 w-full border-separate border-spacing-0 overflow-y-auto",
180
+ className,
181
+ )}
182
+ {...props}
183
+ />
184
+ ),
185
+ th: ({ className, ...props }) => (
186
+ <th
187
+ className={cn(
188
+ "aui-md-th bg-muted px-3 py-1.5 text-start font-medium first:rounded-ss-lg last:rounded-se-lg [[align=center]]:text-center [[align=right]]:text-right",
189
+ className,
190
+ )}
191
+ {...props}
192
+ />
193
+ ),
194
+ td: ({ className, ...props }) => (
195
+ <td
196
+ className={cn(
197
+ "aui-md-td border-muted-foreground/20 border-s border-b px-3 py-1.5 text-start last:border-e [[align=center]]:text-center [[align=right]]:text-right",
198
+ className,
199
+ )}
200
+ {...props}
201
+ />
202
+ ),
203
+ tr: ({ className, ...props }) => (
204
+ <tr
205
+ className={cn(
206
+ "aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg",
207
+ className,
208
+ )}
209
+ {...props}
210
+ />
211
+ ),
212
+ li: ({ className, ...props }) => <li className={cn("aui-md-li leading-relaxed", className)} {...props} />,
213
+ strong: ({ className, ...props }) => (
214
+ <strong className={cn("aui-md-strong font-semibold", className)} {...props} />
215
+ ),
216
+ sup: ({ className, ...props }) => (
217
+ <sup className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} {...props} />
218
+ ),
219
+ pre: ({ className, children, ...props }) => {
220
+ const codeElement = isValidElement(children) ? children : undefined;
221
+ const codeClassName = (codeElement?.props as { className?: string } | undefined)?.className ?? "";
222
+ const language = /language-(\S+)/.exec(codeClassName)?.[1] ?? "text";
223
+ const code = extractCodeText(children);
224
+ if (language === "openui") {
225
+ return <OpenUIBlock source={code} isStreaming={isStreaming} />;
226
+ }
227
+ if (language === "sandbox_artifact" || language === "sandbox_artifacts") {
228
+ const artifacts = parseSandboxArtifactCodeBlock(code);
229
+ if (artifacts != null) {
230
+ return <SandboxArtifactList artifacts={artifacts} onDownload={onDownloadArtifact} />;
231
+ }
232
+ }
233
+ return (
234
+ <>
235
+ <CodeBlockHeader language={language} code={code} />
236
+ <pre
237
+ className={cn(
238
+ "aui-md-pre border-border/50 bg-muted/30 overflow-x-auto rounded-t-none rounded-b-xl border border-t-0 p-3.5 text-[13px] leading-relaxed",
239
+ className,
240
+ )}
241
+ {...props}
242
+ >
243
+ {children}
244
+ </pre>
245
+ </>
246
+ );
247
+ },
248
+ code: ({ className, ...props }) => {
249
+ const isCodeBlock = /language-/.test(className ?? "");
250
+ return (
251
+ <code
252
+ className={cn(
253
+ !isCodeBlock && "aui-md-inline-code bg-muted rounded-md px-1.5 py-0.5 font-mono text-[0.85em]",
254
+ className,
255
+ )}
256
+ {...props}
257
+ />
258
+ );
259
+ },
260
+ };
261
+ }
262
+
263
+ export type MarkdownProps = {
264
+ content: string;
265
+ className?: string;
266
+ isStreaming?: boolean;
267
+ onDownloadArtifact?: (path: string) => void | Promise<unknown>;
268
+ };
269
+
270
+ export function Markdown({ content, className, isStreaming, onDownloadArtifact }: MarkdownProps) {
271
+ const components = useMemo(
272
+ () => createMarkdownComponents(isStreaming, onDownloadArtifact),
273
+ [isStreaming, onDownloadArtifact],
274
+ );
275
+ return (
276
+ <div className={cn("aui-md", className)}>
277
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
278
+ {content}
279
+ </ReactMarkdown>
280
+ </div>
281
+ );
282
+ }
283
+
284
+ declare module "../theme/SlotsProvider.js" {
285
+ interface AtomSlots {
286
+ Markdown: typeof Markdown;
287
+ }
288
+ }
@@ -0,0 +1,60 @@
1
+ import { ExternalLinkIcon } from "lucide-react";
2
+
3
+ import { cn } from "./lib/cn.js";
4
+ import { Button } from "./primitives/Button.js";
5
+
6
+ export type McpAuthServer = { id: string; name: string; authUrl: string };
7
+
8
+ export type McpAuthPromptProps = {
9
+ servers: McpAuthServer[];
10
+ disabled: boolean;
11
+ onContinue: () => void;
12
+ className?: string;
13
+ };
14
+
15
+ export function McpAuthPrompt({ servers, disabled, onContinue, className }: McpAuthPromptProps) {
16
+ return (
17
+ <div data-slot="aui_mcp-auth-continue-composer" className={cn("flex w-full flex-col gap-3", className)}>
18
+ <div className="border-primary/20 bg-background overflow-hidden rounded-2xl border">
19
+ <div className="bg-primary/10 border-primary/20 border-b px-4 py-2.5">
20
+ <p className="text-primary text-sm font-semibold">MCP Authentication Required</p>
21
+ </div>
22
+ <ul className="divide-border/60 divide-y">
23
+ {servers.map((server) => (
24
+ <li
25
+ key={server.id}
26
+ className="flex items-center justify-between gap-3 px-4 py-2.5"
27
+ >
28
+ <p className="text-sm">
29
+ <span className="text-muted-foreground">MCP Server Name</span>
30
+ <span className="text-muted-foreground mx-1.5">:</span>
31
+ <span className="font-semibold">{server.name}</span>
32
+ </p>
33
+ <Button
34
+ asChild
35
+ size="sm"
36
+ className="bg-foreground text-background hover:bg-foreground/80 rounded-full"
37
+ >
38
+ <a href={server.authUrl} target="_blank" rel="noopener noreferrer">
39
+ Connect
40
+ <ExternalLinkIcon className="size-3.5" />
41
+ </a>
42
+ </Button>
43
+ </li>
44
+ ))}
45
+ </ul>
46
+ </div>
47
+ <div className="flex justify-end px-2.5">
48
+ <Button type="button" className="rounded-full px-4" disabled={disabled} onClick={onContinue}>
49
+ Continue
50
+ </Button>
51
+ </div>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ declare module "../theme/SlotsProvider.js" {
57
+ interface AtomSlots {
58
+ McpAuthPrompt: typeof McpAuthPrompt;
59
+ }
60
+ }
@@ -0,0 +1,60 @@
1
+ import { CheckIcon, CopyIcon, DownloadIcon, MoreHorizontalIcon } from "lucide-react";
2
+ import { DropdownMenu } from "radix-ui";
3
+
4
+ import { cn } from "./lib/cn.js";
5
+ import { IconButton } from "./primitives/IconButton.js";
6
+
7
+ export type MessageActionBarProps = {
8
+ isCopied: boolean;
9
+ onCopy: () => void;
10
+ onExportMarkdown: () => void;
11
+ className?: string;
12
+ };
13
+
14
+ export function MessageActionBar({ isCopied, onCopy, onExportMarkdown, className }: MessageActionBarProps) {
15
+ return (
16
+ <div
17
+ className={cn(
18
+ "aui-assistant-action-bar-root text-muted-foreground animate-in fade-in flex gap-1 duration-200",
19
+ className,
20
+ )}
21
+ >
22
+ <IconButton tooltip="Copy" onClick={onCopy}>
23
+ {isCopied ? (
24
+ <CheckIcon className="animate-in zoom-in-50 fade-in duration-200 ease-out" />
25
+ ) : (
26
+ <CopyIcon className="animate-in zoom-in-75 fade-in duration-150" />
27
+ )}
28
+ </IconButton>
29
+ <DropdownMenu.Root>
30
+ <DropdownMenu.Trigger asChild>
31
+ <IconButton tooltip="More" className="data-[state=open]:bg-accent">
32
+ <MoreHorizontalIcon />
33
+ </IconButton>
34
+ </DropdownMenu.Trigger>
35
+ <DropdownMenu.Portal>
36
+ <DropdownMenu.Content
37
+ side="bottom"
38
+ align="start"
39
+ sideOffset={6}
40
+ className="aui-action-bar-more-content bg-popover/95 text-popover-foreground data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-xl border p-1.5 shadow-lg backdrop-blur-sm"
41
+ >
42
+ <DropdownMenu.Item
43
+ onSelect={onExportMarkdown}
44
+ className="aui-action-bar-more-item hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm outline-none select-none"
45
+ >
46
+ <DownloadIcon className="size-4" />
47
+ Export as Markdown
48
+ </DropdownMenu.Item>
49
+ </DropdownMenu.Content>
50
+ </DropdownMenu.Portal>
51
+ </DropdownMenu.Root>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ declare module "../theme/SlotsProvider.js" {
57
+ interface AtomSlots {
58
+ MessageActionBar: typeof MessageActionBar;
59
+ }
60
+ }
@@ -0,0 +1,80 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { cn } from "./lib/cn.js";
4
+
5
+ export type AssistantMessageBubbleProps = {
6
+ variant: "assistant";
7
+ children: ReactNode;
8
+ error?: ReactNode;
9
+ branchIndicator?: ReactNode;
10
+ actionBar?: ReactNode;
11
+ className?: string;
12
+ };
13
+
14
+ export type UserMessageBubbleProps = {
15
+ variant: "user";
16
+ children: ReactNode;
17
+ attachments?: ReactNode;
18
+ branchIndicator?: ReactNode;
19
+ actionBar?: ReactNode;
20
+ className?: string;
21
+ };
22
+
23
+ export type MessageBubbleProps = AssistantMessageBubbleProps | UserMessageBubbleProps;
24
+
25
+ export function MessageBubble(props: MessageBubbleProps) {
26
+ if (props.variant === "assistant") {
27
+ const { children, error, branchIndicator, actionBar, className } = props;
28
+ return (
29
+ <div
30
+ data-slot="aui_assistant-message-root"
31
+ className={cn("fade-in slide-in-from-bottom-1 animate-in relative duration-150", className)}
32
+ >
33
+ <div
34
+ data-slot="aui_assistant-message-content"
35
+ className="text-foreground flex flex-col gap-3 px-2 leading-relaxed wrap-break-word [contain-intrinsic-size:auto_24px] [content-visibility:auto]"
36
+ >
37
+ {children}
38
+ {error}
39
+ </div>
40
+ <div data-slot="aui_assistant-message-footer" className="ms-2 -mb-7.5 min-h-7.5 pt-1.5 flex items-center">
41
+ {branchIndicator}
42
+ {actionBar}
43
+ </div>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ const { children, attachments, branchIndicator, actionBar, className } = props;
49
+ return (
50
+ <div
51
+ data-slot="aui_user-message-root"
52
+ className={cn(
53
+ "fade-in slide-in-from-bottom-1 animate-in grid auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 duration-150 [contain-intrinsic-size:auto_60px] [content-visibility:auto] [&:where(>*)]:col-start-2",
54
+ className,
55
+ )}
56
+ >
57
+ {attachments}
58
+ <div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
59
+ <div className="aui-user-message-content bg-muted text-foreground rounded-xl px-4 py-2 wrap-break-word empty:hidden">
60
+ {children}
61
+ </div>
62
+ </div>
63
+ {(branchIndicator != null || actionBar != null) && (
64
+ <div
65
+ data-slot="aui_user-message-footer"
66
+ className="col-span-full col-start-1 row-start-3 -me-1 flex items-center justify-end gap-1"
67
+ >
68
+ {branchIndicator}
69
+ {actionBar}
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ declare module "../theme/SlotsProvider.js" {
77
+ interface AtomSlots {
78
+ MessageBubble: typeof MessageBubble;
79
+ }
80
+ }
@@ -0,0 +1,26 @@
1
+ import { cn } from "./lib/cn.js";
2
+
3
+ export type MessageErrorBannerProps = {
4
+ message: string;
5
+ className?: string;
6
+ };
7
+
8
+ export function MessageErrorBanner({ message, className }: MessageErrorBannerProps) {
9
+ return (
10
+ <div
11
+ role="alert"
12
+ className={cn(
13
+ "aui-message-error-root border-destructive bg-destructive/10 text-destructive dark:bg-destructive/5 mt-2 rounded-md border p-3 text-sm dark:text-red-200",
14
+ className,
15
+ )}
16
+ >
17
+ <span className="aui-message-error-message line-clamp-2">{message}</span>
18
+ </div>
19
+ );
20
+ }
21
+
22
+ declare module "../theme/SlotsProvider.js" {
23
+ interface AtomSlots {
24
+ MessageErrorBanner: typeof MessageErrorBanner;
25
+ }
26
+ }
@@ -0,0 +1,23 @@
1
+ import { cn } from "./lib/cn.js";
2
+
3
+ export type MessageIndicatorProps = {
4
+ className?: string;
5
+ };
6
+
7
+ export function MessageIndicator({ className }: MessageIndicatorProps) {
8
+ return (
9
+ <span
10
+ data-slot="aui_assistant-message-indicator"
11
+ className={cn("animate-pulse font-sans", className)}
12
+ aria-label="Assistant is working"
13
+ >
14
+ {"●"}
15
+ </span>
16
+ );
17
+ }
18
+
19
+ declare module "../theme/SlotsProvider.js" {
20
+ interface AtomSlots {
21
+ MessageIndicator: typeof MessageIndicator;
22
+ }
23
+ }
@@ -0,0 +1,37 @@
1
+ // @vitest-environment jsdom
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { OpenUIBlock } from "./OpenUIBlock.js";
6
+
7
+ describe("OpenUIBlock", () => {
8
+ it("renders OpenUI source through Renderer", () => {
9
+ render(<OpenUIBlock source={'Card() { title: "Hello" }'} />);
10
+ expect(screen.getByTestId("openui-renderer")).toHaveTextContent('Card() { title: "Hello" }');
11
+ expect(screen.getByTestId("openui-renderer")).toHaveAttribute("data-streaming", "false");
12
+ });
13
+
14
+ it("forwards isStreaming to Renderer", () => {
15
+ render(<OpenUIBlock source="Card() {}" isStreaming />);
16
+ expect(screen.getByTestId("openui-renderer")).toHaveAttribute("data-streaming", "true");
17
+ });
18
+
19
+ it("exposes a stable wrapper slot for integration tests", () => {
20
+ render(<OpenUIBlock source="Card() {}" />);
21
+ expect(screen.getByTestId("openui-renderer").closest("[data-slot='openui-block']")).toBeTruthy();
22
+ });
23
+
24
+ it("wraps content in OpenUI ThemeProvider using the html .dark class by default", () => {
25
+ document.documentElement.classList.add("dark");
26
+ render(<OpenUIBlock source="Card() {}" />);
27
+ expect(screen.getByTestId("openui-theme-provider")).toHaveAttribute("data-mode", "dark");
28
+ document.documentElement.classList.remove("dark");
29
+ });
30
+
31
+ it("honors an explicit mode override", () => {
32
+ document.documentElement.classList.add("dark");
33
+ render(<OpenUIBlock source="Card() {}" mode="light" />);
34
+ expect(screen.getByTestId("openui-theme-provider")).toHaveAttribute("data-mode", "light");
35
+ document.documentElement.classList.remove("dark");
36
+ });
37
+ });
@@ -0,0 +1,50 @@
1
+ import { memo, useId } from "react";
2
+ import { Renderer } from "@openuidev/react-lang";
3
+ import { ThemeProvider } from "@openuidev/react-ui";
4
+ import { openuiLibrary } from "@openuidev/react-ui/genui-lib";
5
+
6
+ import { useClassDarkMode, type ThemeMode } from "../theme/useClassDarkMode.js";
7
+ import { cn } from "./lib/cn.js";
8
+
9
+ export type OpenUIBlockProps = {
10
+ source: string;
11
+ isStreaming?: boolean;
12
+ className?: string;
13
+ /** Override light/dark mode. Defaults to syncing with a `.dark` class on `<html>`. */
14
+ mode?: ThemeMode;
15
+ };
16
+
17
+ /** Renders an OpenUI Lang fenced code block (```openui ... ```). */
18
+ export const OpenUIBlock = memo(
19
+ function OpenUIBlock({ source, isStreaming, className, mode: modeProp }: OpenUIBlockProps) {
20
+ const detectedMode = useClassDarkMode();
21
+ const mode = modeProp ?? detectedMode;
22
+ const scopeClass = `aui-openui-theme-${useId().replace(/:/g, "")}`;
23
+
24
+ return (
25
+ <ThemeProvider mode={mode} cssSelector={`.${scopeClass}`}>
26
+ <div
27
+ data-slot="openui-block"
28
+ className={cn("aui-openui-block my-3", scopeClass, className)}
29
+ style={{ contain: "layout paint" }}
30
+ >
31
+ <Renderer
32
+ response={source}
33
+ library={openuiLibrary}
34
+ isStreaming={!!isStreaming}
35
+ />
36
+ </div>
37
+ </ThemeProvider>
38
+ );
39
+ },
40
+ (prev, next) =>
41
+ prev.source === next.source &&
42
+ !!prev.isStreaming === !!next.isStreaming &&
43
+ prev.mode === next.mode,
44
+ );
45
+
46
+ declare module "../theme/SlotsProvider.js" {
47
+ interface AtomSlots {
48
+ OpenUIBlock: typeof OpenUIBlock;
49
+ }
50
+ }
@@ -0,0 +1,52 @@
1
+ import type { ReactNode } from "react";
2
+ import { BrainIcon, ChevronDownIcon } from "lucide-react";
3
+
4
+ import { cn } from "./lib/cn.js";
5
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./primitives/Collapsible.js";
6
+
7
+ export type ReasoningCardProps = {
8
+ streaming: boolean;
9
+ expanded: boolean;
10
+ onToggle: () => void;
11
+ children: ReactNode;
12
+ className?: string;
13
+ };
14
+
15
+ export function ReasoningCard({ streaming, expanded, onToggle, children, className }: ReasoningCardProps) {
16
+ return (
17
+ <Collapsible
18
+ data-slot="reasoning-card"
19
+ open={expanded}
20
+ onOpenChange={onToggle}
21
+ className={cn("aui-reasoning-card w-full rounded-lg border px-3 py-2", className)}
22
+ >
23
+ <CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex max-w-[75%] origin-left items-center gap-2 py-1.5 text-sm transition-[color,scale] active:scale-[0.98]">
24
+ <BrainIcon className="size-4 shrink-0" />
25
+ <span className="leading-none tabular-nums">
26
+ Reasoning
27
+ {streaming && (
28
+ <span aria-hidden className="shimmer pointer-events-none">
29
+ {" "}
30
+
31
+ </span>
32
+ )}
33
+ </span>
34
+ <ChevronDownIcon
35
+ className={cn(
36
+ "mt-0.5 size-4 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.32,0.72,0,1)]",
37
+ expanded ? "rotate-0" : "-rotate-90",
38
+ )}
39
+ />
40
+ </CollapsibleTrigger>
41
+ <CollapsibleContent className="text-muted-foreground max-h-64 overflow-y-auto pt-2 pb-1 pl-6 text-sm leading-relaxed">
42
+ {children}
43
+ </CollapsibleContent>
44
+ </Collapsible>
45
+ );
46
+ }
47
+
48
+ declare module "../theme/SlotsProvider.js" {
49
+ interface AtomSlots {
50
+ ReasoningCard: typeof ReasoningCard;
51
+ }
52
+ }