@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
@@ -0,0 +1,205 @@
1
+ "use client";
2
+
3
+ import { Badge } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import { ArrowRightIcon, MinusIcon, PackageIcon, PlusIcon } from "lucide-react";
6
+ import type { HTMLAttributes } from "react";
7
+ import { createContext, useContext, useMemo } from "react";
8
+
9
+ type ChangeType = "major" | "minor" | "patch" | "added" | "removed";
10
+
11
+ interface PackageInfoContextType {
12
+ name: string;
13
+ currentVersion?: string;
14
+ newVersion?: string;
15
+ changeType?: ChangeType;
16
+ }
17
+
18
+ const PackageInfoContext = createContext<PackageInfoContextType>({
19
+ name: "",
20
+ });
21
+
22
+ export type PackageInfoHeaderProps = HTMLAttributes<HTMLDivElement>;
23
+
24
+ export const PackageInfoHeader = ({ className, children, ...props }: PackageInfoHeaderProps) => (
25
+ <div className={cn("flex items-center justify-between gap-2", className)} {...props}>
26
+ {children}
27
+ </div>
28
+ );
29
+
30
+ export type PackageInfoNameProps = HTMLAttributes<HTMLDivElement>;
31
+
32
+ export const PackageInfoName = ({ className, children, ...props }: PackageInfoNameProps) => {
33
+ const { name } = useContext(PackageInfoContext);
34
+
35
+ return (
36
+ <div className={cn("flex items-center gap-2", className)} {...props}>
37
+ <PackageIcon className="size-4 text-muted-foreground" />
38
+ <span className="font-medium font-mono text-sm">{children ?? name}</span>
39
+ </div>
40
+ );
41
+ };
42
+
43
+ const changeTypeStyles: Record<ChangeType, string> = {
44
+ added: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
45
+ major: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
46
+ minor: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
47
+ patch: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
48
+ removed: "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
49
+ };
50
+
51
+ const changeTypeIcons: Record<ChangeType, React.ReactNode> = {
52
+ added: <PlusIcon className="size-3" />,
53
+ major: <ArrowRightIcon className="size-3" />,
54
+ minor: <ArrowRightIcon className="size-3" />,
55
+ patch: <ArrowRightIcon className="size-3" />,
56
+ removed: <MinusIcon className="size-3" />,
57
+ };
58
+
59
+ export type PackageInfoChangeTypeProps = HTMLAttributes<HTMLDivElement>;
60
+
61
+ export const PackageInfoChangeType = ({
62
+ className,
63
+ children,
64
+ ...props
65
+ }: PackageInfoChangeTypeProps) => {
66
+ const { changeType } = useContext(PackageInfoContext);
67
+
68
+ if (!changeType) {
69
+ return null;
70
+ }
71
+
72
+ return (
73
+ <Badge
74
+ className={cn("gap-1 text-xs capitalize", changeTypeStyles[changeType], className)}
75
+ variant="secondary"
76
+ {...props}
77
+ >
78
+ {changeTypeIcons[changeType]}
79
+ {children ?? changeType}
80
+ </Badge>
81
+ );
82
+ };
83
+
84
+ export type PackageInfoVersionProps = HTMLAttributes<HTMLDivElement>;
85
+
86
+ export const PackageInfoVersion = ({ className, children, ...props }: PackageInfoVersionProps) => {
87
+ const { currentVersion, newVersion } = useContext(PackageInfoContext);
88
+
89
+ if (!(currentVersion || newVersion)) {
90
+ return null;
91
+ }
92
+
93
+ return (
94
+ <div
95
+ className={cn(
96
+ "mt-2 flex items-center gap-2 font-mono text-muted-foreground text-sm",
97
+ className,
98
+ )}
99
+ {...props}
100
+ >
101
+ {children ?? (
102
+ <>
103
+ {currentVersion && <span>{currentVersion}</span>}
104
+ {currentVersion && newVersion && <ArrowRightIcon className="size-3" />}
105
+ {newVersion && <span className="font-medium text-foreground">{newVersion}</span>}
106
+ </>
107
+ )}
108
+ </div>
109
+ );
110
+ };
111
+
112
+ export type PackageInfoProps = HTMLAttributes<HTMLDivElement> & {
113
+ name: string;
114
+ currentVersion?: string;
115
+ newVersion?: string;
116
+ changeType?: ChangeType;
117
+ };
118
+
119
+ export const PackageInfo = ({
120
+ name,
121
+ currentVersion,
122
+ newVersion,
123
+ changeType,
124
+ className,
125
+ children,
126
+ ...props
127
+ }: PackageInfoProps) => {
128
+ const contextValue = useMemo(
129
+ () => ({ changeType, currentVersion, name, newVersion }),
130
+ [changeType, currentVersion, name, newVersion],
131
+ );
132
+
133
+ return (
134
+ <PackageInfoContext.Provider value={contextValue}>
135
+ <div className={cn("rounded-lg border bg-background p-4", className)} {...props}>
136
+ {children ?? (
137
+ <>
138
+ <PackageInfoHeader>
139
+ <PackageInfoName />
140
+ {changeType && <PackageInfoChangeType />}
141
+ </PackageInfoHeader>
142
+ {(currentVersion || newVersion) && <PackageInfoVersion />}
143
+ </>
144
+ )}
145
+ </div>
146
+ </PackageInfoContext.Provider>
147
+ );
148
+ };
149
+
150
+ export type PackageInfoDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
151
+
152
+ export const PackageInfoDescription = ({
153
+ className,
154
+ children,
155
+ ...props
156
+ }: PackageInfoDescriptionProps) => (
157
+ <p className={cn("mt-2 text-muted-foreground text-sm", className)} {...props}>
158
+ {children}
159
+ </p>
160
+ );
161
+
162
+ export type PackageInfoContentProps = HTMLAttributes<HTMLDivElement>;
163
+
164
+ export const PackageInfoContent = ({ className, children, ...props }: PackageInfoContentProps) => (
165
+ <div className={cn("mt-3 border-t pt-3", className)} {...props}>
166
+ {children}
167
+ </div>
168
+ );
169
+
170
+ export type PackageInfoDependenciesProps = HTMLAttributes<HTMLDivElement>;
171
+
172
+ export const PackageInfoDependencies = ({
173
+ className,
174
+ children,
175
+ ...props
176
+ }: PackageInfoDependenciesProps) => (
177
+ <div className={cn("space-y-2", className)} {...props}>
178
+ <span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
179
+ Dependencies
180
+ </span>
181
+ <div className="space-y-1">{children}</div>
182
+ </div>
183
+ );
184
+
185
+ export type PackageInfoDependencyProps = HTMLAttributes<HTMLDivElement> & {
186
+ name: string;
187
+ version?: string;
188
+ };
189
+
190
+ export const PackageInfoDependency = ({
191
+ name,
192
+ version,
193
+ className,
194
+ children,
195
+ ...props
196
+ }: PackageInfoDependencyProps) => (
197
+ <div className={cn("flex items-center justify-between text-sm", className)} {...props}>
198
+ {children ?? (
199
+ <>
200
+ <span className="font-mono text-muted-foreground">{name}</span>
201
+ {version && <span className="font-mono text-xs">{version}</span>}
202
+ </>
203
+ )}
204
+ </div>
205
+ );
package/src/panel.tsx ADDED
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import type { Panel as PanelPrimitive } from "@xyflow/react";
4
+ import type { ComponentProps } from "react";
5
+ import { Suspense } from "react";
6
+
7
+ import { lazyFlowPart } from "./_flow-lazy";
8
+
9
+ export type PanelProps = ComponentProps<typeof PanelPrimitive>;
10
+
11
+ /** React Flow lives behind a dynamic import — see `_flow-lazy.ts` and ADR 0019. */
12
+ const PanelImpl = lazyFlowPart<PanelProps>((m) => m.Panel);
13
+
14
+ export const Panel = (props: PanelProps) => (
15
+ // An overlay inside an already-loaded `<Canvas>`, so there is no box to
16
+ // reserve — the engine chunk it needs is the one the canvas already fetched.
17
+ <Suspense fallback={null}>
18
+ <PanelImpl {...props} />
19
+ </Suspense>
20
+ );
@@ -0,0 +1,258 @@
1
+ /**
2
+ * part-groups.ts — Pure, framework-free part-grouping engine.
3
+ *
4
+ * Derives collapsible reasoning/tool traces from an ordered list of message
5
+ * parts. This is a CLIENT-SIDE VIEW TRANSFORM — never a model output format.
6
+ * Adapted from assistant-ui's grouping design onto this library's idiom.
7
+ *
8
+ * `groupBy(part, context)` maps each part to a group-key path (`group-…`);
9
+ * ADJACENT parts sharing a key path coalesce into synthetic group nodes
10
+ * (`{ type: "group-…", status, indices }`); multi-element paths nest. The
11
+ * `group-` namespace is reserved so a group key can never collide with a real
12
+ * part `type`.
13
+ */
14
+
15
+ /** A reserved group-key. The `group-` prefix can't collide with real part types. */
16
+ export type GroupKey = `group-${string}`;
17
+
18
+ /** Rolled-up status of a group (any member running → the group is running). */
19
+ export type GroupStatus = "pending" | "running" | "complete" | "error";
20
+
21
+ /** The minimum a groupable part must expose. */
22
+ export interface GroupablePart {
23
+ type: string;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ /** Context passed to `groupBy` for each part. */
28
+ export interface GroupByContext<T extends GroupablePart> {
29
+ index: number;
30
+ parts: readonly T[];
31
+ }
32
+
33
+ /** Maps a part to its group-key path (`[]` = not grouped, a top-level leaf). */
34
+ export type GroupBy<T extends GroupablePart> = (
35
+ part: T,
36
+ context: GroupByContext<T>,
37
+ ) => readonly GroupKey[];
38
+
39
+ /** Extract a part's status; return `undefined` to fall back to the default. */
40
+ export type GetPartStatus<T extends GroupablePart> = (part: T) => GroupStatus | undefined;
41
+
42
+ /** The synthetic node produced for a coalesced group. */
43
+ export interface PartGroupNode {
44
+ type: GroupKey;
45
+ status: GroupStatus;
46
+ /** Original indices of every leaf part under this group (recursive). */
47
+ indices: number[];
48
+ }
49
+
50
+ /** One node in the grouped tree — a leaf part or a synthetic group. */
51
+ export type GroupedNode<T extends GroupablePart> =
52
+ | { kind: "leaf"; key: string; index: number; part: T }
53
+ | { kind: "group"; key: string; group: PartGroupNode; children: GroupedNode<T>[] };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Default status extraction
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const RUNNING_STATES = new Set([
60
+ "input-streaming",
61
+ "input-available",
62
+ "approval-requested",
63
+ "approval-responded",
64
+ "calling",
65
+ "streaming",
66
+ "in-progress",
67
+ ]);
68
+ const ERROR_STATES = new Set(["output-error", "error", "failed"]);
69
+ const COMPLETE_STATES = new Set(["output-available", "output-denied", "done", "complete"]);
70
+
71
+ /**
72
+ * Best-effort status from common AI-SDK part shapes (tool `state`, reasoning
73
+ * `state`, `isStreaming`). Overridable via `getStatus`.
74
+ */
75
+ export function defaultPartStatus(part: GroupablePart): GroupStatus {
76
+ if (part.isStreaming === true) return "running";
77
+ const state = typeof part.state === "string" ? part.state : undefined;
78
+ if (state) {
79
+ if (ERROR_STATES.has(state)) return "error";
80
+ if (RUNNING_STATES.has(state)) return "running";
81
+ if (COMPLETE_STATES.has(state)) return "complete";
82
+ }
83
+ if (part.errorText || part.error) return "error";
84
+ return "complete";
85
+ }
86
+
87
+ /** Roll several member statuses up into one group status. */
88
+ export function rollupStatus(statuses: readonly GroupStatus[]): GroupStatus {
89
+ if (statuses.some((s) => s === "running")) return "running";
90
+ if (statuses.some((s) => s === "error")) return "error";
91
+ if (statuses.some((s) => s === "pending")) return "pending";
92
+ return "complete";
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Stable identity keys
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** Stable leaf key so streaming rows don't remount (prefers a part id). */
100
+ function leafKey(part: GroupablePart, index: number): string {
101
+ const id =
102
+ (typeof part.id === "string" && part.id) ||
103
+ (typeof part.toolCallId === "string" && part.toolCallId) ||
104
+ null;
105
+ return id ?? `${part.type}#${index}`;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Tree building
110
+ // ---------------------------------------------------------------------------
111
+
112
+ interface Entry<T extends GroupablePart> {
113
+ part: T;
114
+ index: number;
115
+ path: readonly GroupKey[];
116
+ }
117
+
118
+ function buildLevel<T extends GroupablePart>(
119
+ entries: Entry<T>[],
120
+ statusOf: (part: T) => GroupStatus,
121
+ ): GroupedNode<T>[] {
122
+ const out: GroupedNode<T>[] = [];
123
+ let i = 0;
124
+ while (i < entries.length) {
125
+ const entry = entries[i]!;
126
+ if (entry.path.length === 0) {
127
+ out.push({
128
+ kind: "leaf",
129
+ key: leafKey(entry.part, entry.index),
130
+ index: entry.index,
131
+ part: entry.part,
132
+ });
133
+ i += 1;
134
+ continue;
135
+ }
136
+
137
+ // Coalesce the adjacent run of entries sharing this level's key.
138
+ const key0 = entry.path[0]!;
139
+ const run: Entry<T>[] = [];
140
+ let j = i;
141
+ while (j < entries.length && entries[j]!.path.length > 0 && entries[j]!.path[0] === key0) {
142
+ run.push(entries[j]!);
143
+ j += 1;
144
+ }
145
+
146
+ // Recurse with each member's path shifted one level down (nesting).
147
+ const childEntries: Entry<T>[] = run.map((r) => ({
148
+ part: r.part,
149
+ index: r.index,
150
+ path: r.path.slice(1),
151
+ }));
152
+ const children = buildLevel(childEntries, statusOf);
153
+ const indices = run.map((r) => r.index);
154
+ const status = rollupStatus(run.map((r) => statusOf(r.part)));
155
+
156
+ out.push({
157
+ kind: "group",
158
+ // First member index keeps the group key stable across append-only streams.
159
+ key: `${key0}@${run[0]!.index}`,
160
+ group: { type: key0, status, indices },
161
+ children,
162
+ });
163
+ i = j;
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Build the grouped tree from an ordered part list. Pure + deterministic —
170
+ * never throws. Adjacent parts with a shared group-key path coalesce; multi-key
171
+ * paths nest; ungrouped parts (path `[]`) stay as top-level leaves.
172
+ */
173
+ export function buildPartGroups<T extends GroupablePart>(
174
+ parts: readonly T[],
175
+ groupBy: GroupBy<T>,
176
+ getStatus?: GetPartStatus<T>,
177
+ ): GroupedNode<T>[] {
178
+ const statusOf = (part: T): GroupStatus => getStatus?.(part) ?? defaultPartStatus(part);
179
+ const entries: Entry<T>[] = parts.map((part, index) => {
180
+ let path: readonly GroupKey[] = [];
181
+ try {
182
+ path = groupBy(part, { index, parts }) ?? [];
183
+ } catch {
184
+ path = []; // a throwing groupBy must not break the transcript
185
+ }
186
+ return { part, index, path };
187
+ });
188
+ return buildLevel(entries, statusOf);
189
+ }
190
+
191
+ /**
192
+ * A cheap fingerprint of the parts' identity + type + status. Feed it to a
193
+ * `useMemo` so the grouped tree only rebuilds when something structural
194
+ * changed — streaming token updates that don't change status won't churn it.
195
+ */
196
+ export function partsFingerprint<T extends GroupablePart>(
197
+ parts: readonly T[],
198
+ getStatus?: GetPartStatus<T>,
199
+ ): string {
200
+ return parts
201
+ .map((part, index) => {
202
+ const status = getStatus?.(part) ?? defaultPartStatus(part);
203
+ return `${leafKey(part, index)}:${part.type}:${status}`;
204
+ })
205
+ .join("|");
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // groupPartByType — the default grouping strategy + classification hint
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /** Whether a part folds into a trace (`inline`) or always stands alone. */
213
+ export type PartDisplay = "inline" | "standalone";
214
+
215
+ export interface GroupPartByTypeOptions<T extends GroupablePart> {
216
+ /** Group key for the collapsible trace. @default "group-work" */
217
+ groupKey?: GroupKey;
218
+ /** Part types folded into the trace. @default reasoning + tool-* + dynamic-tool + step-start */
219
+ inline?: (string | RegExp)[];
220
+ /** Part types forced standalone (never folded). @default approval / human / confirmation */
221
+ standalone?: (string | RegExp)[];
222
+ /** Explicit per-part classification; overrides the type lists. */
223
+ classify?: (part: T) => PartDisplay | undefined;
224
+ }
225
+
226
+ const DEFAULT_INLINE: (string | RegExp)[] = [/^reasoning$/, /^tool-/, "dynamic-tool", "step-start"];
227
+ const DEFAULT_STANDALONE: (string | RegExp)[] = [/approval/i, /human/i, "confirmation"];
228
+
229
+ function matchesAny(type: string, patterns: (string | RegExp)[]): boolean {
230
+ return patterns.some((p) => (typeof p === "string" ? p === type : p.test(type)));
231
+ }
232
+
233
+ /**
234
+ * Build a `groupBy` that folds reasoning/tool parts into ONE collapsible trace
235
+ * while forcing human/approval-style parts to stand alone — an interaction card
236
+ * must never fold into a thinking accordion. The classification maps to the
237
+ * `display: "inline" | "standalone"` hint.
238
+ */
239
+ export function groupPartByType<T extends GroupablePart>(
240
+ options: GroupPartByTypeOptions<T> = {},
241
+ ): GroupBy<T> {
242
+ const groupKey = options.groupKey ?? "group-work";
243
+ const inline = options.inline ?? DEFAULT_INLINE;
244
+ const standalone = options.standalone ?? DEFAULT_STANDALONE;
245
+ return (part) => {
246
+ const explicit = options.classify?.(part);
247
+ if (explicit === "standalone") return [];
248
+ if (explicit === "inline") return [groupKey];
249
+ if (matchesAny(part.type, standalone)) return []; // forced standalone
250
+ if (matchesAny(part.type, inline)) return [groupKey];
251
+ return []; // ungrouped (text, sources, files, …) stays top-level
252
+ };
253
+ }
254
+
255
+ /** Type guard: is this render subject a synthetic group node? */
256
+ export function isGroupNode(part: { type: string }): part is PartGroupNode {
257
+ return part.type.startsWith("group-");
258
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Persona artwork sources — a plain data module shared by `persona.tsx` (the
3
+ * public shell) and `_persona-rive.tsx` (the lazily-loaded Rive half), so
4
+ * neither has to import the other.
5
+ */
6
+
7
+ export type PersonaState = "idle" | "listening" | "thinking" | "speaking" | "asleep";
8
+
9
+ export interface PersonaSource {
10
+ /** The artwork exposes a colour view-model that follows the light/dark theme. */
11
+ dynamicColor: boolean;
12
+ /** The artwork has a Rive view-model (vs. a plain state machine). */
13
+ hasModel: boolean;
14
+ /** Absolute URL of the `.riv` artwork. */
15
+ source: string;
16
+ }
17
+
18
+ /**
19
+ * Where the shipped `.riv` artwork is fetched from.
20
+ *
21
+ * ⚠️ These are REMOTE origins fetched at runtime by the Rive runtime — a
22
+ * deployment with a restrictive `connect-src` blocks them, and the component
23
+ * falls back to a token-driven orb. Self-host the six files and pass
24
+ * `<Persona src={…}>` to point at your own copies. See `docs/CSP-AND-NETWORK.md`.
25
+ */
26
+ export const PERSONA_SOURCES = {
27
+ command: {
28
+ dynamicColor: true,
29
+ hasModel: true,
30
+ source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
31
+ },
32
+ glint: {
33
+ dynamicColor: true,
34
+ hasModel: true,
35
+ source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
36
+ },
37
+ halo: {
38
+ dynamicColor: true,
39
+ hasModel: true,
40
+ source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
41
+ },
42
+ mana: {
43
+ dynamicColor: false,
44
+ hasModel: true,
45
+ source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
46
+ },
47
+ obsidian: {
48
+ dynamicColor: true,
49
+ hasModel: true,
50
+ source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
51
+ },
52
+ opal: {
53
+ dynamicColor: false,
54
+ hasModel: false,
55
+ source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
56
+ },
57
+ } as const satisfies Record<string, PersonaSource>;
58
+
59
+ export type PersonaVariant = keyof typeof PERSONA_SOURCES;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Persona — the animated agent presence.
3
+ *
4
+ * The artwork is a Rive `.riv` file fetched from a **remote origin** at runtime,
5
+ * and the WebGL2 runtime is loaded lazily (ADR 0019). Both facts matter for
6
+ * deployment: under a restrictive `connect-src` the fetch is blocked and the
7
+ * component falls back to a token-driven orb rather than an empty box. See
8
+ * `docs/CSP-AND-NETWORK.md`, and pass `src` to self-host the artwork.
9
+ */
10
+ import type { Meta, StoryObj } from "@storybook/react-vite";
11
+
12
+ import { Persona, type PersonaState } from "./persona";
13
+
14
+ /**
15
+ * The visible caption below mirrors the visually-hidden `role="status"` text
16
+ * `Persona` announces to assistive tech for each state (see `PersonaProps.statusLabel`).
17
+ */
18
+ const STATUS_LABELS: Record<PersonaState, string> = {
19
+ idle: "Assistant idle",
20
+ listening: "Assistant listening",
21
+ thinking: "Assistant thinking…",
22
+ speaking: "Assistant speaking",
23
+ asleep: "Assistant asleep",
24
+ };
25
+
26
+ const meta = {
27
+ title: "AI/Persona",
28
+ component: Persona,
29
+ parameters: {
30
+ layout: "centered",
31
+ docs: {
32
+ description: {
33
+ component:
34
+ "The animated agent presence. Artwork is a remote Rive `.riv` file and the WebGL2 runtime loads lazily, so the first frame is the fallback orb. Pass `src` to self-host, `fallback` to replace the placeholder. Blocked/offline loads render the fallback instead of an empty box.",
35
+ },
36
+ },
37
+ },
38
+ args: { state: "idle" },
39
+ tags: ["autodocs"],
40
+ } satisfies Meta<typeof Persona>;
41
+
42
+ export default meta;
43
+ type Story = StoryObj<typeof meta>;
44
+
45
+ /**
46
+ * The offline / CSP-blocked state — and the default story on purpose.
47
+ *
48
+ * `src` points at a URL that cannot resolve, so this renders the fallback orb
49
+ * without reaching the network. Keeping the DEFAULT story offline means CI and
50
+ * the visual sweep never depend on a third-party origin being up.
51
+ */
52
+ export const Default: Story = {
53
+ args: { src: "about:blank#persona-offline" },
54
+ };
55
+
56
+ /** An explicit custom fallback, for brands that want their own resting mark. */
57
+ export const CustomFallback: Story = {
58
+ args: {
59
+ src: "about:blank#persona-offline",
60
+ fallback: (
61
+ <div className="grid size-16 place-items-center rounded-full border-2 border-primary/40 border-dashed">
62
+ <span className="text-meta text-muted-foreground">AI</span>
63
+ </div>
64
+ ),
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Every conversational state, offline.
70
+ *
71
+ * With live artwork these animate; here they all show the fallback, which is
72
+ * exactly what a locked-down deployment sees. The caption under each orb is
73
+ * the SAME text `Persona` announces to assistive tech via its default
74
+ * `statusLabel` — shown visibly here purely for documentation.
75
+ */
76
+ export const States: Story = {
77
+ render: (args) => (
78
+ <div className="flex items-center gap-6">
79
+ {(["idle", "listening", "thinking", "speaking", "asleep"] as const).map((state) => (
80
+ <figure className="flex flex-col items-center gap-2" key={state}>
81
+ <Persona {...args} src="about:blank#persona-offline" state={state} />
82
+ <figcaption className="text-meta text-muted-foreground">
83
+ {state}
84
+ <br />
85
+ <span className="italic">{STATUS_LABELS[state]}</span>
86
+ </figcaption>
87
+ </figure>
88
+ ))}
89
+ </div>
90
+ ),
91
+ };
92
+
93
+ /**
94
+ * Opt out of the announcement with `statusLabel={null}` — for a surface that
95
+ * already renders its own `role="status"` line elsewhere (e.g. next to a
96
+ * chat composer) and doesn't want AT told twice.
97
+ */
98
+ export const NoStatusAnnouncement: Story = {
99
+ args: { src: "about:blank#persona-offline", state: "thinking", statusLabel: null },
100
+ };
101
+
102
+ /**
103
+ * Live artwork from the default remote origin.
104
+ *
105
+ * ⚠️ This story REACHES THE NETWORK (`*.public.blob.vercel-storage.com`). It is
106
+ * the only one that does; it exists so the real animation can be reviewed, and
107
+ * it degrades to the fallback orb when the origin is unreachable.
108
+ */
109
+ export const LiveRemoteArtwork: Story = {
110
+ args: { state: "thinking", variant: "obsidian" },
111
+ };