@elabs-ai/components-ai 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * message-table-spec.ts — Serializable TableSpec for MessageTable.
3
+ *
4
+ * Pure data types + zod schemas (no React). The shape is what an LLM tool-call
5
+ * emits when it wants to show tabular data inside a chat message. It is
6
+ * column-oriented: `columns` declare the shape + per-column `format`, and each
7
+ * `rows` entry is an object keyed by column `key`.
8
+ *
9
+ * The exported zod schemas are the source of truth downstream catalogs compile
10
+ * prompts + validators from. Specs carry NO style-bearing props — the model
11
+ * chooses the data and its semantic format, never the look.
12
+ */
13
+
14
+ import { z } from "zod";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Columns
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * How a cell's value is rendered + aligned.
22
+ * - `text` (default) — as-is, start-aligned.
23
+ * - `number` / `currency` / `percent` — `Intl`-formatted, end-aligned, tabular.
24
+ * - `date` — localized date.
25
+ * - `badge` — a tonal badge whose tone is inferred from the value's semantics.
26
+ */
27
+ export const cellFormatSchema = z.enum(["text", "number", "currency", "percent", "date", "badge"]);
28
+ export type CellFormat = z.infer<typeof cellFormatSchema>;
29
+
30
+ /** One column definition. */
31
+ export const columnSpecSchema = z.object({
32
+ /** The row-object key this column reads. */
33
+ key: z.string(),
34
+ /** Header label. Falls back to the `key` when omitted. */
35
+ label: z.string().optional(),
36
+ /** How cells in this column are formatted + aligned. @default "text" */
37
+ format: cellFormatSchema.optional(),
38
+ });
39
+ export type ColumnSpec = z.infer<typeof columnSpecSchema>;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // TableSpec
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** A single row: an object keyed by column `key`. */
46
+ export type TableRowData = Record<string, unknown>;
47
+
48
+ /**
49
+ * The serializable table specification produced by an LLM tool-call.
50
+ * MessageTable reads this and renders a lightweight, message-body table.
51
+ */
52
+ export const tableSpecSchema = z.object({
53
+ /** Optional heading + accessible label for the table. */
54
+ title: z.string().optional(),
55
+ /** Ordered column definitions. */
56
+ columns: z.array(columnSpecSchema),
57
+ /** Row objects keyed by column `key`. */
58
+ rows: z.array(z.record(z.string(), z.unknown())),
59
+ /** Message shown when there are zero rows. @default "No rows to display" */
60
+ emptyText: z.string().optional(),
61
+ });
62
+ export type TableSpec = z.infer<typeof tableSpecSchema>;
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Normalization (lenient — streaming-tolerant, never throws)
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /** A validated, render-ready TableSpec. */
69
+ export interface NormalizedTableSpec {
70
+ title?: string;
71
+ columns: ColumnSpec[];
72
+ rows: TableRowData[];
73
+ emptyText?: string;
74
+ }
75
+
76
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+
80
+ function strOrUndefined(value: unknown): string | undefined {
81
+ return typeof value === "string" ? value : undefined;
82
+ }
83
+
84
+ /**
85
+ * Lenient parse for the render path. NEVER throws. Individually invalid columns
86
+ * are dropped and non-object rows are filtered out, so a partial (streaming)
87
+ * spec still renders progressively. Returns `{ ok: false }` only when the whole
88
+ * spec is unusable (not an object).
89
+ */
90
+ export function normalizeTableSpec(
91
+ spec: unknown,
92
+ ): { ok: true; spec: NormalizedTableSpec } | { ok: false; reason: string } {
93
+ if (!isPlainObject(spec)) {
94
+ return { ok: false, reason: "Table specification is missing or malformed." };
95
+ }
96
+
97
+ const columns: ColumnSpec[] = [];
98
+ const seen = new Set<string>();
99
+ if (Array.isArray(spec.columns)) {
100
+ for (const candidate of spec.columns) {
101
+ const parsed = columnSpecSchema.safeParse(candidate);
102
+ if (!parsed.success) continue; // drop invalid / still-streaming column
103
+ if (seen.has(parsed.data.key)) continue; // de-dupe by key
104
+ seen.add(parsed.data.key);
105
+ columns.push(parsed.data);
106
+ }
107
+ }
108
+
109
+ const rows: TableRowData[] = Array.isArray(spec.rows) ? spec.rows.filter(isPlainObject) : [];
110
+
111
+ return {
112
+ ok: true,
113
+ spec: {
114
+ title: strOrUndefined(spec.title),
115
+ columns,
116
+ rows,
117
+ emptyText: strOrUndefined(spec.emptyText),
118
+ },
119
+ };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Formatting
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /** The em-dash shown for a missing / empty cell. */
127
+ export const EMPTY_CELL = "—";
128
+
129
+ /** Whether a format is numeric (drives end-alignment + `tabular-nums`). */
130
+ export function isNumericFormat(format: CellFormat | undefined): boolean {
131
+ return format === "number" || format === "currency" || format === "percent";
132
+ }
133
+
134
+ /** Whether a value is considered empty (renders the em-dash). */
135
+ export function isEmptyCell(value: unknown): boolean {
136
+ return value === null || value === undefined || value === "";
137
+ }
138
+
139
+ function toNumber(value: unknown): number {
140
+ if (typeof value === "number") return value;
141
+ if (typeof value === "string" && value.trim() !== "") return Number(value);
142
+ return Number.NaN;
143
+ }
144
+
145
+ const numberFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
146
+ const currencyFormatter = new Intl.NumberFormat(undefined, {
147
+ style: "currency",
148
+ currency: "USD",
149
+ maximumFractionDigits: 0,
150
+ });
151
+ const percentFormatter = new Intl.NumberFormat(undefined, {
152
+ style: "percent",
153
+ maximumFractionDigits: 1,
154
+ });
155
+
156
+ /**
157
+ * Format a present value to a display string per its column format.
158
+ * Unknown formats fall through to `String(value)` (never throws). Empty values
159
+ * are the caller's concern (render `EMPTY_CELL`). `percent` follows `Intl`
160
+ * semantics — pass fractions (0.42 → "42%").
161
+ */
162
+ export function formatCellValue(value: unknown, format: CellFormat | undefined): string {
163
+ switch (format) {
164
+ case "number": {
165
+ const n = toNumber(value);
166
+ return Number.isNaN(n) ? String(value) : numberFormatter.format(n);
167
+ }
168
+ case "currency": {
169
+ const n = toNumber(value);
170
+ return Number.isNaN(n) ? String(value) : currencyFormatter.format(n);
171
+ }
172
+ case "percent": {
173
+ const n = toNumber(value);
174
+ return Number.isNaN(n) ? String(value) : percentFormatter.format(n);
175
+ }
176
+ case "date": {
177
+ const d = value instanceof Date ? value : new Date(String(value));
178
+ return Number.isNaN(d.getTime())
179
+ ? String(value)
180
+ : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
181
+ }
182
+ default:
183
+ return String(value);
184
+ }
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Badge tone inference (semantic, not style — the spec never picks a tone)
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export type BadgeTone = "secondary" | "success" | "warning" | "destructive" | "info";
192
+
193
+ const SUCCESS_WORDS = new Set([
194
+ "active",
195
+ "success",
196
+ "successful",
197
+ "complete",
198
+ "completed",
199
+ "done",
200
+ "passed",
201
+ "pass",
202
+ "approved",
203
+ "ok",
204
+ "online",
205
+ "enabled",
206
+ "healthy",
207
+ "live",
208
+ "paid",
209
+ "yes",
210
+ "true",
211
+ ]);
212
+ const WARNING_WORDS = new Set([
213
+ "pending",
214
+ "warning",
215
+ "in progress",
216
+ "in-progress",
217
+ "review",
218
+ "in review",
219
+ "waiting",
220
+ "partial",
221
+ "degraded",
222
+ "low",
223
+ "hold",
224
+ "on hold",
225
+ ]);
226
+ const DESTRUCTIVE_WORDS = new Set([
227
+ "failed",
228
+ "fail",
229
+ "error",
230
+ "rejected",
231
+ "inactive",
232
+ "offline",
233
+ "disabled",
234
+ "blocked",
235
+ "cancelled",
236
+ "canceled",
237
+ "overdue",
238
+ "no",
239
+ "false",
240
+ "critical",
241
+ "high",
242
+ "expired",
243
+ ]);
244
+ const INFO_WORDS = new Set(["info", "new", "draft", "queued", "scheduled", "beta", "todo", "open"]);
245
+
246
+ /** Infer a badge tone from a value's semantics (status vocabulary). */
247
+ export function badgeToneForValue(value: unknown): BadgeTone {
248
+ const key = String(value).trim().toLowerCase();
249
+ if (SUCCESS_WORDS.has(key)) return "success";
250
+ if (WARNING_WORDS.has(key)) return "warning";
251
+ if (DESTRUCTIVE_WORDS.has(key)) return "destructive";
252
+ if (INFO_WORDS.has(key)) return "info";
253
+ return "secondary";
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Sorting
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** Compare two cell values for sorting, honoring numeric/date formats. */
261
+ export function compareCellValues(a: unknown, b: unknown, format: CellFormat | undefined): number {
262
+ const aEmpty = isEmptyCell(a);
263
+ const bEmpty = isEmptyCell(b);
264
+ if (aEmpty && bEmpty) return 0;
265
+ if (aEmpty) return 1; // empties sort last
266
+ if (bEmpty) return -1;
267
+
268
+ if (isNumericFormat(format)) {
269
+ return toNumber(a) - toNumber(b);
270
+ }
271
+ if (format === "date") {
272
+ const da = new Date(String(a)).getTime();
273
+ const db = new Date(String(b)).getTime();
274
+ return (Number.isNaN(da) ? 0 : da) - (Number.isNaN(db) ? 0 : db);
275
+ }
276
+ // Mixed/auto: numeric when both parse as numbers, else locale string compare.
277
+ const na = toNumber(a);
278
+ const nb = toNumber(b);
279
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
280
+ return String(a).localeCompare(String(b));
281
+ }
@@ -0,0 +1,176 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { MessageTable, type TableSort } from "./message-table";
4
+ import type { TableSpec } from "./message-table-spec";
5
+
6
+ const meta = {
7
+ title: "AI/MessageTable",
8
+ component: MessageTable,
9
+ tags: ["autodocs"],
10
+ parameters: { layout: "padded" },
11
+ } satisfies Meta<typeof MessageTable>;
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ // A representative spec an LLM tool-call might emit inside a chat message.
16
+ const invoicesSpec: TableSpec = {
17
+ title: "Recent invoices",
18
+ columns: [
19
+ { key: "id", label: "Invoice" },
20
+ { key: "customer", label: "Customer" },
21
+ { key: "amount", label: "Amount", format: "currency" },
22
+ { key: "margin", label: "Margin", format: "percent" },
23
+ { key: "due", label: "Due", format: "date" },
24
+ { key: "status", label: "Status", format: "badge" },
25
+ ],
26
+ rows: [
27
+ {
28
+ id: "INV-1042",
29
+ customer: "Acme",
30
+ amount: 12400,
31
+ margin: 0.32,
32
+ due: "2026-08-01",
33
+ status: "Paid",
34
+ },
35
+ {
36
+ id: "INV-1043",
37
+ customer: "Globex",
38
+ amount: 8800,
39
+ margin: 0.21,
40
+ due: "2026-08-05",
41
+ status: "Pending",
42
+ },
43
+ {
44
+ id: "INV-1044",
45
+ customer: "Initech",
46
+ amount: 21500,
47
+ margin: 0.44,
48
+ due: "2026-07-20",
49
+ status: "Overdue",
50
+ },
51
+ {
52
+ id: "INV-1045",
53
+ customer: "Umbrella",
54
+ amount: 3600,
55
+ margin: 0.12,
56
+ due: "2026-08-12",
57
+ status: "Draft",
58
+ },
59
+ ],
60
+ };
61
+
62
+ /** The default: formatted columns (currency/percent right-aligned, tonal badge). */
63
+ export const Default: Story = {
64
+ args: { spec: invoicesSpec },
65
+ };
66
+
67
+ /** Sortable — click a header to sort; numeric columns sort numerically. */
68
+ export const Sortable: Story = {
69
+ args: { spec: invoicesSpec, sortable: true },
70
+ };
71
+
72
+ /** Controlled sort — the parent owns the sort state. */
73
+ export const ControlledSort: Story = {
74
+ render: function ControlledSort() {
75
+ const [sort, setSort] = useState<TableSort | null>({ key: "amount", direction: "desc" });
76
+ return (
77
+ <div className="flex flex-col gap-3">
78
+ <p className="text-caption text-muted-foreground">
79
+ Sort: {sort ? `${sort.key} ${sort.direction}` : "none"}
80
+ </p>
81
+ <MessageTable spec={invoicesSpec} sortable sort={sort} onSortChange={setSort} />
82
+ </div>
83
+ );
84
+ },
85
+ };
86
+
87
+ /** Truncated — `maxRows` caps the display and shows a "Showing N of M" notice. */
88
+ export const Truncated: Story = {
89
+ args: {
90
+ spec: {
91
+ title: "Top accounts",
92
+ columns: [
93
+ { key: "name", label: "Account" },
94
+ { key: "arr", label: "ARR", format: "currency" },
95
+ ],
96
+ rows: Array.from({ length: 40 }, (_, i) => ({
97
+ name: `Account ${i + 1}`,
98
+ arr: (40 - i) * 1000,
99
+ })),
100
+ },
101
+ maxRows: 5,
102
+ },
103
+ };
104
+
105
+ /** Streaming / partial — columns known, rows still arriving → skeleton rows. */
106
+ export const StreamingRows: Story = {
107
+ args: {
108
+ streaming: true,
109
+ spec: { title: "Loading results", columns: invoicesSpec.columns, rows: [] },
110
+ },
111
+ };
112
+
113
+ /** Missing cells render an em-dash — a half-arrived row never crashes. */
114
+ export const PartialRows: Story = {
115
+ args: {
116
+ streaming: true,
117
+ spec: {
118
+ title: "Streaming rows",
119
+ columns: invoicesSpec.columns,
120
+ rows: [
121
+ {
122
+ id: "INV-1042",
123
+ customer: "Acme",
124
+ amount: 12400,
125
+ margin: 0.32,
126
+ due: "2026-08-01",
127
+ status: "Paid",
128
+ },
129
+ { id: "INV-1043", customer: "Globex" }, // rest not arrived yet
130
+ ],
131
+ },
132
+ },
133
+ };
134
+
135
+ /** Empty (not streaming) — a real empty state row, never broken UI. */
136
+ export const Empty: Story = {
137
+ args: {
138
+ spec: {
139
+ title: "No results",
140
+ columns: [
141
+ { key: "name", label: "Name" },
142
+ { key: "role", label: "Role" },
143
+ ],
144
+ rows: [],
145
+ emptyText: "No teammates match this filter.",
146
+ },
147
+ },
148
+ };
149
+
150
+ /** Fallback — a non-object spec renders a typed fallback (no throw). */
151
+ export const Fallback: Story = {
152
+ args: { spec: 42 as unknown },
153
+ };
154
+
155
+ /** Per-cell override — compose custom cell content (like ChangeReview.renderHunk). */
156
+ export const CustomCell: Story = {
157
+ args: {
158
+ spec: invoicesSpec,
159
+ renderCell: ({ value, column }) =>
160
+ column.key === "customer" ? (
161
+ <span className="font-medium text-foreground">{String(value)}</span>
162
+ ) : undefined,
163
+ },
164
+ };
165
+
166
+ /** Dark theme — verifies token-driven surfaces + dividers in dark. */
167
+ export const DarkTheme: Story = {
168
+ args: { spec: invoicesSpec, sortable: true },
169
+ decorators: [
170
+ (Story) => (
171
+ <div data-theme="dark" className="rounded-lg bg-background p-6 text-foreground">
172
+ <Story />
173
+ </div>
174
+ ),
175
+ ],
176
+ };
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen, within } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { MessageTable } from "./message-table";
5
+ import {
6
+ badgeToneForValue,
7
+ compareCellValues,
8
+ formatCellValue,
9
+ normalizeTableSpec,
10
+ type TableSpec,
11
+ } from "./message-table-spec";
12
+
13
+ const spec: TableSpec = {
14
+ title: "Invoices",
15
+ columns: [
16
+ { key: "id", label: "Invoice" },
17
+ { key: "amount", label: "Amount", format: "currency" },
18
+ { key: "status", label: "Status", format: "badge" },
19
+ ],
20
+ rows: [
21
+ { id: "INV-2", amount: 300, status: "Pending" },
22
+ { id: "INV-1", amount: 100, status: "Paid" },
23
+ ],
24
+ };
25
+
26
+ describe("MessageTable — rendering", () => {
27
+ it("renders the title, headers and rows", () => {
28
+ render(<MessageTable spec={spec} />);
29
+ expect(screen.getByText("Invoices")).toBeInTheDocument();
30
+ expect(screen.getByRole("columnheader", { name: "Invoice" })).toBeInTheDocument();
31
+ expect(screen.getByText("INV-1")).toBeInTheDocument();
32
+ expect(screen.getByText("INV-2")).toBeInTheDocument();
33
+ });
34
+
35
+ it("labels the table via its title", () => {
36
+ render(<MessageTable spec={spec} />);
37
+ expect(screen.getByRole("table", { name: "Invoices" })).toBeInTheDocument();
38
+ });
39
+
40
+ it("renders an em-dash for a missing cell (never crashes)", () => {
41
+ render(<MessageTable spec={{ columns: spec.columns, rows: [{ id: "INV-9" }] }} />);
42
+ expect(screen.getAllByText("—").length).toBeGreaterThan(0);
43
+ });
44
+
45
+ it("renders a real empty state (not streaming)", () => {
46
+ render(<MessageTable spec={{ columns: spec.columns, rows: [], emptyText: "Nothing here." }} />);
47
+ expect(screen.getByText("Nothing here.")).toBeInTheDocument();
48
+ });
49
+
50
+ it("shows a truncation notice when maxRows caps the rows", () => {
51
+ render(<MessageTable spec={spec} maxRows={1} />);
52
+ expect(screen.getByText("Showing 1 of 2 rows")).toBeInTheDocument();
53
+ });
54
+ });
55
+
56
+ describe("MessageTable — never throws / fallback", () => {
57
+ it("renders a fallback for a non-object spec", () => {
58
+ render(<MessageTable spec={42 as unknown} />);
59
+ expect(screen.getByText(/missing or malformed|could not/i)).toBeInTheDocument();
60
+ });
61
+
62
+ it("renders a fallback when there are no columns (not streaming)", () => {
63
+ render(<MessageTable spec={{ columns: [], rows: [] }} />);
64
+ expect(screen.getByText(/no columns/i)).toBeInTheDocument();
65
+ });
66
+
67
+ it("renders skeleton rows while streaming with no rows yet", () => {
68
+ const { container } = render(
69
+ <MessageTable spec={{ columns: spec.columns, rows: [] }} streaming />,
70
+ );
71
+ expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
72
+ });
73
+ });
74
+
75
+ describe("MessageTable — sorting", () => {
76
+ it("sorts numerically when a header is clicked (uncontrolled)", async () => {
77
+ const user = userEvent.setup();
78
+ render(<MessageTable spec={spec} sortable />);
79
+ await user.click(screen.getByRole("button", { name: /Amount/ }));
80
+ const rows = screen.getAllByRole("row");
81
+ // rows[0] is the header row; first body row should be the smaller amount asc.
82
+ const firstBody = within(rows[1] as HTMLElement).getByText("INV-1");
83
+ expect(firstBody).toBeInTheDocument();
84
+ // aria-sort is set on the sorted header.
85
+ expect(screen.getByRole("columnheader", { name: /Amount/ })).toHaveAttribute(
86
+ "aria-sort",
87
+ "ascending",
88
+ );
89
+ });
90
+
91
+ it("supports controlled sort via onSortChange", async () => {
92
+ const user = userEvent.setup();
93
+ const onSortChange = vi.fn();
94
+ render(<MessageTable spec={spec} sortable sort={null} onSortChange={onSortChange} />);
95
+ await user.click(screen.getByRole("button", { name: /Invoice/ }));
96
+ expect(onSortChange).toHaveBeenCalledWith({ key: "id", direction: "asc" });
97
+ });
98
+ });
99
+
100
+ describe("MessageTable — per-cell override", () => {
101
+ it("uses renderCell when it returns a node, else falls back", () => {
102
+ render(
103
+ <MessageTable
104
+ spec={spec}
105
+ renderCell={({ value, column }) =>
106
+ column.key === "id" ? <a href="#x">{String(value)}</a> : undefined
107
+ }
108
+ />,
109
+ );
110
+ expect(screen.getByRole("link", { name: "INV-1" })).toBeInTheDocument();
111
+ // Non-overridden column still formats currency.
112
+ expect(screen.getByText("$100")).toBeInTheDocument();
113
+ });
114
+ });
115
+
116
+ describe("message-table-spec", () => {
117
+ it("normalizeTableSpec drops invalid columns + non-object rows", () => {
118
+ const result = normalizeTableSpec({
119
+ columns: [{ key: "a" }, { label: "no key" }],
120
+ rows: [{ a: 1 }, "not a row", 5],
121
+ });
122
+ expect(result.ok).toBe(true);
123
+ if (result.ok) {
124
+ expect(result.spec.columns).toHaveLength(1);
125
+ expect(result.spec.rows).toHaveLength(1);
126
+ }
127
+ });
128
+
129
+ it("formatCellValue handles number/currency/percent/date + unknown", () => {
130
+ expect(formatCellValue(0.42, "percent")).toContain("42");
131
+ expect(formatCellValue("abc", "number")).toBe("abc"); // non-numeric → string, never throws
132
+ });
133
+
134
+ it("badgeToneForValue maps status vocabulary to tones", () => {
135
+ expect(badgeToneForValue("Paid")).toBe("success");
136
+ expect(badgeToneForValue("Overdue")).toBe("destructive");
137
+ expect(badgeToneForValue("Whatever")).toBe("secondary");
138
+ });
139
+
140
+ it("compareCellValues sorts empties last", () => {
141
+ expect(compareCellValues(undefined, 5, "number")).toBeGreaterThan(0);
142
+ expect(compareCellValues(5, undefined, "number")).toBeLessThan(0);
143
+ });
144
+ });