@elabs-ai/components-ai 4.0.0 → 4.1.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/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MessageCompare — side-by-side comparison of 2-4 model responses to the same
|
|
5
|
+
* prompt (issue #23). The one-at-a-time sibling of `MessageBranch`
|
|
6
|
+
* (`message.tsx`): `MessageBranch` switches between responses; `MessageCompare`
|
|
7
|
+
* shows several at once, each with its own status, its own scroll position, and
|
|
8
|
+
* its own `MessageFeedback`.
|
|
9
|
+
*
|
|
10
|
+
* Architecture (lifted state, INTERNAL-ONLY — not the ChartFrame/PromptInputProvider
|
|
11
|
+
* PUBLIC-provider shape): `MessageCompareProvider` + `useMessageCompare()` hold the
|
|
12
|
+
* `state`/`actions`/`meta` that back the grid, but both are unexported implementation
|
|
13
|
+
* details, like `ChartFrameProvider` (`.claude/rules/chart-components.md` — "not
|
|
14
|
+
* exported publicly"). `MessageCompare` always mounts its own private instance and
|
|
15
|
+
* treats every child as a column (`Children.toArray`), so there is no seam where an
|
|
16
|
+
* ambient provider or a sibling control could attach to that SAME instance without
|
|
17
|
+
* corrupting the grid (an extra `ResizablePanel`, or a spurious tab with no model
|
|
18
|
+
* name). The supported way to add a "Sync scroll" toggle beside the grid is the
|
|
19
|
+
* ordinary controlled-prop pattern — external `useState` plus `syncScroll`/
|
|
20
|
+
* `onSyncScrollChange` on `MessageCompare` (see `SyncedScrollDemo` in
|
|
21
|
+
* `message-compare.stories.tsx`) — which needs neither export. Column registration
|
|
22
|
+
* and the scroll-sync broadcast live on a second, also-unexported context — the same
|
|
23
|
+
* internal-context-splitting shape `context-panel.tsx` uses for its own refs.
|
|
24
|
+
*
|
|
25
|
+
* Independent streaming/scroll (the issue's core requirement): a column never
|
|
26
|
+
* auto-scrolls itself or a sibling — there is no shared "stick to bottom"
|
|
27
|
+
* driver here (that's `Conversation`'s job for a single transcript). Content
|
|
28
|
+
* growing in one column therefore cannot move a sibling's scroll position by
|
|
29
|
+
* construction; the only way scroll moves across columns is the opt-in
|
|
30
|
+
* `syncScroll` broadcast below.
|
|
31
|
+
*
|
|
32
|
+
* Responsive collapse: under the `md` breakpoint (768px, the same threshold
|
|
33
|
+
* `useIsMobile` already drives for `ContextPanel`'s Sheet fallback) the
|
|
34
|
+
* side-by-side grid becomes a `Tabs` strip, per the issue. Every column stays
|
|
35
|
+
* mounted (`forceMount` + Radix's own `hidden` attribute on the inactive
|
|
36
|
+
* panel) rather than unmounting on tab switch, so a column's scroll position
|
|
37
|
+
* survives flipping between tabs.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
createContext,
|
|
42
|
+
forwardRef,
|
|
43
|
+
useCallback,
|
|
44
|
+
useEffect,
|
|
45
|
+
useId,
|
|
46
|
+
useMemo,
|
|
47
|
+
useRef,
|
|
48
|
+
useState,
|
|
49
|
+
use,
|
|
50
|
+
Children,
|
|
51
|
+
Fragment,
|
|
52
|
+
type HTMLAttributes,
|
|
53
|
+
type ReactElement,
|
|
54
|
+
type ReactNode,
|
|
55
|
+
} from "react";
|
|
56
|
+
import type { ChatStatus } from "ai";
|
|
57
|
+
import { AlertTriangleIcon } from "lucide-react";
|
|
58
|
+
import {
|
|
59
|
+
ResizableHandle,
|
|
60
|
+
ResizablePanel,
|
|
61
|
+
ResizablePanelGroup,
|
|
62
|
+
Tabs,
|
|
63
|
+
TabsContent,
|
|
64
|
+
TabsList,
|
|
65
|
+
TabsTrigger,
|
|
66
|
+
useIsMobile,
|
|
67
|
+
useLocale,
|
|
68
|
+
} from "@elabs-ai/components-ui";
|
|
69
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
70
|
+
import { Shimmer } from "./shimmer";
|
|
71
|
+
|
|
72
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** The minimum a column needs to identify itself — mainly its accessible name. */
|
|
75
|
+
export interface MessageCompareModel {
|
|
76
|
+
/** Rendered in the column header AND used as the region's `aria-label`. */
|
|
77
|
+
name: string;
|
|
78
|
+
/** Optional stable id, e.g. for analytics — not used for layout or a11y. */
|
|
79
|
+
id?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `MessageCompare` supports 2-4 side-by-side responses (issue #23). */
|
|
83
|
+
export type MessageCompareColumnsCount = 2 | 3 | 4;
|
|
84
|
+
|
|
85
|
+
interface MessageCompareState {
|
|
86
|
+
syncScroll: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface MessageCompareActions {
|
|
90
|
+
setSyncScroll: (value: boolean) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface MessageCompareMeta {
|
|
94
|
+
columns: MessageCompareColumnsCount;
|
|
95
|
+
isMobile: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface MessageCompareContextValue {
|
|
99
|
+
state: MessageCompareState;
|
|
100
|
+
actions: MessageCompareActions;
|
|
101
|
+
meta: MessageCompareMeta;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Internal state context (NOT exported — see the file's architecture comment) ─
|
|
105
|
+
|
|
106
|
+
const MessageCompareContext = createContext<MessageCompareContextValue | null>(null);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* INTERNAL — reads the `state`/`actions`/`meta` `MessageCompareProvider` computes.
|
|
110
|
+
* Not exported: `MessageCompare` always owns a private `MessageCompareProvider`
|
|
111
|
+
* instance, so there is no ambient instance for an outside caller to read.
|
|
112
|
+
*/
|
|
113
|
+
function useMessageCompare(): MessageCompareContextValue {
|
|
114
|
+
const ctx = use(MessageCompareContext);
|
|
115
|
+
if (!ctx) {
|
|
116
|
+
throw new Error("useMessageCompare must be used within a MessageCompareProvider.");
|
|
117
|
+
}
|
|
118
|
+
return ctx;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Internal context (column registry + scroll broadcast — not public API) ───
|
|
122
|
+
|
|
123
|
+
interface MessageCompareColumnEntry {
|
|
124
|
+
node: HTMLDivElement;
|
|
125
|
+
/** Set right before a programmatic scroll so the column's own handler knows
|
|
126
|
+
* to swallow the resulting `scroll` event instead of re-broadcasting it. */
|
|
127
|
+
suppressNextScroll: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface MessageCompareInternalValue {
|
|
131
|
+
registerColumn: (id: string, node: HTMLDivElement | null) => void;
|
|
132
|
+
reportScroll: (id: string) => void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const MessageCompareInternalContext = createContext<MessageCompareInternalValue | null>(null);
|
|
136
|
+
|
|
137
|
+
function useMessageCompareInternal(): MessageCompareInternalValue {
|
|
138
|
+
const ctx = use(MessageCompareInternalContext);
|
|
139
|
+
if (!ctx) {
|
|
140
|
+
throw new Error("MessageCompareColumn must be used within a MessageCompare.");
|
|
141
|
+
}
|
|
142
|
+
return ctx;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Provider (INTERNAL — not exported; see the file's architecture comment) ────
|
|
146
|
+
|
|
147
|
+
interface MessageCompareProviderProps {
|
|
148
|
+
children: ReactNode;
|
|
149
|
+
columns: MessageCompareColumnsCount;
|
|
150
|
+
/** Controlled sync-scroll flag. Omit for the uncontrolled default. */
|
|
151
|
+
syncScroll?: boolean;
|
|
152
|
+
/** Uncontrolled initial value. @default false */
|
|
153
|
+
defaultSyncScroll?: boolean;
|
|
154
|
+
onSyncScrollChange?: (value: boolean) => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function MessageCompareProvider({
|
|
158
|
+
children,
|
|
159
|
+
columns,
|
|
160
|
+
syncScroll: syncScrollProp,
|
|
161
|
+
defaultSyncScroll = false,
|
|
162
|
+
onSyncScrollChange,
|
|
163
|
+
}: MessageCompareProviderProps) {
|
|
164
|
+
const isControlled = syncScrollProp !== undefined;
|
|
165
|
+
const [internalSyncScroll, setInternalSyncScroll] = useState(defaultSyncScroll);
|
|
166
|
+
const syncScroll = isControlled ? syncScrollProp : internalSyncScroll;
|
|
167
|
+
const isMobile = useIsMobile();
|
|
168
|
+
|
|
169
|
+
const setSyncScroll = useCallback(
|
|
170
|
+
(value: boolean) => {
|
|
171
|
+
if (!isControlled) setInternalSyncScroll(value);
|
|
172
|
+
onSyncScrollChange?.(value);
|
|
173
|
+
},
|
|
174
|
+
[isControlled, onSyncScrollChange],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// A ref, not state: scroll happens far too often to route through a render.
|
|
178
|
+
const columnsRef = useRef<Map<string, MessageCompareColumnEntry>>(new Map());
|
|
179
|
+
const syncScrollRef = useRef(syncScroll);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
syncScrollRef.current = syncScroll;
|
|
182
|
+
}, [syncScroll]);
|
|
183
|
+
|
|
184
|
+
const registerColumn = useCallback((id: string, node: HTMLDivElement | null) => {
|
|
185
|
+
if (node) columnsRef.current.set(id, { node, suppressNextScroll: false });
|
|
186
|
+
else columnsRef.current.delete(id);
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
const reportScroll = useCallback((id: string) => {
|
|
190
|
+
const entry = columnsRef.current.get(id);
|
|
191
|
+
if (!entry) return;
|
|
192
|
+
|
|
193
|
+
// This scroll was OUR OWN programmatic write (a broadcast landing on this
|
|
194
|
+
// column) — swallow it once rather than re-broadcasting, which is what
|
|
195
|
+
// would otherwise ping-pong two synced columns back and forth.
|
|
196
|
+
if (entry.suppressNextScroll) {
|
|
197
|
+
entry.suppressNextScroll = false;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!syncScrollRef.current) return;
|
|
202
|
+
|
|
203
|
+
const { node } = entry;
|
|
204
|
+
const range = node.scrollHeight - node.clientHeight;
|
|
205
|
+
const ratio = range > 0 ? node.scrollTop / range : 0;
|
|
206
|
+
|
|
207
|
+
columnsRef.current.forEach((other, otherId) => {
|
|
208
|
+
if (otherId === id) return;
|
|
209
|
+
const otherRange = other.node.scrollHeight - other.node.clientHeight;
|
|
210
|
+
if (otherRange <= 0) return;
|
|
211
|
+
// Assign first, THEN compare the browser's actual (possibly rounded)
|
|
212
|
+
// scrollTop to what it was before — never predict equality from the
|
|
213
|
+
// unrounded `ratio * otherRange` math, since scrollTop is an integer
|
|
214
|
+
// pixel value and the destination can already sit at that pixel. Only
|
|
215
|
+
// arm suppression when the write really changed something: if it
|
|
216
|
+
// didn't, no scroll event will fire to clear the flag, and the
|
|
217
|
+
// destination's NEXT real user scroll (a single keyboard PageDown/Home
|
|
218
|
+
// in particular) would otherwise be silently swallowed instead of
|
|
219
|
+
// propagating to its siblings (#12/#53 review, P2).
|
|
220
|
+
const previousScrollTop = other.node.scrollTop;
|
|
221
|
+
other.node.scrollTop = ratio * otherRange;
|
|
222
|
+
if (other.node.scrollTop !== previousScrollTop) {
|
|
223
|
+
other.suppressNextScroll = true;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}, []);
|
|
227
|
+
|
|
228
|
+
const state = useMemo<MessageCompareState>(() => ({ syncScroll }), [syncScroll]);
|
|
229
|
+
const actions = useMemo<MessageCompareActions>(() => ({ setSyncScroll }), [setSyncScroll]);
|
|
230
|
+
const meta = useMemo<MessageCompareMeta>(() => ({ columns, isMobile }), [columns, isMobile]);
|
|
231
|
+
const contextValue = useMemo<MessageCompareContextValue>(
|
|
232
|
+
() => ({ state, actions, meta }),
|
|
233
|
+
[state, actions, meta],
|
|
234
|
+
);
|
|
235
|
+
const internalValue = useMemo<MessageCompareInternalValue>(
|
|
236
|
+
() => ({ registerColumn, reportScroll }),
|
|
237
|
+
[registerColumn, reportScroll],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<MessageCompareContext value={contextValue}>
|
|
242
|
+
<MessageCompareInternalContext value={internalValue}>
|
|
243
|
+
{children}
|
|
244
|
+
</MessageCompareInternalContext>
|
|
245
|
+
</MessageCompareContext>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── MessageCompare (layout) ───────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
function columnKey(child: ReactElement, index: number): string {
|
|
252
|
+
return child.key ?? String(index);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export type MessageCompareProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
|
256
|
+
/** `MessageCompareColumn` elements — 2 to 4. */
|
|
257
|
+
children: ReactNode;
|
|
258
|
+
columns: MessageCompareColumnsCount;
|
|
259
|
+
/** Controlled sync-scroll flag. Omit for the uncontrolled default (off). */
|
|
260
|
+
syncScroll?: boolean;
|
|
261
|
+
/** Uncontrolled initial value. @default false */
|
|
262
|
+
defaultSyncScroll?: boolean;
|
|
263
|
+
onSyncScrollChange?: (value: boolean) => void;
|
|
264
|
+
/** aria-label for the narrow-viewport tab strip. Defaults to microcopy. */
|
|
265
|
+
tabsLabel?: string;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Responsive side-by-side comparison of 2-4 `MessageCompareColumn`s. Desktop:
|
|
270
|
+
* resizable columns (`ResizablePanelGroup`). Under `md`: a `Tabs` strip, per
|
|
271
|
+
* the issue's named narrow-viewport treatment.
|
|
272
|
+
*/
|
|
273
|
+
export const MessageCompare = forwardRef<HTMLDivElement, MessageCompareProps>(
|
|
274
|
+
function MessageCompare(
|
|
275
|
+
{
|
|
276
|
+
children,
|
|
277
|
+
columns,
|
|
278
|
+
syncScroll,
|
|
279
|
+
defaultSyncScroll,
|
|
280
|
+
onSyncScrollChange,
|
|
281
|
+
tabsLabel,
|
|
282
|
+
className,
|
|
283
|
+
...props
|
|
284
|
+
},
|
|
285
|
+
ref,
|
|
286
|
+
) {
|
|
287
|
+
return (
|
|
288
|
+
<MessageCompareProvider
|
|
289
|
+
columns={columns}
|
|
290
|
+
defaultSyncScroll={defaultSyncScroll}
|
|
291
|
+
onSyncScrollChange={onSyncScrollChange}
|
|
292
|
+
syncScroll={syncScroll}
|
|
293
|
+
>
|
|
294
|
+
<MessageCompareLayout ref={ref} className={className} tabsLabel={tabsLabel} {...props}>
|
|
295
|
+
{children}
|
|
296
|
+
</MessageCompareLayout>
|
|
297
|
+
</MessageCompareProvider>
|
|
298
|
+
);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
type MessageCompareLayoutProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
|
303
|
+
children: ReactNode;
|
|
304
|
+
tabsLabel?: string;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const MessageCompareLayout = forwardRef<HTMLDivElement, MessageCompareLayoutProps>(
|
|
308
|
+
function MessageCompareLayout({ children, tabsLabel, className, ...props }, ref) {
|
|
309
|
+
const { meta } = useMessageCompare();
|
|
310
|
+
const { t } = useLocale();
|
|
311
|
+
const items = Children.toArray(children) as ReactElement[];
|
|
312
|
+
|
|
313
|
+
if (meta.isMobile) {
|
|
314
|
+
const firstKey = items[0] ? columnKey(items[0], 0) : undefined;
|
|
315
|
+
return (
|
|
316
|
+
<div
|
|
317
|
+
ref={ref}
|
|
318
|
+
className={cn("flex h-full min-h-0 flex-col", className)}
|
|
319
|
+
data-slot="message-compare"
|
|
320
|
+
{...props}
|
|
321
|
+
>
|
|
322
|
+
<Tabs className="flex h-full min-h-0 flex-col" defaultValue={firstKey}>
|
|
323
|
+
<TabsList
|
|
324
|
+
aria-label={tabsLabel ?? t("ai.messageCompare.tabs")}
|
|
325
|
+
className="w-full shrink-0"
|
|
326
|
+
>
|
|
327
|
+
{items.map((child, index) => {
|
|
328
|
+
const key = columnKey(child, index);
|
|
329
|
+
const props = child.props as { model?: MessageCompareModel };
|
|
330
|
+
return (
|
|
331
|
+
<TabsTrigger className="flex-1" key={key} value={key}>
|
|
332
|
+
{props.model?.name ?? key}
|
|
333
|
+
</TabsTrigger>
|
|
334
|
+
);
|
|
335
|
+
})}
|
|
336
|
+
</TabsList>
|
|
337
|
+
{items.map((child, index) => {
|
|
338
|
+
const key = columnKey(child, index);
|
|
339
|
+
return (
|
|
340
|
+
<TabsContent
|
|
341
|
+
className="mt-2 min-h-0 flex-1 data-[state=active]:flex"
|
|
342
|
+
forceMount
|
|
343
|
+
key={key}
|
|
344
|
+
value={key}
|
|
345
|
+
>
|
|
346
|
+
{child}
|
|
347
|
+
</TabsContent>
|
|
348
|
+
);
|
|
349
|
+
})}
|
|
350
|
+
</Tabs>
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<div
|
|
357
|
+
ref={ref}
|
|
358
|
+
className={cn("h-full min-h-0", className)}
|
|
359
|
+
data-slot="message-compare"
|
|
360
|
+
{...props}
|
|
361
|
+
>
|
|
362
|
+
<ResizablePanelGroup direction="horizontal">
|
|
363
|
+
{items.map((child, index) => (
|
|
364
|
+
<Fragment key={columnKey(child, index)}>
|
|
365
|
+
{index > 0 && <ResizableHandle withHandle />}
|
|
366
|
+
<ResizablePanel defaultSize={100 / items.length} minSize={15}>
|
|
367
|
+
{child}
|
|
368
|
+
</ResizablePanel>
|
|
369
|
+
</Fragment>
|
|
370
|
+
))}
|
|
371
|
+
</ResizablePanelGroup>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// ── MessageCompareColumn ──────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
export type MessageCompareColumnProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
|
380
|
+
/** Identifies the column — its `name` becomes the region's accessible name. */
|
|
381
|
+
model: MessageCompareModel;
|
|
382
|
+
/** In-flight state for this column's OWN response, independent of siblings. */
|
|
383
|
+
status?: ChatStatus;
|
|
384
|
+
/** The consumer's own `Message`/`MessageResponse` composition for this column. */
|
|
385
|
+
children?: ReactNode;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* One labelled, independently-scrolling response column. `role="region"` +
|
|
390
|
+
* `aria-label={model.name}` so a screen-reader user can tell which answer
|
|
391
|
+
* they're in (issue #23 a11y requirement).
|
|
392
|
+
*/
|
|
393
|
+
export const MessageCompareColumn = forwardRef<HTMLDivElement, MessageCompareColumnProps>(
|
|
394
|
+
function MessageCompareColumn({ model, status, children, className, id: idProp, ...props }, ref) {
|
|
395
|
+
const { registerColumn, reportScroll } = useMessageCompareInternal();
|
|
396
|
+
const { t } = useLocale();
|
|
397
|
+
const generatedId = useId();
|
|
398
|
+
const id = idProp ?? generatedId;
|
|
399
|
+
|
|
400
|
+
const setScrollRef = useCallback(
|
|
401
|
+
(node: HTMLDivElement | null) => registerColumn(id, node),
|
|
402
|
+
[id, registerColumn],
|
|
403
|
+
);
|
|
404
|
+
const handleScroll = useCallback(() => reportScroll(id), [id, reportScroll]);
|
|
405
|
+
|
|
406
|
+
const isBusy = status === "streaming" || status === "submitted";
|
|
407
|
+
const isError = status === "error";
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<div
|
|
411
|
+
ref={ref}
|
|
412
|
+
aria-label={model.name}
|
|
413
|
+
className={cn("flex h-full min-h-0 min-w-0 flex-col", className)}
|
|
414
|
+
data-slot="message-compare-column"
|
|
415
|
+
id={id}
|
|
416
|
+
role="region"
|
|
417
|
+
{...props}
|
|
418
|
+
>
|
|
419
|
+
<div
|
|
420
|
+
className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2"
|
|
421
|
+
data-slot="message-compare-column-header"
|
|
422
|
+
>
|
|
423
|
+
<span className="truncate text-body font-medium">{model.name}</span>
|
|
424
|
+
{isBusy && (
|
|
425
|
+
<span className="shrink-0" role="status" aria-live="polite">
|
|
426
|
+
<Shimmer as="span" className="text-meta" duration={1.5}>
|
|
427
|
+
{t("loading")}
|
|
428
|
+
</Shimmer>
|
|
429
|
+
</span>
|
|
430
|
+
)}
|
|
431
|
+
{isError && (
|
|
432
|
+
<span
|
|
433
|
+
className="flex shrink-0 items-center gap-1 text-meta text-destructive-text"
|
|
434
|
+
role="alert"
|
|
435
|
+
>
|
|
436
|
+
<AlertTriangleIcon aria-hidden="true" className="size-3.5" />
|
|
437
|
+
{t("ai.messageCompare.error")}
|
|
438
|
+
</span>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
<div
|
|
442
|
+
className="min-h-0 flex-1 overflow-y-auto p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
443
|
+
data-slot="message-compare-column-body"
|
|
444
|
+
onScroll={handleScroll}
|
|
445
|
+
ref={setScrollRef}
|
|
446
|
+
tabIndex={0}
|
|
447
|
+
>
|
|
448
|
+
{children}
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
);
|
|
452
|
+
},
|
|
453
|
+
);
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageForm — **not a chat input.** A model-emitted form rendered INSIDE a
|
|
3
|
+
* message.
|
|
4
|
+
*
|
|
5
|
+
* It sits next to `Composer` in the sidebar and reads like a sibling of it; it
|
|
6
|
+
* is not one. The composer is the field a person types their turn into
|
|
7
|
+
* ([AI/Composer](?path=/docs/ai-composer--docs)); this is a form the MODEL
|
|
8
|
+
* authored — it emits a serializable `FormSpec` (`message-form-spec.ts`), the
|
|
9
|
+
* user fills it in place in the transcript, and the app receives structured
|
|
10
|
+
* `{ formName, values }` on submit.
|
|
11
|
+
*
|
|
12
|
+
* Spec-driven and zod-validated: the model is the author, it never chooses the
|
|
13
|
+
* look. A malformed spec degrades to `MessageFormFallback` rather than
|
|
14
|
+
* throwing, half-arrived fields are dropped while streaming, and a submitted
|
|
15
|
+
* form renders inert with its values still visible — a chat message is a
|
|
16
|
+
* historical record.
|
|
17
|
+
*/
|
|
1
18
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
19
|
import { useState } from "react";
|
|
3
20
|
import { MessageForm } from "./message-form";
|
|
@@ -7,7 +24,18 @@ const meta = {
|
|
|
7
24
|
title: "AI/MessageForm",
|
|
8
25
|
component: MessageForm,
|
|
9
26
|
tags: ["autodocs"],
|
|
10
|
-
parameters: {
|
|
27
|
+
parameters: {
|
|
28
|
+
layout: "padded",
|
|
29
|
+
docs: {
|
|
30
|
+
description: {
|
|
31
|
+
component:
|
|
32
|
+
"Not a chat input. A model-emitted form rendered inside a message. " +
|
|
33
|
+
"The composer is the field a person types their turn into ([AI/Composer](?path=/docs/ai-composer--docs)); this is a form the MODEL authored — it emits a serializable FormSpec, the user fills it in place in the transcript, and the app receives structured `{ formName, values }` on submit. " +
|
|
34
|
+
"Spec-driven and zod-validated (the model is the author, it never chooses the look): a malformed spec degrades to MessageFormFallback rather than throwing, half-arrived fields are dropped while streaming, and a submitted form renders inert with its values still visible, because a chat message is a historical record. " +
|
|
35
|
+
"Composes @elabs-ai/components-ui inputs; semantic tokens only; reads in all themes.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
11
39
|
} satisfies Meta<typeof MessageForm>;
|
|
12
40
|
export default meta;
|
|
13
41
|
type Story = StoryObj<typeof meta>;
|
package/src/message.stories.tsx
CHANGED
|
@@ -28,7 +28,15 @@ import { SourceList } from "./sources";
|
|
|
28
28
|
const meta = {
|
|
29
29
|
title: "AI/Message",
|
|
30
30
|
component: Message,
|
|
31
|
-
parameters: {
|
|
31
|
+
parameters: {
|
|
32
|
+
layout: "padded",
|
|
33
|
+
docs: {
|
|
34
|
+
description: {
|
|
35
|
+
component:
|
|
36
|
+
"One turn of a CHAT transcript; one line of a CONSOLE transcript is `Terminal/TerminalTranscriptRow` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `UserMessage` and `AgentMessage` are named presets over this same component and keep its `message` slot, so one consumer selector matches every entry point.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
32
40
|
} satisfies Meta<typeof Message>;
|
|
33
41
|
export default meta;
|
|
34
42
|
type Story = StoryObj<typeof meta>;
|