@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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MessageTable — a model-emittable data table rendered inside a chat message.
|
|
5
|
+
*
|
|
6
|
+
* The model emits a serializable, column-oriented `TableSpec` (see
|
|
7
|
+
* `message-table-spec.ts`); MessageTable renders a lightweight table (NOT the
|
|
8
|
+
* app-chrome `@elabs-ai/components-data` DataTable — this is message content).
|
|
9
|
+
*
|
|
10
|
+
* Design bar (mirrors AutoChart):
|
|
11
|
+
* - Spec-driven, zod-validated; the model never chooses the look.
|
|
12
|
+
* - Never throws. A malformed spec → `MessageTableFallback`; an unknown format
|
|
13
|
+
* falls back to text; a missing cell renders an em-dash.
|
|
14
|
+
* - Streaming-tolerant. Half-arrived columns/rows are dropped or render
|
|
15
|
+
* progressively; a spec with columns but no rows yet shows skeleton rows.
|
|
16
|
+
* - Tokens only; numeric columns are end-aligned + `tabular-nums`.
|
|
17
|
+
* - Optional client-side sort (controlled OR uncontrolled) and an optional
|
|
18
|
+
* per-cell `renderCell` override for consumer composition.
|
|
19
|
+
*
|
|
20
|
+
* Composes the `@elabs-ai/components-ui` `Table` primitive.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { forwardRef, useId, useMemo, useState, type HTMLAttributes, type ReactNode } from "react";
|
|
24
|
+
import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
|
|
25
|
+
import {
|
|
26
|
+
Badge,
|
|
27
|
+
Skeleton,
|
|
28
|
+
Table,
|
|
29
|
+
TableBody,
|
|
30
|
+
TableCaption,
|
|
31
|
+
TableCell,
|
|
32
|
+
TableHead,
|
|
33
|
+
TableHeader,
|
|
34
|
+
TableRow,
|
|
35
|
+
} from "@elabs-ai/components-ui";
|
|
36
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
37
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
badgeToneForValue,
|
|
41
|
+
compareCellValues,
|
|
42
|
+
EMPTY_CELL,
|
|
43
|
+
formatCellValue,
|
|
44
|
+
isEmptyCell,
|
|
45
|
+
isNumericFormat,
|
|
46
|
+
normalizeTableSpec,
|
|
47
|
+
type ColumnSpec,
|
|
48
|
+
type TableRowData,
|
|
49
|
+
type TableSpec,
|
|
50
|
+
} from "./message-table-spec";
|
|
51
|
+
|
|
52
|
+
// ─── Sort ─────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export interface TableSort {
|
|
55
|
+
/** The sorted column's key. */
|
|
56
|
+
key: string;
|
|
57
|
+
direction: "asc" | "desc";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Per-cell render override — return `undefined` to fall back to default rendering. */
|
|
61
|
+
export type RenderCell = (args: {
|
|
62
|
+
value: unknown;
|
|
63
|
+
column: ColumnSpec;
|
|
64
|
+
row: TableRowData;
|
|
65
|
+
rowIndex: number;
|
|
66
|
+
}) => ReactNode;
|
|
67
|
+
|
|
68
|
+
// ─── Fallback ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export interface MessageTableFallbackProps extends HTMLAttributes<HTMLDivElement> {
|
|
71
|
+
/** Short human reason the table could not render. */
|
|
72
|
+
message?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shown when the spec is unusable. Mirrors `ChartFallback` semantics. */
|
|
76
|
+
export const MessageTableFallback = forwardRef<HTMLDivElement, MessageTableFallbackProps>(
|
|
77
|
+
function MessageTableFallback(
|
|
78
|
+
{ message = "This table could not be displayed.", className, ...props },
|
|
79
|
+
ref,
|
|
80
|
+
) {
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
ref={ref}
|
|
84
|
+
role="status"
|
|
85
|
+
aria-live="polite"
|
|
86
|
+
className={cn(
|
|
87
|
+
// Sole structural cue is the border (no fill) — reads in every theme,
|
|
88
|
+
// incl. themes where surface-muted ≈ background. border-strong is
|
|
89
|
+
// contrast-guaranteed vs the background.
|
|
90
|
+
"flex items-center justify-center rounded-md border border-border-strong px-4 py-6 text-center text-body text-muted-foreground",
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
{...props}
|
|
94
|
+
>
|
|
95
|
+
{message}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// ─── MessageTable ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export interface MessageTableProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
104
|
+
/** The serializable table specification produced by an LLM tool-call. */
|
|
105
|
+
spec: TableSpec | unknown;
|
|
106
|
+
/** Enable click-to-sort column headers. @default false */
|
|
107
|
+
sortable?: boolean;
|
|
108
|
+
/** Controlled sort. When provided, `onSortChange` is the only way to update it. */
|
|
109
|
+
sort?: TableSort | null;
|
|
110
|
+
/** Uncontrolled initial sort. */
|
|
111
|
+
defaultSort?: TableSort | null;
|
|
112
|
+
/** Called with the next sort (or `null`) when a header is clicked. */
|
|
113
|
+
onSortChange?: (sort: TableSort | null) => void;
|
|
114
|
+
/** Cap the number of rendered rows; shows a "Showing N of M rows" notice. */
|
|
115
|
+
maxRows?: number;
|
|
116
|
+
/** Per-cell render override for custom composition. */
|
|
117
|
+
renderCell?: RenderCell;
|
|
118
|
+
/** The spec is still streaming (columns-but-no-rows shows skeleton rows). */
|
|
119
|
+
streaming?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* MessageTable — renders a `TableSpec` as a lightweight message-body table.
|
|
124
|
+
*
|
|
125
|
+
* Intentionally bare: no toolbar, no expand chrome. Growing one here would be
|
|
126
|
+
* `ChartFrame` re-implemented inside this package.
|
|
127
|
+
*
|
|
128
|
+
* **To make it expandable, wrap it in `ExpandDialog`**
|
|
129
|
+
* (`@elabs-ai/components-ui`) — the shared two-pane expand surface,
|
|
130
|
+
* the same one a chart opens. Do NOT hand-roll a lightbox or a full-screen
|
|
131
|
+
* breakout; four of those already existed before the component was shared, and
|
|
132
|
+
* each one taught users a different meaning for the same button.
|
|
133
|
+
*
|
|
134
|
+
* The context pane for a table is its SHAPE, not statistics: row count,
|
|
135
|
+
* "showing N of M", column count, each column's declared `format`. A
|
|
136
|
+
* model-emitted table's numerics may already be formatted strings, so min/max/avg
|
|
137
|
+
* over them is wrong more often than right.
|
|
138
|
+
*/
|
|
139
|
+
export const MessageTable = forwardRef<HTMLDivElement, MessageTableProps>(function MessageTable(
|
|
140
|
+
{
|
|
141
|
+
spec,
|
|
142
|
+
sortable = false,
|
|
143
|
+
sort: sortProp,
|
|
144
|
+
defaultSort = null,
|
|
145
|
+
onSortChange,
|
|
146
|
+
maxRows,
|
|
147
|
+
renderCell,
|
|
148
|
+
streaming = false,
|
|
149
|
+
className,
|
|
150
|
+
...props
|
|
151
|
+
},
|
|
152
|
+
ref,
|
|
153
|
+
) {
|
|
154
|
+
const { t } = useLocale();
|
|
155
|
+
const reactId = useId();
|
|
156
|
+
const isControlled = sortProp !== undefined;
|
|
157
|
+
const [internalSort, setInternalSort] = useState<TableSort | null>(defaultSort);
|
|
158
|
+
const resolvedSort = isControlled ? (sortProp as TableSort | null) : internalSort;
|
|
159
|
+
|
|
160
|
+
const result = normalizeTableSpec(spec);
|
|
161
|
+
|
|
162
|
+
const normalized = result.ok ? result.spec : null;
|
|
163
|
+
|
|
164
|
+
const sortedRows = useMemo<TableRowData[]>(() => {
|
|
165
|
+
if (!normalized || !resolvedSort) return normalized?.rows ?? [];
|
|
166
|
+
const column = normalized.columns.find((c) => c.key === resolvedSort.key);
|
|
167
|
+
if (!column) return normalized.rows;
|
|
168
|
+
const direction = resolvedSort.direction === "asc" ? 1 : -1;
|
|
169
|
+
return [...normalized.rows].sort(
|
|
170
|
+
(a, b) => direction * compareCellValues(a[column.key], b[column.key], column.format),
|
|
171
|
+
);
|
|
172
|
+
}, [normalized, resolvedSort]);
|
|
173
|
+
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
return (
|
|
176
|
+
<MessageTableFallback ref={ref} message={result.reason} className={className} {...props} />
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { columns, title, emptyText } = result.spec;
|
|
181
|
+
|
|
182
|
+
// No columns: skeleton while streaming, otherwise a fallback.
|
|
183
|
+
if (columns.length === 0) {
|
|
184
|
+
if (streaming) {
|
|
185
|
+
return (
|
|
186
|
+
<div ref={ref} className={cn("w-full", className)} {...props}>
|
|
187
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
188
|
+
Loading table…
|
|
189
|
+
</span>
|
|
190
|
+
<div
|
|
191
|
+
aria-hidden="true"
|
|
192
|
+
className="flex flex-col gap-2 rounded-md border border-border p-3"
|
|
193
|
+
>
|
|
194
|
+
<Skeleton className="h-5 w-40" />
|
|
195
|
+
<Skeleton className="h-4 w-full" />
|
|
196
|
+
<Skeleton className="h-4 w-full" />
|
|
197
|
+
<Skeleton className="h-4 w-3/4" />
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return (
|
|
203
|
+
<MessageTableFallback
|
|
204
|
+
ref={ref}
|
|
205
|
+
message="This table has no columns to display."
|
|
206
|
+
className={className}
|
|
207
|
+
{...props}
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const total = sortedRows.length;
|
|
213
|
+
const cappedRows =
|
|
214
|
+
maxRows !== undefined && maxRows >= 0 ? sortedRows.slice(0, maxRows) : sortedRows;
|
|
215
|
+
const truncatedCount = total - cappedRows.length;
|
|
216
|
+
|
|
217
|
+
const titleId = title ? `${reactId}-title` : undefined;
|
|
218
|
+
|
|
219
|
+
const setSort = (next: TableSort | null) => {
|
|
220
|
+
if (!isControlled) setInternalSort(next);
|
|
221
|
+
onSortChange?.(next);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const toggleSort = (column: ColumnSpec) => {
|
|
225
|
+
if (resolvedSort?.key === column.key) {
|
|
226
|
+
setSort({ key: column.key, direction: resolvedSort.direction === "asc" ? "desc" : "asc" });
|
|
227
|
+
} else {
|
|
228
|
+
setSort({ key: column.key, direction: "asc" });
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const renderCellContent = (
|
|
233
|
+
column: ColumnSpec,
|
|
234
|
+
row: TableRowData,
|
|
235
|
+
rowIndex: number,
|
|
236
|
+
): ReactNode => {
|
|
237
|
+
const value = row[column.key];
|
|
238
|
+
if (renderCell) {
|
|
239
|
+
const overridden = renderCell({ value, column, row, rowIndex });
|
|
240
|
+
if (overridden !== undefined) return overridden;
|
|
241
|
+
}
|
|
242
|
+
if (isEmptyCell(value)) {
|
|
243
|
+
return <span className="text-muted-foreground">{EMPTY_CELL}</span>;
|
|
244
|
+
}
|
|
245
|
+
if (column.format === "badge") {
|
|
246
|
+
return <Badge variant={badgeToneForValue(value)}>{String(value)}</Badge>;
|
|
247
|
+
}
|
|
248
|
+
return formatCellValue(value, column.format);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div ref={ref} className={cn("w-full", className)} {...props}>
|
|
253
|
+
{title && (
|
|
254
|
+
<p id={titleId} className="mb-1 text-body font-semibold text-foreground text-balance">
|
|
255
|
+
{title}
|
|
256
|
+
</p>
|
|
257
|
+
)}
|
|
258
|
+
<div className="overflow-hidden rounded-md border border-border">
|
|
259
|
+
{streaming && cappedRows.length === 0 && (
|
|
260
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
261
|
+
Loading table…
|
|
262
|
+
</span>
|
|
263
|
+
)}
|
|
264
|
+
<Table
|
|
265
|
+
aria-labelledby={titleId}
|
|
266
|
+
aria-label={title ? undefined : t("ai.messageTable.label")}
|
|
267
|
+
aria-busy={(streaming && cappedRows.length === 0) || undefined}
|
|
268
|
+
>
|
|
269
|
+
{truncatedCount > 0 && (
|
|
270
|
+
<TableCaption className="mb-2 mt-3 px-3 text-caption">
|
|
271
|
+
Showing {cappedRows.length} of {total} rows
|
|
272
|
+
</TableCaption>
|
|
273
|
+
)}
|
|
274
|
+
<TableHeader>
|
|
275
|
+
<TableRow>
|
|
276
|
+
{columns.map((column) => {
|
|
277
|
+
const numeric = isNumericFormat(column.format);
|
|
278
|
+
const active = resolvedSort?.key === column.key;
|
|
279
|
+
const ariaSort = active
|
|
280
|
+
? resolvedSort?.direction === "asc"
|
|
281
|
+
? "ascending"
|
|
282
|
+
: "descending"
|
|
283
|
+
: sortable
|
|
284
|
+
? "none"
|
|
285
|
+
: undefined;
|
|
286
|
+
const label = column.label ?? column.key;
|
|
287
|
+
return (
|
|
288
|
+
<TableHead
|
|
289
|
+
key={column.key}
|
|
290
|
+
scope="col"
|
|
291
|
+
aria-sort={ariaSort}
|
|
292
|
+
className={cn(numeric && "text-end")}
|
|
293
|
+
>
|
|
294
|
+
{sortable ? (
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
onClick={() => toggleSort(column)}
|
|
298
|
+
className={cn(
|
|
299
|
+
"inline-flex items-center gap-1 rounded-sm font-medium",
|
|
300
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
301
|
+
"hover:text-foreground transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
302
|
+
numeric && "flex-row-reverse",
|
|
303
|
+
)}
|
|
304
|
+
>
|
|
305
|
+
<span>{label}</span>
|
|
306
|
+
{active ? (
|
|
307
|
+
resolvedSort?.direction === "asc" ? (
|
|
308
|
+
<ChevronUp aria-hidden="true" className="size-3.5" />
|
|
309
|
+
) : (
|
|
310
|
+
<ChevronDown aria-hidden="true" className="size-3.5" />
|
|
311
|
+
)
|
|
312
|
+
) : (
|
|
313
|
+
<ChevronsUpDown aria-hidden="true" className="size-3.5 opacity-50" />
|
|
314
|
+
)}
|
|
315
|
+
</button>
|
|
316
|
+
) : (
|
|
317
|
+
label
|
|
318
|
+
)}
|
|
319
|
+
</TableHead>
|
|
320
|
+
);
|
|
321
|
+
})}
|
|
322
|
+
</TableRow>
|
|
323
|
+
</TableHeader>
|
|
324
|
+
<TableBody>
|
|
325
|
+
{cappedRows.length === 0 && streaming ? (
|
|
326
|
+
[0, 1, 2].map((i) => (
|
|
327
|
+
<TableRow key={`skeleton-${i}`} aria-hidden="true">
|
|
328
|
+
{columns.map((column) => (
|
|
329
|
+
<TableCell key={column.key}>
|
|
330
|
+
<Skeleton className="h-4 w-full" />
|
|
331
|
+
</TableCell>
|
|
332
|
+
))}
|
|
333
|
+
</TableRow>
|
|
334
|
+
))
|
|
335
|
+
) : cappedRows.length === 0 ? (
|
|
336
|
+
<TableRow>
|
|
337
|
+
<TableCell
|
|
338
|
+
colSpan={columns.length}
|
|
339
|
+
className="py-6 text-center text-muted-foreground"
|
|
340
|
+
>
|
|
341
|
+
{emptyText ?? "No rows to display"}
|
|
342
|
+
</TableCell>
|
|
343
|
+
</TableRow>
|
|
344
|
+
) : (
|
|
345
|
+
cappedRows.map((row, rowIndex) => (
|
|
346
|
+
<TableRow key={rowIndex}>
|
|
347
|
+
{columns.map((column) => {
|
|
348
|
+
const numeric = isNumericFormat(column.format);
|
|
349
|
+
return (
|
|
350
|
+
<TableCell
|
|
351
|
+
key={column.key}
|
|
352
|
+
className={cn(numeric && "text-end tabular-nums")}
|
|
353
|
+
>
|
|
354
|
+
{renderCellContent(column, row, rowIndex)}
|
|
355
|
+
</TableCell>
|
|
356
|
+
);
|
|
357
|
+
})}
|
|
358
|
+
</TableRow>
|
|
359
|
+
))
|
|
360
|
+
)}
|
|
361
|
+
</TableBody>
|
|
362
|
+
</Table>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, userEvent } from "storybook/test";
|
|
4
|
+
import {
|
|
5
|
+
Copy as CopyIcon,
|
|
6
|
+
Pencil as PencilIcon,
|
|
7
|
+
Pin as PinIcon,
|
|
8
|
+
RotateCcw as RotateCcwIcon,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import {
|
|
11
|
+
AgentMessage,
|
|
12
|
+
Message,
|
|
13
|
+
MessageAction,
|
|
14
|
+
MessageActions,
|
|
15
|
+
MessageAvatar,
|
|
16
|
+
MessageBranch,
|
|
17
|
+
MessageBranchContent,
|
|
18
|
+
MessageBranchNext,
|
|
19
|
+
MessageBranchPage,
|
|
20
|
+
MessageBranchPrevious,
|
|
21
|
+
MessageBranchSelector,
|
|
22
|
+
MessageContent,
|
|
23
|
+
MessageHeader,
|
|
24
|
+
MessageResponse,
|
|
25
|
+
UserMessage,
|
|
26
|
+
} from "./message";
|
|
27
|
+
import { SourceList } from "./sources";
|
|
28
|
+
const meta = {
|
|
29
|
+
title: "AI/Message",
|
|
30
|
+
component: Message,
|
|
31
|
+
parameters: { layout: "padded" },
|
|
32
|
+
} satisfies Meta<typeof Message>;
|
|
33
|
+
export default meta;
|
|
34
|
+
type Story = StoryObj<typeof meta>;
|
|
35
|
+
export const Conversation_: Story = {
|
|
36
|
+
name: "User + Assistant",
|
|
37
|
+
render: () => (
|
|
38
|
+
<div className="space-y-4">
|
|
39
|
+
<Message from="user">
|
|
40
|
+
<MessageContent>Summarize last week's deploys.</MessageContent>
|
|
41
|
+
</Message>
|
|
42
|
+
<Message from="assistant">
|
|
43
|
+
<MessageContent>Three services shipped; billing is degraded.</MessageContent>
|
|
44
|
+
</Message>
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
// The named preset wrappers (#191, research 11 §B.2): UserMessage / AgentMessage
|
|
49
|
+
// with the MessageHeader identity slot.
|
|
50
|
+
export const Presets: Story = {
|
|
51
|
+
name: "UserMessage + AgentMessage",
|
|
52
|
+
render: () => (
|
|
53
|
+
<div className="space-y-4">
|
|
54
|
+
<UserMessage>
|
|
55
|
+
<MessageContent>How did churn develop this quarter?</MessageContent>
|
|
56
|
+
</UserMessage>
|
|
57
|
+
<AgentMessage>
|
|
58
|
+
<MessageHeader>
|
|
59
|
+
<MessageAvatar name="Atlas" role="agent" />
|
|
60
|
+
<span>Atlas</span>
|
|
61
|
+
</MessageHeader>
|
|
62
|
+
<MessageContent>Pulling the retention cohorts now…</MessageContent>
|
|
63
|
+
</AgentMessage>
|
|
64
|
+
</div>
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
// emphasis="answer" = the green left rail on the wrapper; prose + grounding
|
|
68
|
+
// footer are COMPOSED children, not baked-in layout.
|
|
69
|
+
export const FinalAnswer: Story = {
|
|
70
|
+
name: "AgentMessage emphasis=answer",
|
|
71
|
+
render: () => (
|
|
72
|
+
<AgentMessage emphasis="answer">
|
|
73
|
+
<MessageHeader>
|
|
74
|
+
<MessageAvatar name="Atlas" role="agent" />
|
|
75
|
+
<span>Atlas</span>
|
|
76
|
+
</MessageHeader>
|
|
77
|
+
<MessageContent>
|
|
78
|
+
<MessageResponse>{`## Churn improved this quarter
|
|
79
|
+
|
|
80
|
+
Enterprise churn fell **0.4pp** to 1.8%, driven by the renewal-desk rollout:
|
|
81
|
+
|
|
82
|
+
- 12 at-risk accounts recovered
|
|
83
|
+
- \`auto-renew\` adoption up 9pp`}</MessageResponse>
|
|
84
|
+
<SourceList
|
|
85
|
+
sources={[
|
|
86
|
+
{ href: "https://warehouse.acme.com/q3-retention", title: "Q3 retention cohort" },
|
|
87
|
+
{ href: "https://crm.acme.com/renewals", title: "Renewal desk log" },
|
|
88
|
+
]}
|
|
89
|
+
/>
|
|
90
|
+
</MessageContent>
|
|
91
|
+
</AgentMessage>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
// LOADING — no tokens have arrived yet (loading-states.md `loading`).
|
|
95
|
+
// `MessageResponse` renders skeleton lines at the body line-height instead of
|
|
96
|
+
// `<Streamdown>`, so the bubble reserves its space before the first token.
|
|
97
|
+
export const Loading: Story = {
|
|
98
|
+
name: "MessageResponse loading",
|
|
99
|
+
render: () => (
|
|
100
|
+
<AgentMessage>
|
|
101
|
+
<MessageHeader>
|
|
102
|
+
<MessageAvatar name="Atlas" role="agent" />
|
|
103
|
+
<span>Atlas</span>
|
|
104
|
+
</MessageHeader>
|
|
105
|
+
<MessageContent>
|
|
106
|
+
<MessageResponse loading />
|
|
107
|
+
</MessageContent>
|
|
108
|
+
</AgentMessage>
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
// Controlled MessageBranch (#361) — an external control ("Jump to reply N")
|
|
112
|
+
// drives the SAME `branch` state that the in-component Previous/Next controls
|
|
113
|
+
// mutate via `onBranchChange`, proving the host can own branch selection
|
|
114
|
+
// (e.g. restore it from a URL param) without remounting the subtree.
|
|
115
|
+
export const BranchControlled: Story = {
|
|
116
|
+
name: "MessageBranch — controlled",
|
|
117
|
+
render: function Render() {
|
|
118
|
+
const [branch, setBranch] = useState(0);
|
|
119
|
+
return (
|
|
120
|
+
<div className="space-y-3">
|
|
121
|
+
<div className="flex gap-2">
|
|
122
|
+
<button
|
|
123
|
+
className="rounded-md border px-2 py-1 text-caption"
|
|
124
|
+
onClick={() => setBranch(0)}
|
|
125
|
+
type="button"
|
|
126
|
+
>
|
|
127
|
+
Jump to reply 1
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
className="rounded-md border px-2 py-1 text-caption"
|
|
131
|
+
onClick={() => setBranch(2)}
|
|
132
|
+
type="button"
|
|
133
|
+
>
|
|
134
|
+
Jump to reply 3
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
<MessageBranch branch={branch} onBranchChange={setBranch}>
|
|
138
|
+
<MessageBranchContent>
|
|
139
|
+
<AgentMessage key="concise">
|
|
140
|
+
<MessageContent>Here's a concise answer.</MessageContent>
|
|
141
|
+
</AgentMessage>
|
|
142
|
+
<AgentMessage key="detailed">
|
|
143
|
+
<MessageContent>Here's a longer answer, with more context.</MessageContent>
|
|
144
|
+
</AgentMessage>
|
|
145
|
+
<AgentMessage key="alternate">
|
|
146
|
+
<MessageContent>Here's a third, differently-phrased answer.</MessageContent>
|
|
147
|
+
</AgentMessage>
|
|
148
|
+
</MessageBranchContent>
|
|
149
|
+
<MessageBranchSelector>
|
|
150
|
+
<MessageBranchPrevious />
|
|
151
|
+
<MessageBranchPage />
|
|
152
|
+
<MessageBranchNext />
|
|
153
|
+
</MessageBranchSelector>
|
|
154
|
+
</MessageBranch>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
play: async ({ canvas }) => {
|
|
159
|
+
await expect(canvas.getByText("1 of 3")).toBeInTheDocument();
|
|
160
|
+
|
|
161
|
+
await userEvent.click(canvas.getByRole("button", { name: "Jump to reply 3" }));
|
|
162
|
+
await expect(canvas.getByText("3 of 3")).toBeInTheDocument();
|
|
163
|
+
|
|
164
|
+
// The component's own Previous control still drives the SAME external
|
|
165
|
+
// state — it doesn't fall back to internal tracking in controlled mode.
|
|
166
|
+
await userEvent.click(canvas.getByRole("button", { name: "Previous branch" }));
|
|
167
|
+
await expect(canvas.getByText("2 of 3")).toBeInTheDocument();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
// ACTIONS, always visible (`appearance="plain" reveal="always"` — the defaults,
|
|
171
|
+
// set explicitly here so both default values are exercised in a RENDERED
|
|
172
|
+
// position, not only in the controls panel).
|
|
173
|
+
export const ActionsPlain: Story = {
|
|
174
|
+
name: "MessageActions — inline",
|
|
175
|
+
render: () => (
|
|
176
|
+
<AgentMessage>
|
|
177
|
+
<MessageContent>Three services shipped; billing is degraded.</MessageContent>
|
|
178
|
+
<MessageActions appearance="plain" reveal="always">
|
|
179
|
+
<MessageAction onClick={() => {}} tooltip="Copy">
|
|
180
|
+
<CopyIcon aria-hidden="true" size={14} />
|
|
181
|
+
</MessageAction>
|
|
182
|
+
<MessageAction onClick={() => {}} tooltip="Pin">
|
|
183
|
+
<PinIcon aria-hidden="true" size={14} />
|
|
184
|
+
</MessageAction>
|
|
185
|
+
</MessageActions>
|
|
186
|
+
</AgentMessage>
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
// ACTIONS, hover-revealed floating pill — the chat-client shape. The row is
|
|
190
|
+
// `opacity-0` at rest on a FINE pointer only and fades in from the parent
|
|
191
|
+
// `Message`'s hover; on a coarse (touch) pointer it never hides, since nothing
|
|
192
|
+
// there could reveal it.
|
|
193
|
+
//
|
|
194
|
+
// The controls stay in the tab order the whole time — `focus-within` is what
|
|
195
|
+
// brings the pill back for a keyboard user, which is why the row is NOT
|
|
196
|
+
// `hidden`/`invisible`/unmounted at rest.
|
|
197
|
+
export const ActionsHoverBar: Story = {
|
|
198
|
+
name: "MessageActions — hover pill",
|
|
199
|
+
render: function Render() {
|
|
200
|
+
const [pinned, setPinned] = useState(false);
|
|
201
|
+
return (
|
|
202
|
+
<div className="space-y-4">
|
|
203
|
+
<AgentMessage>
|
|
204
|
+
<MessageHeader>
|
|
205
|
+
<MessageAvatar name="Atlas" role="agent" />
|
|
206
|
+
<span>Atlas</span>
|
|
207
|
+
</MessageHeader>
|
|
208
|
+
<MessageContent>
|
|
209
|
+
Retention held at 94% — the dip you saw was a reporting lag, not churn.
|
|
210
|
+
</MessageContent>
|
|
211
|
+
<MessageActions appearance="bar" reveal="hover">
|
|
212
|
+
<MessageAction onClick={() => {}} tooltip="Copy">
|
|
213
|
+
<CopyIcon aria-hidden="true" size={14} />
|
|
214
|
+
</MessageAction>
|
|
215
|
+
{/* Pin is a TOGGLE the host owns — the component ships no pin state.
|
|
216
|
+
`aria-pressed` + the filled glyph are the two channels (a11y.md:
|
|
217
|
+
colour is never the only one). */}
|
|
218
|
+
<MessageAction
|
|
219
|
+
aria-pressed={pinned}
|
|
220
|
+
onClick={() => setPinned((v) => !v)}
|
|
221
|
+
tooltip={pinned ? "Unpin" : "Pin"}
|
|
222
|
+
>
|
|
223
|
+
<PinIcon
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
className={pinned ? "fill-current" : undefined}
|
|
226
|
+
size={14}
|
|
227
|
+
/>
|
|
228
|
+
</MessageAction>
|
|
229
|
+
<MessageAction onClick={() => {}} tooltip="Retry">
|
|
230
|
+
<RotateCcwIcon aria-hidden="true" size={14} />
|
|
231
|
+
</MessageAction>
|
|
232
|
+
</MessageActions>
|
|
233
|
+
</AgentMessage>
|
|
234
|
+
<UserMessage>
|
|
235
|
+
<MessageContent>Can you break that down by region?</MessageContent>
|
|
236
|
+
<MessageActions appearance="bar" reveal="hover">
|
|
237
|
+
<MessageAction onClick={() => {}} tooltip="Copy">
|
|
238
|
+
<CopyIcon aria-hidden="true" size={14} />
|
|
239
|
+
</MessageAction>
|
|
240
|
+
<MessageAction onClick={() => {}} tooltip="Edit">
|
|
241
|
+
<PencilIcon aria-hidden="true" size={14} />
|
|
242
|
+
</MessageAction>
|
|
243
|
+
</MessageActions>
|
|
244
|
+
</UserMessage>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
play: async ({ canvas }) => {
|
|
249
|
+
// Hover-revealed ≠ removed: the controls are real, named, reachable buttons
|
|
250
|
+
// at rest, which is the whole reason the row fades rather than unmounts.
|
|
251
|
+
const groups = canvas.getAllByRole("group", { name: "Message actions" });
|
|
252
|
+
await expect(groups).toHaveLength(2);
|
|
253
|
+
|
|
254
|
+
const pin = canvas.getByRole("button", { name: "Pin" });
|
|
255
|
+
await expect(pin).toHaveAttribute("aria-pressed", "false");
|
|
256
|
+
await userEvent.click(pin);
|
|
257
|
+
await expect(canvas.getByRole("button", { name: "Unpin" })).toHaveAttribute(
|
|
258
|
+
"aria-pressed",
|
|
259
|
+
"true",
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
};
|