cortena-ui 1.1.0 → 1.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.
@@ -0,0 +1,438 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The A2UI React renderer.
5
+ *
6
+ * Takes A2UI messages, folds them onto surfaces, walks the component tree and
7
+ * draws each node through the catalogue. Nothing an agent sends reaches the DOM
8
+ * as markup: a node is a catalogue name plus properties that must pass the
9
+ * entry's zod schema, and anything else becomes an inline error card.
10
+ *
11
+ * ## Why this is written rather than adopted
12
+ *
13
+ * `@a2ui/react` 0.11.0 (Google, Apache-2.0, published 2026-09-01) is a real,
14
+ * maintained v0.9 renderer and it does let a host supply a catalogue. It was
15
+ * evaluated for DESIGN-33 and not adopted, for three reasons:
16
+ *
17
+ * 1. **zod major.** `@a2ui/web_core`'s generic binder reads zod 3 internals —
18
+ * `schema._def.typeName`, `_def.shape()`, `_def.innerType` — to decide how to
19
+ * bind each property. cortena-ui's zod peer is `^3.25 || ^4`, and zod 4 has
20
+ * no `_def.typeName` and exposes `shape` as a property, so a catalogue
21
+ * authored with the consumer's zod 4 fails inside the binder. Adopting would
22
+ * pin the whole package's schema layer to zod 3 while `form.tsx` and
23
+ * `@hookform/resolvers` follow the consumer's.
24
+ * 2. **Dialect.** Cortena already speaks A2UI v0.8: core bundles the Lit
25
+ * renderer at `src/canvas-host/a2ui/` and the `canvas` tool pushes
26
+ * `beginRendering` / `surfaceUpdate` / `dataModelUpdate` as JSONL.
27
+ * `@a2ui/react` splits v0_8 and v0_9 into separate entry points with separate
28
+ * catalogues, so neither one reads both. Every payload written for the
29
+ * native nodes would have to be rewritten before it rendered on the web.
30
+ * 3. **Weight and surface.** It brings `@a2ui/web_core` (3.8 MB unpacked),
31
+ * markdown-it, `@preact/signals-core` and date-fns into a package whose build
32
+ * externalises everything and whose markdown already goes through
33
+ * react-markdown with a sanitize schema. And the API this task needs —
34
+ * `resolveDataSource` for server tables, `onAction` for the AG-UI round trip —
35
+ * has no counterpart there, so it would be wrapped either way.
36
+ *
37
+ * What we keep is the part that matters for interop: the **wire format** is
38
+ * A2UI's, the action we emit is shaped like A2UI's client-to-server `action`
39
+ * (`{ name, surfaceId, sourceComponentId, context }`), and the catalogue is a
40
+ * schema-per-component registry. Swapping the engine later is mechanical.
41
+ */
42
+
43
+ import * as React from "react";
44
+ import type { DataSource } from "@/components/data-table";
45
+ import { cn } from "@/lib/cn";
46
+ import {
47
+ A2UI_DEFAULT_SURFACE_ID,
48
+ foldMessages,
49
+ isDataBinding,
50
+ readAction,
51
+ readChildren,
52
+ readComponent,
53
+ resolvePointer,
54
+ resolveValue,
55
+ setAtPointer,
56
+ type A2UIAction,
57
+ type A2UIComponentSpec,
58
+ type A2UIMessageInput,
59
+ type A2UISurface,
60
+ } from "./message";
61
+ import { defaultCatalogue, type A2UICatalogue } from "./catalogue";
62
+ import { A2UIErrorCard, type A2UINodeInfo, type A2UIResolvedAction, type A2UIRow } from "./views";
63
+
64
+ export interface A2UIRendererProps {
65
+ /**
66
+ * The payload. One message, the list an AG-UI `CUSTOM` event carries, or the
67
+ * JSONL text `canvas.a2ui.pushJSONL` sends. Later messages fold onto earlier
68
+ * ones, so a push stream can be accumulated by the host and handed here whole.
69
+ */
70
+ message: A2UIMessageInput;
71
+ /** The component vocabulary. Defaults to the whole cortena-ui catalogue. */
72
+ catalogue?: A2UICatalogue;
73
+ /** Receives every user event, shaped like A2UI's client-to-server action. */
74
+ onAction?: (action: A2UIAction) => void;
75
+ /**
76
+ * Resolves a `{ kind: "server", sourceId }` table handle into a real data
77
+ * source. Without it, a server table renders an error card rather than an
78
+ * empty one, because a silently empty table reads as "no results".
79
+ */
80
+ resolveDataSource?: (sourceId: string) => DataSource<A2UIRow> | undefined;
81
+ /** Render only this surface. Default: every surface, in arrival order. */
82
+ surfaceId?: string;
83
+ /** Debounce before a continuous input becomes an action. @default 300 */
84
+ debounceMs?: number;
85
+ className?: string;
86
+ }
87
+
88
+ /** Depth limit; a tree deeper than this is a malformed payload, not a design. */
89
+ const MAX_DEPTH = 32;
90
+
91
+ interface NodeContext {
92
+ surface: A2UISurface;
93
+ catalogue: A2UICatalogue;
94
+ model: Record<string, unknown>;
95
+ setModel: (updater: (model: Record<string, unknown>) => Record<string, unknown>) => void;
96
+ onAction: ((action: A2UIAction) => void) | undefined;
97
+ resolveDataSource: (sourceId: string) => DataSource<A2UIRow> | undefined;
98
+ debounceMs: number;
99
+ }
100
+
101
+ /**
102
+ * A2UIRenderer — draws A2UI messages through cortena-ui.
103
+ *
104
+ * ```tsx
105
+ * <A2UIRenderer
106
+ * message={customEvent.value}
107
+ * catalogue={defaultCatalogue}
108
+ * onAction={(action) => agent.send(action)}
109
+ * resolveDataSource={(id) => sources[id]}
110
+ * />
111
+ * ```
112
+ */
113
+ export function A2UIRenderer({
114
+ message,
115
+ catalogue = defaultCatalogue,
116
+ onAction,
117
+ resolveDataSource,
118
+ surfaceId,
119
+ debounceMs = 300,
120
+ className,
121
+ }: A2UIRendererProps) {
122
+ const fold = React.useMemo(() => foldMessages(message), [message]);
123
+
124
+ // Local data model per surface, seeded from the folded one. Inputs write here
125
+ // so a bound value updates as the user types; a new payload replaces it,
126
+ // because the agent is then the authority on what the surface shows.
127
+ const [models, setModels] = React.useState<Record<string, Record<string, unknown>>>(() =>
128
+ seedModels(fold.surfaces),
129
+ );
130
+ const seenFold = React.useRef(fold);
131
+ if (seenFold.current !== fold) {
132
+ seenFold.current = fold;
133
+ setModels(seedModels(fold.surfaces));
134
+ }
135
+
136
+ const resolve = React.useCallback(
137
+ (sourceId: string) => resolveDataSource?.(sourceId),
138
+ [resolveDataSource],
139
+ );
140
+
141
+ const surfaces = surfaceId
142
+ ? fold.surfaces.filter((surface) => surface.id === surfaceId)
143
+ : fold.surfaces;
144
+
145
+ const missingSurface = Boolean(surfaceId) && surfaces.length === 0 && fold.surfaces.length > 0;
146
+
147
+ return (
148
+ <div data-slot="a2ui-renderer" className={cn("flex min-w-0 flex-col gap-3", className)}>
149
+ {fold.errors.length > 0 ? (
150
+ <A2UIErrorCard
151
+ title="Malformed A2UI payload"
152
+ detail={fold.errors.slice(0, 5).join(" ")}
153
+ />
154
+ ) : null}
155
+ {missingSurface ? (
156
+ <A2UIErrorCard
157
+ title="Unknown surface"
158
+ detail={`No surface "${surfaceId}" in this payload.`}
159
+ />
160
+ ) : null}
161
+ {surfaces.map((surface) => (
162
+ <A2UISurfaceView
163
+ key={surface.id}
164
+ surface={surface}
165
+ catalogue={catalogue}
166
+ model={models[surface.id] ?? surface.dataModel}
167
+ setModel={(updater) =>
168
+ setModels((current) => ({
169
+ ...current,
170
+ [surface.id]: updater(current[surface.id] ?? surface.dataModel),
171
+ }))
172
+ }
173
+ onAction={onAction}
174
+ resolveDataSource={resolve}
175
+ debounceMs={debounceMs}
176
+ />
177
+ ))}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ function seedModels(surfaces: readonly A2UISurface[]): Record<string, Record<string, unknown>> {
183
+ const models: Record<string, Record<string, unknown>> = {};
184
+ for (const surface of surfaces) {
185
+ models[surface.id] = surface.dataModel;
186
+ }
187
+ return models;
188
+ }
189
+
190
+ function A2UISurfaceView({
191
+ surface,
192
+ catalogue,
193
+ model,
194
+ setModel,
195
+ onAction,
196
+ resolveDataSource,
197
+ debounceMs,
198
+ }: {
199
+ surface: A2UISurface;
200
+ catalogue: A2UICatalogue;
201
+ model: Record<string, unknown>;
202
+ setModel: (updater: (model: Record<string, unknown>) => Record<string, unknown>) => void;
203
+ onAction: ((action: A2UIAction) => void) | undefined;
204
+ resolveDataSource: (sourceId: string) => DataSource<A2UIRow> | undefined;
205
+ debounceMs: number;
206
+ }) {
207
+ const context: NodeContext = {
208
+ surface,
209
+ catalogue,
210
+ model,
211
+ setModel,
212
+ onAction,
213
+ resolveDataSource,
214
+ debounceMs,
215
+ };
216
+
217
+ if (!surface.root) {
218
+ return (
219
+ <div data-slot="a2ui-surface" data-surface-id={surface.id}>
220
+ <A2UIErrorCard
221
+ title="Empty A2UI surface"
222
+ detail={`Surface "${surface.id}" has no root component. Send updateComponents with a component whose id is "root", or name one in createSurface.`}
223
+ />
224
+ </div>
225
+ );
226
+ }
227
+
228
+ return (
229
+ <div data-slot="a2ui-surface" data-surface-id={surface.id} className="flex min-w-0 flex-col">
230
+ <A2UINode context={context} id={surface.root} contextPath="/" ancestors={[]} depth={0} />
231
+ </div>
232
+ );
233
+ }
234
+
235
+ function A2UINode({
236
+ context,
237
+ id,
238
+ contextPath,
239
+ ancestors,
240
+ depth,
241
+ }: {
242
+ context: NodeContext;
243
+ id: string;
244
+ contextPath: string;
245
+ ancestors: readonly string[];
246
+ depth: number;
247
+ }) {
248
+ const spec = context.surface.components.get(id);
249
+
250
+ if (!spec) {
251
+ return <A2UIErrorCard title="Unknown component id" detail={`No component "${id}" in this surface.`} />;
252
+ }
253
+ if (ancestors.includes(id)) {
254
+ return (
255
+ <A2UIErrorCard
256
+ title="Circular component reference"
257
+ detail={`"${id}" is already an ancestor of itself: ${[...ancestors, id].join(" → ")}.`}
258
+ componentId={id}
259
+ />
260
+ );
261
+ }
262
+ if (depth > MAX_DEPTH) {
263
+ return (
264
+ <A2UIErrorCard
265
+ title="Component tree too deep"
266
+ detail={`More than ${MAX_DEPTH} levels below the root.`}
267
+ componentId={id}
268
+ />
269
+ );
270
+ }
271
+
272
+ const declaration = readComponent(spec);
273
+ if (!declaration) {
274
+ return (
275
+ <A2UIErrorCard
276
+ title="Component has no type"
277
+ detail="Expected `component` to be a catalogue name, or an object keyed by one."
278
+ componentId={id}
279
+ />
280
+ );
281
+ }
282
+
283
+ const entry = context.catalogue[declaration.name];
284
+ if (!entry) {
285
+ return (
286
+ <A2UIErrorCard
287
+ title={`Unknown component "${declaration.name}"`}
288
+ detail={`The catalogue has: ${Object.keys(context.catalogue).sort().join(", ")}.`}
289
+ componentId={id}
290
+ />
291
+ );
292
+ }
293
+
294
+ // Resolve bindings first, then normalise actions, then validate. A view
295
+ // therefore only ever sees values that passed the entry's schema.
296
+ const raw = declaration.properties;
297
+ const bindings: Record<string, string> = {};
298
+ const resolved: Record<string, unknown> = {};
299
+ for (const [key, value] of Object.entries(raw)) {
300
+ if (key === "children") {
301
+ continue;
302
+ }
303
+ if (isDataBinding(value)) {
304
+ bindings[key] = resolvePointer(value.path, contextPath);
305
+ }
306
+ const next = resolveValue(value, context.model, contextPath);
307
+ resolved[key] = key.startsWith("on") ? readAction(next) : next;
308
+ }
309
+
310
+ const parsed = entry.props.safeParse(resolved);
311
+ if (!parsed.success) {
312
+ const issues = parsed.error.issues
313
+ .slice(0, 4)
314
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
315
+ .join("; ");
316
+ return (
317
+ <A2UIErrorCard
318
+ title={`Invalid props for ${declaration.name}`}
319
+ detail={issues}
320
+ componentId={id}
321
+ />
322
+ );
323
+ }
324
+
325
+ const node: A2UINodeInfo = {
326
+ surfaceId: context.surface.id,
327
+ componentId: id,
328
+ dataContextPath: contextPath,
329
+ };
330
+
331
+ const children =
332
+ entry.children === "many"
333
+ ? renderChildren(context, spec, raw, contextPath, [...ancestors, id], depth)
334
+ : [];
335
+
336
+ const emit = (
337
+ action: A2UIResolvedAction | undefined,
338
+ fallbackName: string,
339
+ payload?: Record<string, unknown>,
340
+ ) => {
341
+ context.onAction?.({
342
+ surfaceId: context.surface.id,
343
+ componentId: id,
344
+ name: action?.name ?? fallbackName,
345
+ payload: { ...(action?.context ?? {}), ...(payload ?? {}) },
346
+ });
347
+ };
348
+
349
+ const writeBack = (property: string, value: unknown) => {
350
+ const pointer = bindings[property];
351
+ if (!pointer) {
352
+ return;
353
+ }
354
+ context.setModel((model) => setAtPointer(model, pointer, value));
355
+ };
356
+
357
+ const View = entry.view as React.FC<{
358
+ props: unknown;
359
+ node: A2UINodeInfo;
360
+ children: React.ReactNode[];
361
+ dataModel: Record<string, unknown>;
362
+ emit: typeof emit;
363
+ writeBack: typeof writeBack;
364
+ resolveDataSource: (sourceId: string) => DataSource<A2UIRow> | undefined;
365
+ debounceMs: number;
366
+ }>;
367
+
368
+ return (
369
+ <View
370
+ props={parsed.data}
371
+ node={node}
372
+ dataModel={context.model}
373
+ emit={emit}
374
+ writeBack={writeBack}
375
+ resolveDataSource={context.resolveDataSource}
376
+ debounceMs={context.debounceMs}
377
+ >
378
+ {children}
379
+ </View>
380
+ );
381
+ }
382
+
383
+ /**
384
+ * Render a component's children.
385
+ *
386
+ * An explicit list renders each id once. A template renders the referenced
387
+ * component once per item in a bound array, each with its own data context, so
388
+ * an agent describes a row of a table or a list item once instead of repeating
389
+ * it per item.
390
+ */
391
+ function renderChildren(
392
+ context: NodeContext,
393
+ spec: A2UIComponentSpec,
394
+ properties: Record<string, unknown>,
395
+ contextPath: string,
396
+ ancestors: readonly string[],
397
+ depth: number,
398
+ ): React.ReactNode[] {
399
+ const children = readChildren(spec, properties);
400
+ if (children.kind === "none") {
401
+ return [];
402
+ }
403
+ if (children.kind === "list") {
404
+ return children.ids.map((childId) => (
405
+ <A2UINode
406
+ key={childId}
407
+ context={context}
408
+ id={childId}
409
+ contextPath={contextPath}
410
+ ancestors={ancestors}
411
+ depth={depth + 1}
412
+ />
413
+ ));
414
+ }
415
+ const pointer = resolvePointer(children.path, contextPath);
416
+ const items = resolveValue({ path: children.path }, context.model, contextPath);
417
+ if (!Array.isArray(items)) {
418
+ return [
419
+ <A2UIErrorCard
420
+ key="template-error"
421
+ title="Template list is not an array"
422
+ detail={`"${pointer}" does not resolve to an array in the data model.`}
423
+ />,
424
+ ];
425
+ }
426
+ return items.map((_item, index) => (
427
+ <A2UINode
428
+ key={`${children.componentId}:${index}`}
429
+ context={context}
430
+ id={children.componentId}
431
+ contextPath={`${pointer === "/" ? "" : pointer}/${index}`}
432
+ ancestors={ancestors}
433
+ depth={depth + 1}
434
+ />
435
+ ));
436
+ }
437
+
438
+ export { A2UI_DEFAULT_SURFACE_ID };