@elabs-ai/components-ai 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- package/src/web-preview.tsx +283 -0
package/src/message.tsx
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage, Button } from "@elabs-ai/components-ui";
|
|
4
|
+
import { ButtonGroup, ButtonGroupText, Skeleton } from "@elabs-ai/components-ui";
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@elabs-ai/components-ui";
|
|
6
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
7
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
8
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
9
|
+
import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
|
|
10
|
+
import type { UIMessage } from "ai";
|
|
11
|
+
import { BotIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
|
12
|
+
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
|
13
|
+
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
14
|
+
import { Streamdown } from "streamdown";
|
|
15
|
+
|
|
16
|
+
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
|
17
|
+
from: UIMessage["role"];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const Message = ({ className, from, ...props }: MessageProps) => (
|
|
21
|
+
<div
|
|
22
|
+
className={cn(
|
|
23
|
+
"group flex w-full max-w-[95%] flex-col gap-2",
|
|
24
|
+
// NOTE `is-user` / `is-assistant` are LOAD-BEARING, not decorative: they
|
|
25
|
+
// are what `MessageContent`'s `group-[.is-user]:…` selectors compile
|
|
26
|
+
// against (`.group.is-user &`). `data-role` below is their semantic twin
|
|
27
|
+
// for querying/testing — don't dedupe the two, removing the class would
|
|
28
|
+
// silently kill the `--chat-user` fill in every theme.
|
|
29
|
+
from === "user" ? "is-user ms-auto justify-end" : "is-assistant",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
data-role={from}
|
|
33
|
+
data-slot="message"
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
|
39
|
+
|
|
40
|
+
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
|
41
|
+
<div
|
|
42
|
+
className={cn(
|
|
43
|
+
"flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-body",
|
|
44
|
+
// The user turn is the 08 fill channel: the revalued `--chat-user` tint +
|
|
45
|
+
// its AA-validated foreground (research 11 §B.1 MSG-1). The assistant
|
|
46
|
+
// branch stays bare — its separation is the AgentMessage rail.
|
|
47
|
+
"group-[.is-user]:ms-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-chat-user group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-chat-user-foreground",
|
|
48
|
+
"group-[.is-assistant]:text-foreground",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
data-slot="message-content"
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export type MessageHeaderProps = HTMLAttributes<HTMLDivElement>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Optional identity row above the bubble — avatar + name + timestamp
|
|
62
|
+
* (research 11 §B.1 MSG-2). A slot, not baked structure.
|
|
63
|
+
*/
|
|
64
|
+
export const MessageHeader = ({ className, ...props }: MessageHeaderProps) => (
|
|
65
|
+
<div
|
|
66
|
+
className={cn("flex items-center gap-2 text-meta text-muted-foreground", className)}
|
|
67
|
+
data-slot="message-header"
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
|
|
73
|
+
/** Image source for the participant. */
|
|
74
|
+
src?: string;
|
|
75
|
+
/** Participant name — drives the image alt + the fallback initial, and the accessible label. */
|
|
76
|
+
name?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Visual identity role.
|
|
79
|
+
* - `"user"` (default): shows the initial letter as a fallback.
|
|
80
|
+
* - `"agent"`: shows a branded Bot icon on a primary (green) ground instead
|
|
81
|
+
* of a text initial, matching the agent identity convention.
|
|
82
|
+
*/
|
|
83
|
+
role?: "user" | "agent";
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Thin preset over the `@elabs-ai/components-ui` Avatar sized for inline chat (NOT a new
|
|
88
|
+
* avatar primitive — research 11 §B.1 MSG-2).
|
|
89
|
+
*
|
|
90
|
+
* When `role="agent"`, renders a filled primary-green circle with a centered
|
|
91
|
+
* Bot icon (lucide-react `BotIcon`) and an accessible label derived from
|
|
92
|
+
* `name`. The primary/primary-foreground token pair is the agent identity
|
|
93
|
+
* colour across both themes (green on light/dark; theme-adapted
|
|
94
|
+
* on others).
|
|
95
|
+
*/
|
|
96
|
+
export const MessageAvatar = ({
|
|
97
|
+
src,
|
|
98
|
+
name,
|
|
99
|
+
role = "user",
|
|
100
|
+
className,
|
|
101
|
+
children,
|
|
102
|
+
...props
|
|
103
|
+
}: MessageAvatarProps) => {
|
|
104
|
+
if (role === "agent") {
|
|
105
|
+
return (
|
|
106
|
+
<Avatar
|
|
107
|
+
aria-label={name ? `${name} (agent)` : "Agent"}
|
|
108
|
+
// `role="img"` is LOAD-BEARING, not decoration (#386). Radix's
|
|
109
|
+
// `Avatar.Root` renders a plain `<span>`, whose implicit role is
|
|
110
|
+
// `generic` — and ARIA PROHIBITS `aria-label` on `generic`, so axe's
|
|
111
|
+
// `aria-prohibited-attr` reds the story AND assistive tech never
|
|
112
|
+
// announces the name. `img` is the role permitted to carry a name for a
|
|
113
|
+
// graphic whose contents (the Bot glyph) are not text — the same
|
|
114
|
+
// precedent a titled `@elabs-ai/components-icons` `Icon`
|
|
115
|
+
// uses (.claude/rules/icons.md). Only the agent branch takes it: the
|
|
116
|
+
// user branch's fallback initial is real text content, so naming it too
|
|
117
|
+
// would add an `img` per message for no gain.
|
|
118
|
+
role="img"
|
|
119
|
+
className={cn("size-6 bg-primary text-primary-foreground", className)}
|
|
120
|
+
data-role="agent"
|
|
121
|
+
data-slot="message-avatar"
|
|
122
|
+
{...props}
|
|
123
|
+
>
|
|
124
|
+
{src ? <AvatarImage alt={name ?? ""} src={src} /> : null}
|
|
125
|
+
<AvatarFallback className="bg-primary text-primary-foreground">
|
|
126
|
+
<BotIcon aria-hidden="true" size={14} />
|
|
127
|
+
</AvatarFallback>
|
|
128
|
+
</Avatar>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<Avatar
|
|
134
|
+
className={cn("size-6", className)}
|
|
135
|
+
data-role={role}
|
|
136
|
+
data-slot="message-avatar"
|
|
137
|
+
{...props}
|
|
138
|
+
>
|
|
139
|
+
{src ? <AvatarImage alt={name ?? ""} src={src} /> : null}
|
|
140
|
+
<AvatarFallback className="text-meta">
|
|
141
|
+
{children ?? name?.slice(0, 1).toUpperCase() ?? "?"}
|
|
142
|
+
</AvatarFallback>
|
|
143
|
+
</Avatar>
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type UserMessageProps = Omit<MessageProps, "from">;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Named front door for a user turn (research 11 §B.2) — a thin preset over
|
|
151
|
+
* `Message from="user"`; the chat-user fill comes from `MessageContent`.
|
|
152
|
+
*/
|
|
153
|
+
export const UserMessage = (props: UserMessageProps) => <Message from="user" {...props} />;
|
|
154
|
+
|
|
155
|
+
export const agentMessageVariants = cva("", {
|
|
156
|
+
variants: {
|
|
157
|
+
/**
|
|
158
|
+
* "Final answer" is a VISUAL axis, not a behavioural mode (research 11
|
|
159
|
+
* §B.2): `answer` applies the 08 green left rail. It sits on the message
|
|
160
|
+
* wrapper — never on `MessageContent`, whose `overflow-hidden` would clip
|
|
161
|
+
* it (research 11 §G.1).
|
|
162
|
+
*/
|
|
163
|
+
emphasis: {
|
|
164
|
+
default: "",
|
|
165
|
+
answer: "border-s-4 border-s-primary ps-4",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
defaultVariants: { emphasis: "default" },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
export type AgentMessageEmphasis = NonNullable<
|
|
172
|
+
VariantProps<typeof agentMessageVariants>["emphasis"]
|
|
173
|
+
>;
|
|
174
|
+
|
|
175
|
+
export type AgentMessageProps = Omit<MessageProps, "from"> &
|
|
176
|
+
VariantProps<typeof agentMessageVariants>;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Named front door for an assistant turn (research 11 §B.2). KPI band / prose /
|
|
180
|
+
* footer are COMPOSED children (`MetricGrid` + `MessageResponse` +
|
|
181
|
+
* `SourceList`), not baked-in layout.
|
|
182
|
+
*/
|
|
183
|
+
export const AgentMessage = ({ className, emphasis, ...props }: AgentMessageProps) => (
|
|
184
|
+
<Message
|
|
185
|
+
from="assistant"
|
|
186
|
+
className={cn(agentMessageVariants({ emphasis }), className)}
|
|
187
|
+
{...props}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
export const messageActionsVariants = cva(
|
|
192
|
+
[
|
|
193
|
+
"flex items-center gap-1",
|
|
194
|
+
// Follow the bubble to the right on a user turn — the SAME selector
|
|
195
|
+
// `MessageContent` uses for the `--chat-user` fill above. Without it the row
|
|
196
|
+
// sits at the flex-column's cross-start (left) edge while the bubble it acts
|
|
197
|
+
// on is `ms-auto`'d to the right, so a `bar` pill renders under the PREVIOUS
|
|
198
|
+
// (assistant) message: "Edit" appears to edit the agent's answer. It was
|
|
199
|
+
// invisible before only because a `plain` row stretched full-width and had
|
|
200
|
+
// no drawn box; `w-fit` is what made the misplacement visible.
|
|
201
|
+
"group-[.is-user]:ms-auto",
|
|
202
|
+
],
|
|
203
|
+
{
|
|
204
|
+
variants: {
|
|
205
|
+
/**
|
|
206
|
+
* How the row presents itself. Two VISUAL values, per `cva`'s remit — no
|
|
207
|
+
* behaviour forks on this axis.
|
|
208
|
+
* - `plain` (default): a bare inline row, the pre-#existing shape. Unchanged
|
|
209
|
+
* for every consumer that never sets the prop.
|
|
210
|
+
* - `bar`: a floating pill that reads as chrome sitting ABOVE the transcript
|
|
211
|
+
* rather than as more message content. Per ADR 0020 a floating surface
|
|
212
|
+
* takes `shadow-ring-*` and NO border — the ring bakes the 1px edge in as
|
|
213
|
+
* the shadow's last layer, so there is no second crisp stroke beside it,
|
|
214
|
+
* and because the hairline sits OUTSIDE `--shadow-strength` the pill keeps
|
|
215
|
+
* a drawn edge at high decoration / `data-decoration="8|9|10"` where the lift
|
|
216
|
+
* is zeroed.
|
|
217
|
+
*/
|
|
218
|
+
appearance: {
|
|
219
|
+
plain: "",
|
|
220
|
+
bar: "w-fit rounded-full bg-popover p-0.5 shadow-ring-sm",
|
|
221
|
+
},
|
|
222
|
+
/**
|
|
223
|
+
* When the row is visible.
|
|
224
|
+
* - `always` (default): in flow, always shown.
|
|
225
|
+
* - `hover`: faded out at rest, revealed when the pointer is anywhere over
|
|
226
|
+
* the parent `Message`, when focus lands inside the row, or while a menu
|
|
227
|
+
* inside it is open.
|
|
228
|
+
*/
|
|
229
|
+
reveal: {
|
|
230
|
+
always: "",
|
|
231
|
+
hover: [
|
|
232
|
+
"transition-opacity duration-fast ease-standard motion-reduce:transition-none",
|
|
233
|
+
// Hover-reveal is gated on the POINTER, not on a breakpoint: a touch
|
|
234
|
+
// device has no hover state at all, so hiding the row there would make
|
|
235
|
+
// it unreachable (you cannot tap what you cannot see, and there is no
|
|
236
|
+
// gesture that reveals it first). `pointer-coarse` therefore never
|
|
237
|
+
// takes the `opacity-0` at all and the row stays visible. Note this is
|
|
238
|
+
// a stronger guarantee than the `md:` breakpoint proxy used by the
|
|
239
|
+
// `@elabs-ai/components-ui` Sidebar — a ≥md tablet is
|
|
240
|
+
// exactly the case that proxy gets wrong.
|
|
241
|
+
"pointer-fine:opacity-0",
|
|
242
|
+
// Reveal on: the parent Message (`Message` sets the bare `group` class
|
|
243
|
+
// — see the load-bearing note there), the row itself, keyboard focus
|
|
244
|
+
// landing inside it, and an open menu launched from it (a `⋯ more`
|
|
245
|
+
// trigger carries `data-state="open"`; without this the row fades out
|
|
246
|
+
// from under the menu the moment the pointer travels to it).
|
|
247
|
+
"pointer-fine:group-hover:opacity-100",
|
|
248
|
+
"pointer-fine:hover:opacity-100",
|
|
249
|
+
"pointer-fine:focus-within:opacity-100",
|
|
250
|
+
"pointer-fine:has-[[data-state=open]]:opacity-100",
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
defaultVariants: { appearance: "plain", reveal: "always" },
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
export type MessageActionsProps = ComponentProps<"div"> &
|
|
259
|
+
VariantProps<typeof messageActionsVariants> & {
|
|
260
|
+
/**
|
|
261
|
+
* Accessible name for the group. Defaults to the `ai.message.actions`
|
|
262
|
+
* microcopy key. Naming matters more here than on an always-visible row:
|
|
263
|
+
* assistive tech reaches these controls whether or not a pointer ever
|
|
264
|
+
* revealed them, so the group has to say what they act on.
|
|
265
|
+
*/
|
|
266
|
+
label?: string;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A row of per-message controls (copy, pin, retry, a `⋯` menu, …).
|
|
271
|
+
*
|
|
272
|
+
* **Presentational only** — it lays the controls out and decides when they are
|
|
273
|
+
* visible; it ships no clipboard call, no pin state and no persistence. Every
|
|
274
|
+
* `MessageAction` inside it is a bare `Button`: give it an `onClick`, and for a
|
|
275
|
+
* toggle (pin, bookmark) an `aria-pressed` the host owns. That is the D5
|
|
276
|
+
* boundary (`docs/DECISIONS.md` §D5) — brand-ui renders, the app acts.
|
|
277
|
+
*
|
|
278
|
+
* `reveal="hover"` reads the parent `Message`'s `group` class, so the row must
|
|
279
|
+
* be rendered INSIDE a `Message` for pointer-reveal to fire. Standing alone it
|
|
280
|
+
* still reveals on its own hover and on focus-within, but not from the bubble.
|
|
281
|
+
*/
|
|
282
|
+
export const MessageActions = ({
|
|
283
|
+
appearance,
|
|
284
|
+
className,
|
|
285
|
+
children,
|
|
286
|
+
label,
|
|
287
|
+
reveal,
|
|
288
|
+
...props
|
|
289
|
+
}: MessageActionsProps) => {
|
|
290
|
+
const { t } = useLocale();
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div
|
|
294
|
+
aria-label={label ?? t("ai.message.actions")}
|
|
295
|
+
className={cn(messageActionsVariants({ appearance, reveal }), className)}
|
|
296
|
+
data-slot="message-actions"
|
|
297
|
+
role="group"
|
|
298
|
+
{...props}
|
|
299
|
+
>
|
|
300
|
+
{children}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
export type MessageActionProps = ComponentProps<typeof Button> & {
|
|
306
|
+
tooltip?: string;
|
|
307
|
+
label?: string;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export const MessageAction = ({
|
|
311
|
+
tooltip,
|
|
312
|
+
children,
|
|
313
|
+
label,
|
|
314
|
+
variant = "ghost",
|
|
315
|
+
size = "icon-sm",
|
|
316
|
+
...props
|
|
317
|
+
}: MessageActionProps) => {
|
|
318
|
+
const button = (
|
|
319
|
+
<Button data-slot="message-action" size={size} type="button" variant={variant} {...props}>
|
|
320
|
+
{children}
|
|
321
|
+
<span className="sr-only">{label || tooltip}</span>
|
|
322
|
+
</Button>
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
if (tooltip) {
|
|
326
|
+
return (
|
|
327
|
+
<TooltipProvider>
|
|
328
|
+
<Tooltip>
|
|
329
|
+
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
330
|
+
<TooltipContent>
|
|
331
|
+
<p>{tooltip}</p>
|
|
332
|
+
</TooltipContent>
|
|
333
|
+
</Tooltip>
|
|
334
|
+
</TooltipProvider>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return button;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
interface MessageBranchContextType {
|
|
342
|
+
currentBranch: number;
|
|
343
|
+
totalBranches: number;
|
|
344
|
+
goToPrevious: () => void;
|
|
345
|
+
goToNext: () => void;
|
|
346
|
+
branches: ReactElement[];
|
|
347
|
+
setBranches: (branches: ReactElement[]) => void;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const MessageBranchContext = createContext<MessageBranchContextType | null>(null);
|
|
351
|
+
|
|
352
|
+
const useMessageBranch = () => {
|
|
353
|
+
const context = useContext(MessageBranchContext);
|
|
354
|
+
|
|
355
|
+
if (!context) {
|
|
356
|
+
throw new Error("MessageBranch components must be used within MessageBranch");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return context;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
|
363
|
+
/**
|
|
364
|
+
* Controlled current branch index. When set, `MessageBranch` no longer
|
|
365
|
+
* tracks its own state — the host owns it and must update `branch` from
|
|
366
|
+
* `onBranchChange` (or elsewhere) to move the selection. Omit for the
|
|
367
|
+
* uncontrolled default (see `defaultBranch`).
|
|
368
|
+
*/
|
|
369
|
+
branch?: number;
|
|
370
|
+
defaultBranch?: number;
|
|
371
|
+
onBranchChange?: (branchIndex: number) => void;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export const MessageBranch = ({
|
|
375
|
+
branch: branchProp,
|
|
376
|
+
defaultBranch = 0,
|
|
377
|
+
onBranchChange,
|
|
378
|
+
className,
|
|
379
|
+
...props
|
|
380
|
+
}: MessageBranchProps) => {
|
|
381
|
+
// Derived once and never flipped, per the repo's controlled/uncontrolled
|
|
382
|
+
// convention (component-api.md) — mirrors MessageFeedback/MessageEdit.
|
|
383
|
+
const isControlled = branchProp !== undefined;
|
|
384
|
+
const [internalBranch, setInternalBranch] = useState(defaultBranch);
|
|
385
|
+
const currentBranch = isControlled ? branchProp : internalBranch;
|
|
386
|
+
const [branches, setBranches] = useState<ReactElement[]>([]);
|
|
387
|
+
|
|
388
|
+
const handleBranchChange = useCallback(
|
|
389
|
+
(newBranch: number) => {
|
|
390
|
+
if (!isControlled) setInternalBranch(newBranch);
|
|
391
|
+
onBranchChange?.(newBranch);
|
|
392
|
+
},
|
|
393
|
+
[isControlled, onBranchChange],
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const goToPrevious = useCallback(() => {
|
|
397
|
+
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
|
398
|
+
handleBranchChange(newBranch);
|
|
399
|
+
}, [currentBranch, branches.length, handleBranchChange]);
|
|
400
|
+
|
|
401
|
+
const goToNext = useCallback(() => {
|
|
402
|
+
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
|
403
|
+
handleBranchChange(newBranch);
|
|
404
|
+
}, [currentBranch, branches.length, handleBranchChange]);
|
|
405
|
+
|
|
406
|
+
const contextValue = useMemo<MessageBranchContextType>(
|
|
407
|
+
() => ({
|
|
408
|
+
branches,
|
|
409
|
+
currentBranch,
|
|
410
|
+
goToNext,
|
|
411
|
+
goToPrevious,
|
|
412
|
+
setBranches,
|
|
413
|
+
totalBranches: branches.length,
|
|
414
|
+
}),
|
|
415
|
+
[branches, currentBranch, goToNext, goToPrevious],
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<MessageBranchContext.Provider value={contextValue}>
|
|
420
|
+
<div
|
|
421
|
+
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
|
422
|
+
data-slot="message-branch"
|
|
423
|
+
{...props}
|
|
424
|
+
/>
|
|
425
|
+
</MessageBranchContext.Provider>
|
|
426
|
+
);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
|
430
|
+
|
|
431
|
+
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
|
|
432
|
+
const { currentBranch, setBranches, branches } = useMessageBranch();
|
|
433
|
+
const childrenArray = useMemo(
|
|
434
|
+
() => (Array.isArray(children) ? children : [children]),
|
|
435
|
+
[children],
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// Use useEffect to update branches when they change
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
if (branches.length !== childrenArray.length) {
|
|
441
|
+
setBranches(childrenArray);
|
|
442
|
+
}
|
|
443
|
+
}, [childrenArray, branches, setBranches]);
|
|
444
|
+
|
|
445
|
+
return childrenArray.map((branch, index) => (
|
|
446
|
+
<div
|
|
447
|
+
className={cn(
|
|
448
|
+
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
|
449
|
+
index === currentBranch ? "block" : "hidden",
|
|
450
|
+
)}
|
|
451
|
+
data-slot="message-branch-content"
|
|
452
|
+
key={branch.key}
|
|
453
|
+
{...props}
|
|
454
|
+
>
|
|
455
|
+
{branch}
|
|
456
|
+
</div>
|
|
457
|
+
));
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
|
|
461
|
+
|
|
462
|
+
export const MessageBranchSelector = ({ className, ...props }: MessageBranchSelectorProps) => {
|
|
463
|
+
const { totalBranches } = useMessageBranch();
|
|
464
|
+
|
|
465
|
+
// Don't render if there's only one branch
|
|
466
|
+
if (totalBranches <= 1) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<ButtonGroup
|
|
472
|
+
className={cn(
|
|
473
|
+
"[&>*:not(:first-child)]:rounded-s-md [&>*:not(:last-child)]:rounded-e-md",
|
|
474
|
+
className,
|
|
475
|
+
)}
|
|
476
|
+
data-slot="message-branch-selector"
|
|
477
|
+
orientation="horizontal"
|
|
478
|
+
{...props}
|
|
479
|
+
/>
|
|
480
|
+
);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
|
484
|
+
|
|
485
|
+
export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
|
|
486
|
+
const { t } = useLocale();
|
|
487
|
+
const { goToPrevious, totalBranches } = useMessageBranch();
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<Button
|
|
491
|
+
aria-label={t("ai.message.previousBranch")}
|
|
492
|
+
data-slot="message-branch-previous"
|
|
493
|
+
disabled={totalBranches <= 1}
|
|
494
|
+
onClick={goToPrevious}
|
|
495
|
+
size="icon-sm"
|
|
496
|
+
type="button"
|
|
497
|
+
variant="ghost"
|
|
498
|
+
{...props}
|
|
499
|
+
>
|
|
500
|
+
{children ?? <ChevronLeftIcon size={14} data-rtl-flip />}
|
|
501
|
+
</Button>
|
|
502
|
+
);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
|
506
|
+
|
|
507
|
+
export const MessageBranchNext = ({ children, ...props }: MessageBranchNextProps) => {
|
|
508
|
+
const { t } = useLocale();
|
|
509
|
+
const { goToNext, totalBranches } = useMessageBranch();
|
|
510
|
+
|
|
511
|
+
return (
|
|
512
|
+
<Button
|
|
513
|
+
aria-label={t("ai.message.nextBranch")}
|
|
514
|
+
data-slot="message-branch-next"
|
|
515
|
+
disabled={totalBranches <= 1}
|
|
516
|
+
onClick={goToNext}
|
|
517
|
+
size="icon-sm"
|
|
518
|
+
type="button"
|
|
519
|
+
variant="ghost"
|
|
520
|
+
{...props}
|
|
521
|
+
>
|
|
522
|
+
{children ?? <ChevronRightIcon size={14} data-rtl-flip />}
|
|
523
|
+
</Button>
|
|
524
|
+
);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
|
528
|
+
|
|
529
|
+
export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
|
|
530
|
+
const { currentBranch, totalBranches } = useMessageBranch();
|
|
531
|
+
|
|
532
|
+
return (
|
|
533
|
+
<ButtonGroupText
|
|
534
|
+
className={cn("border-none bg-transparent text-muted-foreground shadow-none", className)}
|
|
535
|
+
data-slot="message-branch-page"
|
|
536
|
+
{...props}
|
|
537
|
+
>
|
|
538
|
+
{currentBranch + 1} of {totalBranches}
|
|
539
|
+
</ButtonGroupText>
|
|
540
|
+
);
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
export type MessageResponseProps = ComponentProps<typeof Streamdown> & {
|
|
544
|
+
/**
|
|
545
|
+
* No content has arrived yet (loading-states.md `loading`) — renders
|
|
546
|
+
* skeleton lines at the body line-height instead of `<Streamdown>`, so the
|
|
547
|
+
* bubble reserves its space before the first token.
|
|
548
|
+
* @default false
|
|
549
|
+
*/
|
|
550
|
+
loading?: boolean;
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
export const MessageResponse = memo(
|
|
554
|
+
({ className, loading = false, ...props }: MessageResponseProps) => {
|
|
555
|
+
// Streamdown renders its own chrome (code copy, table menus, Mermaid
|
|
556
|
+
// toolbar); route its labels through the locale seam (#310). Spread AFTER
|
|
557
|
+
// so an explicit `translations` prop still wins (ADR 0017 override chain).
|
|
558
|
+
const translations = useStreamdownTranslations();
|
|
559
|
+
const { t } = useLocale();
|
|
560
|
+
// Brand-token-derived `code` plugin, not the package's static github-*
|
|
561
|
+
// default (#315 follow-up) — re-derives when the active theme changes.
|
|
562
|
+
const plugins = useStreamdownPlugins();
|
|
563
|
+
|
|
564
|
+
if (loading) {
|
|
565
|
+
return (
|
|
566
|
+
<div
|
|
567
|
+
className={cn("space-y-2", className)}
|
|
568
|
+
data-slot="message-response"
|
|
569
|
+
role="status"
|
|
570
|
+
aria-live="polite"
|
|
571
|
+
>
|
|
572
|
+
<span className="sr-only">{t("loading")}</span>
|
|
573
|
+
{/* Fixed (not percentage) widths: MessageContent is `w-fit`, so a
|
|
574
|
+
skeleton row sized in `%` has nothing definite to shrink-wrap
|
|
575
|
+
around and collapses to zero width. */}
|
|
576
|
+
<Skeleton className="h-5 w-64" />
|
|
577
|
+
<Skeleton className="h-5 w-52" />
|
|
578
|
+
<Skeleton className="h-5 w-40" />
|
|
579
|
+
</div>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return (
|
|
584
|
+
<Streamdown
|
|
585
|
+
className={cn(
|
|
586
|
+
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
|
587
|
+
// Brand prose scale (research 11 §B.1 MSG-3): map the streamed markdown
|
|
588
|
+
// element tree onto the 07 type roles. Descendant-selector className —
|
|
589
|
+
// NOT a `components` map — so streaming stays cheap.
|
|
590
|
+
"[&_h1]:text-title [&_h2]:text-subtitle [&_h3]:text-subtitle",
|
|
591
|
+
"[&_p]:text-body [&_li]:text-body [&_code]:text-code",
|
|
592
|
+
className,
|
|
593
|
+
)}
|
|
594
|
+
data-slot="message-response"
|
|
595
|
+
plugins={plugins}
|
|
596
|
+
translations={translations}
|
|
597
|
+
{...props}
|
|
598
|
+
/>
|
|
599
|
+
);
|
|
600
|
+
},
|
|
601
|
+
(prevProps, nextProps) =>
|
|
602
|
+
prevProps.children === nextProps.children &&
|
|
603
|
+
nextProps.isAnimating === prevProps.isAnimating &&
|
|
604
|
+
nextProps.loading === prevProps.loading,
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
MessageResponse.displayName = "MessageResponse";
|
|
608
|
+
|
|
609
|
+
export type MessageToolbarProps = ComponentProps<"div">;
|
|
610
|
+
|
|
611
|
+
export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
|
|
612
|
+
<div
|
|
613
|
+
className={cn("mt-4 flex w-full items-center justify-between gap-4", className)}
|
|
614
|
+
data-slot="message-toolbar"
|
|
615
|
+
{...props}
|
|
616
|
+
>
|
|
617
|
+
{children}
|
|
618
|
+
</div>
|
|
619
|
+
);
|