@elabs-ai/components-ai 4.0.0 → 4.2.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 +62 -12
- 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 +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -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 +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -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 +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- 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/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- 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 +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -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 +65 -4
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- 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/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- 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/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- 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/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- 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.test.tsx +0 -55
- 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,13 +1,48 @@
|
|
|
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
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
MessageForm,
|
|
22
|
+
MessageFormFields,
|
|
23
|
+
MessageFormProvider,
|
|
24
|
+
MessageFormRoot,
|
|
25
|
+
MessageFormSubmit,
|
|
26
|
+
MessageFormTitle,
|
|
27
|
+
} from "./message-form";
|
|
4
28
|
import type { FormSpec, FormValues } from "./message-form-spec";
|
|
5
29
|
|
|
6
30
|
const meta = {
|
|
7
31
|
title: "AI/MessageForm",
|
|
8
32
|
component: MessageForm,
|
|
9
33
|
tags: ["autodocs"],
|
|
10
|
-
parameters: {
|
|
34
|
+
parameters: {
|
|
35
|
+
layout: "padded",
|
|
36
|
+
docs: {
|
|
37
|
+
description: {
|
|
38
|
+
component:
|
|
39
|
+
"Not a chat input. A model-emitted form rendered inside a message. " +
|
|
40
|
+
"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. " +
|
|
41
|
+
"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. " +
|
|
42
|
+
"Composes @elabs-ai/components-ui inputs; semantic tokens only; reads in all themes.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
11
46
|
} satisfies Meta<typeof MessageForm>;
|
|
12
47
|
export default meta;
|
|
13
48
|
type Story = StoryObj<typeof meta>;
|
|
@@ -67,7 +102,7 @@ export const Default: Story = {
|
|
|
67
102
|
*/
|
|
68
103
|
export const StreamingPartial: Story = {
|
|
69
104
|
args: {
|
|
70
|
-
|
|
105
|
+
isStreaming: true,
|
|
71
106
|
spec: {
|
|
72
107
|
formName: "book_demo",
|
|
73
108
|
title: "Book a demo",
|
|
@@ -83,11 +118,37 @@ export const StreamingPartial: Story = {
|
|
|
83
118
|
/** Streaming with nothing parsed yet → a layout-shaped skeleton. */
|
|
84
119
|
export const StreamingSkeleton: Story = {
|
|
85
120
|
args: {
|
|
86
|
-
|
|
121
|
+
isStreaming: true,
|
|
87
122
|
spec: { formName: "book_demo", title: "Book a demo", fields: [] },
|
|
88
123
|
},
|
|
89
124
|
};
|
|
90
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The compound composition — `MessageFormProvider` + the parts directly,
|
|
128
|
+
* instead of the `MessageForm` convenience wrapper (see the file-level doc
|
|
129
|
+
* comment's "compound structure" section). `isStreaming` lives on the
|
|
130
|
+
* provider itself since it is the one that owns the lifted form state; a
|
|
131
|
+
* custom layout that skips `MessageForm` still needs to pass it through here.
|
|
132
|
+
*/
|
|
133
|
+
export const ProviderStreaming: Story = {
|
|
134
|
+
render: () => (
|
|
135
|
+
<MessageFormProvider
|
|
136
|
+
isStreaming
|
|
137
|
+
spec={{
|
|
138
|
+
formName: "book_demo",
|
|
139
|
+
title: "Book a demo",
|
|
140
|
+
fields: [{ type: "string", name: "fullName", label: "Full name", required: true }],
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<MessageFormRoot>
|
|
144
|
+
<MessageFormTitle />
|
|
145
|
+
<MessageFormFields />
|
|
146
|
+
<MessageFormSubmit />
|
|
147
|
+
</MessageFormRoot>
|
|
148
|
+
</MessageFormProvider>
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
|
|
91
152
|
/** Empty (not streaming) → a calm fallback, never broken UI. */
|
|
92
153
|
export const Empty: Story = {
|
|
93
154
|
args: {
|
|
@@ -116,16 +116,22 @@ describe("MessageForm — never throws / fallback", () => {
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
it("renders a skeleton (not a fallback) for an empty spec while streaming", () => {
|
|
119
|
-
const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }}
|
|
119
|
+
const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} isStreaming />);
|
|
120
120
|
expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
|
|
121
121
|
// Skeleton placeholders are aria-hidden decoration.
|
|
122
122
|
expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it("still honors the deprecated `streaming` alias", () => {
|
|
126
|
+
const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} streaming />);
|
|
127
|
+
expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
|
|
128
|
+
expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
125
131
|
it("drops half-arrived fields while streaming instead of crashing", () => {
|
|
126
132
|
render(
|
|
127
133
|
<MessageForm
|
|
128
|
-
|
|
134
|
+
isStreaming
|
|
129
135
|
spec={{
|
|
130
136
|
formName: "x",
|
|
131
137
|
fields: [
|
package/src/message-form.tsx
CHANGED
|
@@ -88,7 +88,7 @@ interface MessageFormContextValue {
|
|
|
88
88
|
submitted: boolean;
|
|
89
89
|
submitting: boolean;
|
|
90
90
|
disabled: boolean;
|
|
91
|
-
|
|
91
|
+
isStreaming: boolean;
|
|
92
92
|
formId: string;
|
|
93
93
|
headingId: string;
|
|
94
94
|
}
|
|
@@ -145,6 +145,8 @@ export interface MessageFormProviderProps {
|
|
|
145
145
|
/** In-flight submit: controls disabled, the submit button shows a spinner. */
|
|
146
146
|
submitting?: boolean;
|
|
147
147
|
/** The spec is still streaming in (renders a skeleton when no fields yet). */
|
|
148
|
+
isStreaming?: boolean;
|
|
149
|
+
/** @deprecated Use `isStreaming`. */
|
|
148
150
|
streaming?: boolean;
|
|
149
151
|
children: ReactNode;
|
|
150
152
|
}
|
|
@@ -161,9 +163,11 @@ export function MessageFormProvider({
|
|
|
161
163
|
disabled = false,
|
|
162
164
|
submitted = false,
|
|
163
165
|
submitting = false,
|
|
166
|
+
isStreaming,
|
|
164
167
|
streaming = false,
|
|
165
168
|
children,
|
|
166
169
|
}: MessageFormProviderProps) {
|
|
170
|
+
const resolvedStreaming = isStreaming ?? streaming;
|
|
167
171
|
const formId = useId();
|
|
168
172
|
const headingId = `${formId}-title`;
|
|
169
173
|
|
|
@@ -231,7 +235,7 @@ export function MessageFormProvider({
|
|
|
231
235
|
submitted,
|
|
232
236
|
submitting,
|
|
233
237
|
disabled,
|
|
234
|
-
|
|
238
|
+
isStreaming: resolvedStreaming,
|
|
235
239
|
formId,
|
|
236
240
|
headingId,
|
|
237
241
|
}),
|
|
@@ -246,7 +250,7 @@ export function MessageFormProvider({
|
|
|
246
250
|
submitted,
|
|
247
251
|
submitting,
|
|
248
252
|
disabled,
|
|
249
|
-
|
|
253
|
+
resolvedStreaming,
|
|
250
254
|
formId,
|
|
251
255
|
headingId,
|
|
252
256
|
],
|
|
@@ -570,9 +574,9 @@ export type MessageFormFieldsProps = HTMLAttributes<HTMLDivElement>;
|
|
|
570
574
|
/** Renders every field in the spec, in order. A skeleton while streaming empty. */
|
|
571
575
|
export const MessageFormFields = forwardRef<HTMLDivElement, MessageFormFieldsProps>(
|
|
572
576
|
function MessageFormFields({ className, ...props }, ref) {
|
|
573
|
-
const { spec,
|
|
577
|
+
const { spec, isStreaming } = useMessageFormContext();
|
|
574
578
|
|
|
575
|
-
if (spec.fields.length === 0 &&
|
|
579
|
+
if (spec.fields.length === 0 && isStreaming) {
|
|
576
580
|
return (
|
|
577
581
|
<div ref={ref} className={cn("flex flex-col gap-4", className)} {...props}>
|
|
578
582
|
<span className="sr-only" role="status" aria-live="polite">
|
|
@@ -771,6 +775,8 @@ export interface MessageFormProps extends Omit<
|
|
|
771
775
|
/** In-flight submit: controls disabled, spinner on submit. */
|
|
772
776
|
submitting?: boolean;
|
|
773
777
|
/** The spec is still streaming (renders a skeleton when no fields yet). */
|
|
778
|
+
isStreaming?: boolean;
|
|
779
|
+
/** @deprecated Use `isStreaming`. */
|
|
774
780
|
streaming?: boolean;
|
|
775
781
|
}
|
|
776
782
|
|
|
@@ -789,12 +795,14 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
|
|
|
789
795
|
disabled,
|
|
790
796
|
submitted,
|
|
791
797
|
submitting,
|
|
798
|
+
isStreaming,
|
|
792
799
|
streaming,
|
|
793
800
|
className,
|
|
794
801
|
...props
|
|
795
802
|
},
|
|
796
803
|
ref,
|
|
797
804
|
) {
|
|
805
|
+
const resolvedStreaming = isStreaming ?? streaming;
|
|
798
806
|
const result = normalizeFormSpec(spec);
|
|
799
807
|
if (!result.ok) {
|
|
800
808
|
return (
|
|
@@ -806,7 +814,7 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
|
|
|
806
814
|
const empty = normalized.fields.length === 0;
|
|
807
815
|
|
|
808
816
|
// Not streaming + no fields → the spec is structurally valid but useless.
|
|
809
|
-
if (empty && !
|
|
817
|
+
if (empty && !resolvedStreaming) {
|
|
810
818
|
return (
|
|
811
819
|
<MessageFormFallback
|
|
812
820
|
ref={ref}
|
|
@@ -826,7 +834,7 @@ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function
|
|
|
826
834
|
disabled={disabled}
|
|
827
835
|
submitted={submitted}
|
|
828
836
|
submitting={submitting}
|
|
829
|
-
|
|
837
|
+
isStreaming={resolvedStreaming}
|
|
830
838
|
>
|
|
831
839
|
<div ref={ref} className={cn("w-full", className)} {...props}>
|
|
832
840
|
<MessageFormRoot>
|
|
@@ -105,7 +105,7 @@ export const Truncated: Story = {
|
|
|
105
105
|
/** Streaming / partial — columns known, rows still arriving → skeleton rows. */
|
|
106
106
|
export const StreamingRows: Story = {
|
|
107
107
|
args: {
|
|
108
|
-
|
|
108
|
+
isStreaming: true,
|
|
109
109
|
spec: { title: "Loading results", columns: invoicesSpec.columns, rows: [] },
|
|
110
110
|
},
|
|
111
111
|
};
|
|
@@ -113,7 +113,7 @@ export const StreamingRows: Story = {
|
|
|
113
113
|
/** Missing cells render an em-dash — a half-arrived row never crashes. */
|
|
114
114
|
export const PartialRows: Story = {
|
|
115
115
|
args: {
|
|
116
|
-
|
|
116
|
+
isStreaming: true,
|
|
117
117
|
spec: {
|
|
118
118
|
title: "Streaming rows",
|
|
119
119
|
columns: invoicesSpec.columns,
|