@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,170 @@
1
+ "use client";
2
+
3
+ import { Skeleton } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import type { Experimental_SpeechResult as SpeechResult } from "ai";
6
+ import type {
7
+ MediaControlBar,
8
+ MediaController,
9
+ MediaDurationDisplay,
10
+ MediaMuteButton,
11
+ MediaPlayButton,
12
+ MediaSeekBackwardButton,
13
+ MediaSeekForwardButton,
14
+ MediaTimeDisplay,
15
+ MediaTimeRange,
16
+ MediaVolumeRange,
17
+ } from "media-chrome/react";
18
+ import type { ComponentProps, ComponentType } from "react";
19
+ import { lazy, Suspense } from "react";
20
+
21
+ /**
22
+ * media-chrome lives behind a dynamic import — it declares no `sideEffects`, so
23
+ * a static import would put the whole custom-element library in every consumer's
24
+ * entry chunk, `AudioPlayer` rendered or not. Every value import lives in
25
+ * `_audio-player-media-chrome.tsx`; the element types above are TYPE imports and
26
+ * erase. See ADR 0019 and `pnpm heavy-deps:check`.
27
+ *
28
+ * All ten parts resolve from the SAME module specifier, so they share one chunk:
29
+ * once `AudioPlayer` has loaded it, the controls inside it resolve from cache.
30
+ */
31
+ type MediaChromeModule = typeof import("./_audio-player-media-chrome");
32
+
33
+ const lazyPart = <P,>(pick: (module: MediaChromeModule) => ComponentType<P>) =>
34
+ lazy(() => import("./_audio-player-media-chrome").then((m) => ({ default: pick(m) })));
35
+
36
+ export type AudioPlayerProps = Omit<ComponentProps<typeof MediaController>, "audio">;
37
+
38
+ const AudioPlayerImpl = lazyPart<AudioPlayerProps>((m) => m.AudioPlayer);
39
+
40
+ export const AudioPlayer = ({ className, ...props }: AudioPlayerProps) => (
41
+ <Suspense fallback={<Skeleton className={cn("h-10 w-full rounded-md", className)} />}>
42
+ <AudioPlayerImpl className={className} data-slot="audio-player" {...props} />
43
+ </Suspense>
44
+ );
45
+
46
+ export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
47
+ (
48
+ | {
49
+ data: SpeechResult["audio"];
50
+ }
51
+ | {
52
+ src: string;
53
+ }
54
+ );
55
+
56
+ export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
57
+ // oxlint-disable-next-line eslint-plugin-jsx-a11y(media-has-caption) -- audio player captions are provided by consumer
58
+ <audio
59
+ data-slot="audio-player-element"
60
+ slot="media"
61
+ src={"src" in props ? props.src : `data:${props.data.mediaType};base64,${props.data.base64}`}
62
+ {...props}
63
+ />
64
+ );
65
+
66
+ export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
67
+
68
+ const AudioPlayerControlBarImpl = lazyPart<AudioPlayerControlBarProps>(
69
+ (m) => m.AudioPlayerControlBar,
70
+ );
71
+
72
+ export const AudioPlayerControlBar = (props: AudioPlayerControlBarProps) => (
73
+ <Suspense fallback={null}>
74
+ <AudioPlayerControlBarImpl data-slot="audio-player-control-bar" {...props} />
75
+ </Suspense>
76
+ );
77
+
78
+ export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
79
+
80
+ const AudioPlayerPlayButtonImpl = lazyPart<AudioPlayerPlayButtonProps>(
81
+ (m) => m.AudioPlayerPlayButton,
82
+ );
83
+
84
+ export const AudioPlayerPlayButton = (props: AudioPlayerPlayButtonProps) => (
85
+ <Suspense fallback={null}>
86
+ <AudioPlayerPlayButtonImpl data-slot="audio-player-play-button" {...props} />
87
+ </Suspense>
88
+ );
89
+
90
+ export type AudioPlayerSeekBackwardButtonProps = ComponentProps<typeof MediaSeekBackwardButton>;
91
+
92
+ const AudioPlayerSeekBackwardButtonImpl = lazyPart<AudioPlayerSeekBackwardButtonProps>(
93
+ (m) => m.AudioPlayerSeekBackwardButton,
94
+ );
95
+
96
+ export const AudioPlayerSeekBackwardButton = (props: AudioPlayerSeekBackwardButtonProps) => (
97
+ <Suspense fallback={null}>
98
+ <AudioPlayerSeekBackwardButtonImpl data-slot="audio-player-seek-backward-button" {...props} />
99
+ </Suspense>
100
+ );
101
+
102
+ export type AudioPlayerSeekForwardButtonProps = ComponentProps<typeof MediaSeekForwardButton>;
103
+
104
+ const AudioPlayerSeekForwardButtonImpl = lazyPart<AudioPlayerSeekForwardButtonProps>(
105
+ (m) => m.AudioPlayerSeekForwardButton,
106
+ );
107
+
108
+ export const AudioPlayerSeekForwardButton = (props: AudioPlayerSeekForwardButtonProps) => (
109
+ <Suspense fallback={null}>
110
+ <AudioPlayerSeekForwardButtonImpl data-slot="audio-player-seek-forward-button" {...props} />
111
+ </Suspense>
112
+ );
113
+
114
+ export type AudioPlayerTimeDisplayProps = ComponentProps<typeof MediaTimeDisplay>;
115
+
116
+ const AudioPlayerTimeDisplayImpl = lazyPart<AudioPlayerTimeDisplayProps>(
117
+ (m) => m.AudioPlayerTimeDisplay,
118
+ );
119
+
120
+ export const AudioPlayerTimeDisplay = (props: AudioPlayerTimeDisplayProps) => (
121
+ <Suspense fallback={null}>
122
+ <AudioPlayerTimeDisplayImpl data-slot="audio-player-time-display" {...props} />
123
+ </Suspense>
124
+ );
125
+
126
+ export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
127
+
128
+ const AudioPlayerTimeRangeImpl = lazyPart<AudioPlayerTimeRangeProps>((m) => m.AudioPlayerTimeRange);
129
+
130
+ export const AudioPlayerTimeRange = (props: AudioPlayerTimeRangeProps) => (
131
+ <Suspense fallback={null}>
132
+ <AudioPlayerTimeRangeImpl data-slot="audio-player-time-range" {...props} />
133
+ </Suspense>
134
+ );
135
+
136
+ export type AudioPlayerDurationDisplayProps = ComponentProps<typeof MediaDurationDisplay>;
137
+
138
+ const AudioPlayerDurationDisplayImpl = lazyPart<AudioPlayerDurationDisplayProps>(
139
+ (m) => m.AudioPlayerDurationDisplay,
140
+ );
141
+
142
+ export const AudioPlayerDurationDisplay = (props: AudioPlayerDurationDisplayProps) => (
143
+ <Suspense fallback={null}>
144
+ <AudioPlayerDurationDisplayImpl data-slot="audio-player-duration-display" {...props} />
145
+ </Suspense>
146
+ );
147
+
148
+ export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
149
+
150
+ const AudioPlayerMuteButtonImpl = lazyPart<AudioPlayerMuteButtonProps>(
151
+ (m) => m.AudioPlayerMuteButton,
152
+ );
153
+
154
+ export const AudioPlayerMuteButton = (props: AudioPlayerMuteButtonProps) => (
155
+ <Suspense fallback={null}>
156
+ <AudioPlayerMuteButtonImpl data-slot="audio-player-mute-button" {...props} />
157
+ </Suspense>
158
+ );
159
+
160
+ export type AudioPlayerVolumeRangeProps = ComponentProps<typeof MediaVolumeRange>;
161
+
162
+ const AudioPlayerVolumeRangeImpl = lazyPart<AudioPlayerVolumeRangeProps>(
163
+ (m) => m.AudioPlayerVolumeRange,
164
+ );
165
+
166
+ export const AudioPlayerVolumeRange = (props: AudioPlayerVolumeRangeProps) => (
167
+ <Suspense fallback={null}>
168
+ <AudioPlayerVolumeRangeImpl data-slot="audio-player-volume-range" {...props} />
169
+ </Suspense>
170
+ );
@@ -0,0 +1,83 @@
1
+ /**
2
+ * AI Composer — the standard chat-input block.
3
+ *
4
+ * This block is now a thin showcase of the real `<Composer />` component (the
5
+ * canonical brand-ui AI chat input). The double-card chrome — a status strip
6
+ * wrapping a recessed `PromptInput` well (sharp top, theme-rounded bottom),
7
+ * model pill, voice, circular send, suggestion chips — lives in `composer.tsx`,
8
+ * not here, so every surface gets the same input by importing it. `Composer`'s
9
+ * `tone` prop (#254) picks the outer/inner arrangement: `"surface"` (default)
10
+ * is the outer `bg-card` frame this block ships everywhere else; `"card"` is
11
+ * the tinted-outer/distinct-inner arrangement the reference exemplar calls
12
+ * for — see `DoubleCardToned` below. Semantic tokens only; theme-aware radii;
13
+ * reads in all themes.
14
+ */
15
+ import type { Meta, StoryObj } from "@storybook/react-vite";
16
+
17
+ import { ChatGreeting } from "./chat-greeting";
18
+ import { Composer } from "./composer";
19
+
20
+ const SUGGESTIONS = ["Summary", "Code", "Design", "Research"];
21
+
22
+ /** Empty/first-run chat state: greeting + composer + suggestion chips. */
23
+ function AiComposerScene() {
24
+ return (
25
+ <div className="grid min-h-[28rem] place-items-center bg-background px-4 py-10 text-foreground">
26
+ <div className="w-full max-w-2xl">
27
+ <ChatGreeting title="Good morning, Avery" subtitle="How can I" accent="assist you today?" />
28
+
29
+ <Composer onSubmit={() => undefined} suggestions={SUGGESTIONS} />
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ const meta = {
36
+ title: "Patterns/Blocks/AI Composer",
37
+ parameters: {
38
+ layout: "fullscreen",
39
+ docs: {
40
+ description: {
41
+ component:
42
+ "The standard AI chat-input block — a thin showcase of the real <Composer /> component: a rounded two-tone double card (a status strip around a recessed PromptInput well with a sharp top and theme-rounded bottom), a model pill, a circular send, and suggestion chips under a centered greeting. Composer's `tone` prop (#254) picks the arrangement — `surface` (default, this block's Default/ComposerOnly) is the outer bg-card frame; `card` (see DoubleCardToned) is the tinted-outer/distinct-inner frame the reference exemplar calls for. Built on the real @elabs-ai/components-ai PromptInput; semantic tokens only; reads in all themes. Reach for <Composer /> instead of hand-rolling this.",
43
+ },
44
+ },
45
+ },
46
+ tags: ["autodocs"],
47
+ } satisfies Meta;
48
+
49
+ export default meta;
50
+ type Story = StoryObj<typeof meta>;
51
+
52
+ /** The full empty-state scene (greeting + composer + chips). */
53
+ export const Default: Story = { render: () => <AiComposerScene /> };
54
+
55
+ /** Just the double-card composer, to drop into a ChatShell footer. */
56
+ export const ComposerOnly: Story = {
57
+ render: () => (
58
+ <div className="bg-background p-6">
59
+ <div className="mx-auto max-w-2xl">
60
+ <Composer onSubmit={() => undefined} />
61
+ </div>
62
+ </div>
63
+ ),
64
+ };
65
+
66
+ /**
67
+ * The tinted-outer/distinct-inner "double card" (#254) — the look the
68
+ * shadcnuikit ai-chat-v2 exemplar this block was named after actually calls
69
+ * for: `<Composer tone="card" />` swaps the outer frame to `bg-surface-muted`
70
+ * around a `tone="card"` well, instead of the default outer `bg-card` frame
71
+ * `ComposerOnly`/`Default` above ship. Check both themes: the well is
72
+ * raised (lighter than the frame) on light themes, recessed (darker) on
73
+ * dark.
74
+ */
75
+ export const DoubleCardToned: Story = {
76
+ render: () => (
77
+ <div className="bg-background p-6">
78
+ <div className="mx-auto max-w-2xl">
79
+ <Composer tone="card" onSubmit={() => undefined} suggestions={SUGGESTIONS} />
80
+ </div>
81
+ </div>
82
+ ),
83
+ };
@@ -0,0 +1,142 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor, within } from "storybook/test";
3
+
4
+ import { Canvas } from "./canvas";
5
+ import { Controls } from "./controls";
6
+ import { Edge } from "./edge";
7
+ import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle } from "./node";
8
+ import { Panel } from "./panel";
9
+ import { NodeToolbar } from "./toolbar";
10
+
11
+ const meta = {
12
+ title: "AI/Canvas",
13
+ component: Canvas,
14
+ tags: ["autodocs"],
15
+ parameters: {
16
+ layout: "fullscreen",
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "The **in-chat agent workspace graph** — a branded React Flow surface built from " +
21
+ "`Canvas`, `Node`, `Edge`, `Controls`, `Panel` and `NodeToolbar`. For an author-built " +
22
+ "diagram screen reach for `@elabs-ai/components-flow`'s `CanvasShell` " +
23
+ "instead (ADR 0018). React Flow itself is loaded through a dynamic `import()` " +
24
+ "(ADR 0019), so it is not in any consumer's entry chunk; the canvas reserves its " +
25
+ "box with a layout-shaped `Skeleton` until the engine chunk arrives.",
26
+ },
27
+ },
28
+ },
29
+ } satisfies Meta<typeof Canvas>;
30
+
31
+ export default meta;
32
+ type Story = StoryObj<typeof meta>;
33
+
34
+ type StepData = {
35
+ title: string;
36
+ description: string;
37
+ detail: string;
38
+ status: string;
39
+ toolbar?: boolean;
40
+ };
41
+
42
+ const StepNode = ({ data }: { data: StepData }) => (
43
+ <Node handles={{ source: true, target: true }}>
44
+ {data.toolbar ? (
45
+ <NodeToolbar isVisible>
46
+ <span className="text-meta text-muted-foreground">Agent step</span>
47
+ </NodeToolbar>
48
+ ) : null}
49
+ <NodeHeader>
50
+ <NodeTitle>{data.title}</NodeTitle>
51
+ <NodeDescription>{data.description}</NodeDescription>
52
+ </NodeHeader>
53
+ <NodeContent>
54
+ <p className="text-body text-muted-foreground">{data.detail}</p>
55
+ </NodeContent>
56
+ <NodeFooter>
57
+ <span className="text-meta text-muted-foreground">{data.status}</span>
58
+ </NodeFooter>
59
+ </Node>
60
+ );
61
+
62
+ // React Flow requires stable `nodeTypes` / `edgeTypes` identities.
63
+ const nodeTypes = { step: StepNode };
64
+ const edgeTypes = { animated: Edge.Animated, temporary: Edge.Temporary };
65
+
66
+ const nodes = [
67
+ {
68
+ id: "plan",
69
+ type: "step",
70
+ position: { x: 0, y: 0 },
71
+ data: {
72
+ title: "Plan",
73
+ description: "Break the request into steps",
74
+ detail: "Reads the brief, drafts an ordered plan and asks for anything missing.",
75
+ status: "Completed",
76
+ toolbar: true,
77
+ },
78
+ },
79
+ {
80
+ id: "retrieve",
81
+ type: "step",
82
+ position: { x: 460, y: -80 },
83
+ data: {
84
+ title: "Retrieve",
85
+ description: "Gather the supporting sources",
86
+ detail: "Searches the connected spaces and pins the documents it will cite.",
87
+ status: "Running",
88
+ },
89
+ },
90
+ {
91
+ id: "draft",
92
+ type: "step",
93
+ position: { x: 460, y: 220 },
94
+ data: {
95
+ title: "Draft",
96
+ description: "Write the answer",
97
+ detail: "Produces the answer with inline citations back to the retrieved sources.",
98
+ status: "Queued",
99
+ },
100
+ },
101
+ ];
102
+
103
+ const edges = [
104
+ { id: "plan-retrieve", source: "plan", target: "retrieve", type: "animated" },
105
+ { id: "plan-draft", source: "plan", target: "draft", type: "temporary" },
106
+ ];
107
+
108
+ export const Default: Story = {
109
+ render: () => (
110
+ <div className="h-[600px] w-full">
111
+ <Canvas defaultEdges={edges} defaultNodes={nodes} edgeTypes={edgeTypes} nodeTypes={nodeTypes}>
112
+ <Controls />
113
+ <Panel position="top-left">
114
+ <span className="px-2 text-meta text-muted-foreground">Agent workspace</span>
115
+ </Panel>
116
+ </Canvas>
117
+ </div>
118
+ ),
119
+ play: async ({ canvasElement }) => {
120
+ const canvas = within(canvasElement);
121
+ // React Flow arrives in a lazy chunk (#313): the nodes, the animated edge
122
+ // and the overlays are all absent for the first frame, so waiting for them
123
+ // locks in that every part resolves from the one shared boundary chunk.
124
+ await waitFor(
125
+ async () => {
126
+ await expect(canvas.getByText("Plan")).toBeInTheDocument();
127
+ await expect(canvas.getByText("Retrieve")).toBeInTheDocument();
128
+ await expect(canvas.getByText("Agent workspace")).toBeInTheDocument();
129
+ },
130
+ { timeout: 10000 },
131
+ );
132
+
133
+ // The handles are what edges attach to — a node that mounted without them
134
+ // would still show its title, so assert them explicitly.
135
+ await waitFor(() => {
136
+ expect(canvasElement.querySelectorAll(".react-flow__handle").length).toBeGreaterThanOrEqual(
137
+ 6,
138
+ );
139
+ expect(canvasElement.querySelectorAll(".react-flow__edge-path").length).toBe(2);
140
+ });
141
+ },
142
+ };
package/src/canvas.tsx ADDED
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { Skeleton } from "@elabs-ai/components-ui";
4
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
5
+ import type { ReactFlowProps } from "@xyflow/react";
6
+ import type { ReactNode } from "react";
7
+ import { Suspense } from "react";
8
+
9
+ import { lazyFlowPart } from "./_flow-lazy";
10
+
11
+ export type CanvasProps = ReactFlowProps & {
12
+ children?: ReactNode;
13
+ };
14
+
15
+ /**
16
+ * React Flow — and its stylesheet — is reached through a dynamic import: it
17
+ * declares no `sideEffects`, so a static import would put the whole engine in
18
+ * every consumer's entry chunk, a canvas rendered or not. `ReactFlowProps` above
19
+ * is a TYPE import and erases. See ADR 0019 and `pnpm heavy-deps:check`.
20
+ */
21
+ const CanvasImpl = lazyFlowPart<CanvasProps>((m) => m.Canvas);
22
+
23
+ export const Canvas = ({ className, ...props }: CanvasProps) => (
24
+ // The canvas fills the box its parent gives it, so the skeleton reserves the
25
+ // same space and the engine chunk arrives without a layout shift.
26
+ <Suspense fallback={<Skeleton className={cn("size-full", className)} />}>
27
+ <CanvasImpl className={className} {...props} />
28
+ </Suspense>
29
+ );
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
+ import { Badge, type Status } from "@elabs-ai/components-ui";
5
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
6
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
7
+ import { BrainIcon, ChevronDownIcon } from "lucide-react";
8
+ import type { ReactNode } from "react";
9
+ import type { ComponentProps } from "react";
10
+ import { createContext, memo, useContext, useMemo } from "react";
11
+
12
+ import { AgentStep, AgentTimeline, type AgentStepProps } from "./agent-timeline";
13
+
14
+ interface ChainOfThoughtContextValue {
15
+ isOpen: boolean;
16
+ setIsOpen: (open: boolean) => void;
17
+ }
18
+
19
+ const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(null);
20
+
21
+ const useChainOfThought = () => {
22
+ const context = useContext(ChainOfThoughtContext);
23
+ if (!context) {
24
+ throw new Error("ChainOfThought components must be used within ChainOfThought");
25
+ }
26
+ return context;
27
+ };
28
+
29
+ export type ChainOfThoughtProps = ComponentProps<"div"> & {
30
+ open?: boolean;
31
+ defaultOpen?: boolean;
32
+ onOpenChange?: (open: boolean) => void;
33
+ };
34
+
35
+ export const ChainOfThought = memo(
36
+ ({
37
+ className,
38
+ open,
39
+ defaultOpen = false,
40
+ onOpenChange,
41
+ children,
42
+ ...props
43
+ }: ChainOfThoughtProps) => {
44
+ const [isOpen, setIsOpen] = useControllableState({
45
+ defaultProp: defaultOpen,
46
+ onChange: onOpenChange,
47
+ prop: open,
48
+ });
49
+
50
+ const chainOfThoughtContext = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen]);
51
+
52
+ return (
53
+ <ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
54
+ <div className={cn("not-prose w-full space-y-4", className)} {...props}>
55
+ {children}
56
+ </div>
57
+ </ChainOfThoughtContext.Provider>
58
+ );
59
+ },
60
+ );
61
+
62
+ export type ChainOfThoughtHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
63
+
64
+ export const ChainOfThoughtHeader = memo(
65
+ ({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
66
+ const { isOpen, setIsOpen } = useChainOfThought();
67
+
68
+ return (
69
+ <Collapsible onOpenChange={setIsOpen} open={isOpen}>
70
+ <CollapsibleTrigger
71
+ className={cn(
72
+ "flex w-full items-center gap-2 text-body text-muted-foreground transition-colors hover:text-foreground",
73
+ className,
74
+ )}
75
+ {...props}
76
+ >
77
+ <BrainIcon className="size-4" />
78
+ <span className="flex-1 text-start">{children ?? "Chain of Thought"}</span>
79
+ <ChevronDownIcon
80
+ className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
81
+ />
82
+ </CollapsibleTrigger>
83
+ </Collapsible>
84
+ );
85
+ },
86
+ );
87
+
88
+ export type ChainOfThoughtStepProps = Omit<
89
+ AgentStepProps,
90
+ "name" | "summary" | "status" | "hideBadge"
91
+ > & {
92
+ label: ReactNode;
93
+ description?: ReactNode;
94
+ status?: "complete" | "active" | "pending";
95
+ };
96
+
97
+ /**
98
+ * Step status → the canonical 7-state vocabulary (#189, research 10 §B.1):
99
+ * `active` is a running step.
100
+ */
101
+ const stepStatusToCanonical: Record<NonNullable<ChainOfThoughtStepProps["status"]>, Status> = {
102
+ active: "running",
103
+ complete: "complete",
104
+ pending: "pending",
105
+ };
106
+
107
+ /**
108
+ * @deprecated Compose `AgentStep` (agent-timeline.tsx) directly. Since #192
109
+ * this is a thin alias mapping the legacy `label`/`description`/3-state
110
+ * `status` props onto the canonical rail (`AgentTimeline` + `AgentStep`); the
111
+ * pre-#192 hand-rolled connector + text-opacity status idiom is gone
112
+ * (research 10 §B.3). The badge stays hidden here — the rail node carries the
113
+ * status — keeping the quiet prose-rail look.
114
+ */
115
+ export const ChainOfThoughtStep = memo(
116
+ ({ label, description, status = "complete", ...props }: ChainOfThoughtStepProps) => (
117
+ <AgentStep
118
+ hideBadge
119
+ name={label}
120
+ status={stepStatusToCanonical[status]}
121
+ summary={description}
122
+ {...props}
123
+ />
124
+ ),
125
+ );
126
+
127
+ export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
128
+
129
+ export const ChainOfThoughtSearchResults = memo(
130
+ ({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
131
+ <div className={cn("flex flex-wrap items-center gap-2", className)} {...props} />
132
+ ),
133
+ );
134
+
135
+ export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
136
+
137
+ export const ChainOfThoughtSearchResult = memo(
138
+ ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
139
+ <Badge
140
+ className={cn("gap-1 px-2 py-0.5 text-meta font-normal", className)}
141
+ variant="secondary"
142
+ {...props}
143
+ >
144
+ {children}
145
+ </Badge>
146
+ ),
147
+ );
148
+
149
+ export type ChainOfThoughtContentProps = ComponentProps<typeof CollapsibleContent>;
150
+
151
+ export const ChainOfThoughtContent = memo(
152
+ ({ className, children, ...props }: ChainOfThoughtContentProps) => {
153
+ const { isOpen } = useChainOfThought();
154
+
155
+ return (
156
+ <Collapsible open={isOpen}>
157
+ <CollapsibleContent
158
+ className={cn(
159
+ "mt-2 space-y-3",
160
+ "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-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)]",
161
+ className,
162
+ )}
163
+ {...props}
164
+ >
165
+ <AgentTimeline>{children}</AgentTimeline>
166
+ </CollapsibleContent>
167
+ </Collapsible>
168
+ );
169
+ },
170
+ );
171
+
172
+ export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
173
+ caption?: string;
174
+ };
175
+
176
+ export const ChainOfThoughtImage = memo(
177
+ ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
178
+ <div className={cn("mt-2 space-y-2", className)} {...props}>
179
+ <div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
180
+ {children}
181
+ </div>
182
+ {caption && <p className="text-meta text-muted-foreground">{caption}</p>}
183
+ </div>
184
+ ),
185
+ );
186
+
187
+ ChainOfThought.displayName = "ChainOfThought";
188
+ ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
189
+ ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
190
+ ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
191
+ ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
192
+ ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
193
+ ChainOfThoughtImage.displayName = "ChainOfThoughtImage";