diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000000..2a2e38b333 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"74da53f6-8f01-4e0f-b961-4a177c2cc25f","pid":18276,"procStart":"Sat May 9 09:08:27 2026","acquiredAt":1778500900181} \ No newline at end of file diff --git a/packages/genui/a2ui-playground/examples/README.md b/packages/genui/a2ui-playground/examples/README.md new file mode 100644 index 0000000000..2fde5a855f --- /dev/null +++ b/packages/genui/a2ui-playground/examples/README.md @@ -0,0 +1,56 @@ +# A2UI playground examples + +Reference implementations that intentionally live **outside** +`@lynx-js/a2ui-reactlynx`. The package itself ships only: + +- `` — the protocol-naive renderer. +- `MessageStore` — a pure raw-message buffer. +- The catalog + custom-component-author API. + +Everything else — talking to an agent, chunking turns, theming the chat +shell — is the developer's choice. These examples show common shapes; +copy and adapt them. + +## `io-mock/` + +`createMockAgent(store, opts)` returns a driver that pushes a fixed +initial stream into the store and serves canned responses to user +actions. Used by the playground's `lynx-src/App.tsx` to exercise demos +without a real agent. + +```ts +const store = createMessageStore(); +const agent = createMockAgent(store, { initialMessages, actionMocks }); +agent.start(); // streams initial messages into the buffer +agent.onAction(action); // pushes the canned response to a user action +``` + +## Multi-turn chat shell pattern + +For chat UIs, give each turn (user prompt + agent response) its own +`MessageStore` and render one `` per +agent turn. The shell only tracks turns; the renderer handles +everything inside an agent turn. + +```tsx +function Conversation({ catalogs, respond }) { + const [turns, setTurns] = useState([]); + const send = async (input) => { + const store = createMessageStore(); + setTurns((t) => [ + ...t, + { kind: 'user', content: input }, + { kind: 'agent', store }, + ]); + await respond(input, store); + }; + return turns.map((t) => + t.kind === 'user' + ? {t.content} + : + ); +} +``` + +Each `` only sees a bounded buffer; history is just a list of +turns the shell maintains. diff --git a/packages/genui/a2ui-playground/examples/io-mock/mockAgent.ts b/packages/genui/a2ui-playground/examples/io-mock/mockAgent.ts new file mode 100644 index 0000000000..454a43352e --- /dev/null +++ b/packages/genui/a2ui-playground/examples/io-mock/mockAgent.ts @@ -0,0 +1,96 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +// +// Reference mock IO module. Pushes a fixed initial stream into the store +// and serves canned responses to user actions. NOT shipped from +// `@lynx-js/a2ui-reactlynx` — copy as a starting point for tests / demos. +import type { + MessageStore, + ServerToClientMessage, + UserActionPayload, +} from '@lynx-js/a2ui-reactlynx'; + +export interface MockAgentOptions { + /** Streamed once after `start()`. */ + initialMessages?: readonly ServerToClientMessage[]; + /** Per-action response messages, keyed by action name. */ + actionMocks?: Record< + string, + | readonly ServerToClientMessage[] + | ((ctx: UserActionPayload) => readonly ServerToClientMessage[]) + >; + /** Delay between successive batches when streaming. */ + delayMs?: number; +} + +export interface MockAgent { + /** + * Begin streaming the initial messages. Idempotent — calling twice + * returns the original promise. + */ + start(): Promise; + /** Forward a user action; pushes the canned response, if any. */ + onAction(action: UserActionPayload): Promise; + /** Stop streaming and discard any pending messages. */ + stop(): void; +} + +/** + * Build a mock agent driver bound to a `MessageStore`. The driver + * streams raw protocol messages into the store with a small delay + * between each, simulating an SSE-like server. + */ +export function createMockAgent( + store: MessageStore, + options: MockAgentOptions = {}, +): MockAgent { + const { initialMessages, actionMocks = {}, delayMs = 800 } = options; + const abort = new AbortController(); + let started: Promise | null = null; + + function sleep(ms: number): Promise { + if (ms <= 0 || abort.signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + abort.signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + abort.signal.addEventListener('abort', onAbort, { once: true }); + }); + } + + async function streamInto( + messages: readonly ServerToClientMessage[], + ): Promise { + for (const msg of messages) { + if (abort.signal.aborted) return; + store.push(msg); + if (delayMs > 0) { + await sleep(delayMs); + if (abort.signal.aborted) return; + } + } + } + + return { + start() { + if (started) return started; + started = streamInto(initialMessages ?? []); + return started; + }, + async onAction(action) { + const mock = actionMocks[action.name]; + if (!mock) return; + const stream = typeof mock === 'function' ? mock(action) : mock; + await streamInto(stream); + }, + stop() { + abort.abort(); + }, + }; +} diff --git a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx index 519be92ffa..379702e162 100644 --- a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx @@ -1,9 +1,36 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { A2UIRender, BaseClient } from '@lynx-js/a2ui-reactlynx/core'; -import type { Resource } from '@lynx-js/a2ui-reactlynx/core'; -import '@lynx-js/a2ui-reactlynx/catalog/all'; +import { + A2UI, + Button, + Card, + CheckBox, + Column, + Divider, + Image, + List, + RadioGroup, + Row, + Text, + createMessageStore, +} from '@lynx-js/a2ui-reactlynx'; +import type { + CatalogInput, + MessageStore, + ServerToClientMessage, + UserActionPayload, +} from '@lynx-js/a2ui-reactlynx'; +import buttonManifest from '@lynx-js/a2ui-reactlynx/catalog/Button/catalog.json'; +import cardManifest from '@lynx-js/a2ui-reactlynx/catalog/Card/catalog.json'; +import checkBoxManifest from '@lynx-js/a2ui-reactlynx/catalog/CheckBox/catalog.json'; +import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json'; +import dividerManifest from '@lynx-js/a2ui-reactlynx/catalog/Divider/catalog.json'; +import imageManifest from '@lynx-js/a2ui-reactlynx/catalog/Image/catalog.json'; +import listManifest from '@lynx-js/a2ui-reactlynx/catalog/List/catalog.json'; +import radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catalog.json'; +import rowManifest from '@lynx-js/a2ui-reactlynx/catalog/Row/catalog.json'; +import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json'; import { useEffect, useGlobalProps, @@ -13,6 +40,31 @@ import { useState, } from '@lynx-js/react'; +import { createMockAgent } from '../../examples/io-mock/mockAgent.js'; + +const DEFAULT_STREAM_DELAY_MS = 800; + +// Compose every built-in. There is intentionally no all-in-one aggregate +// shipped from the package — this list makes the cost of "everything" +// visible and lets the bundler tree-shake when you only need a few. +// +// Schemas are not attached because the playground doesn't perform an +// agent handshake. To include schemas, pair each component with its +// `catalog.json` manifest — see +// `packages/genui/a2ui/src/catalog/README.md`. +const ALL_BUILTINS: readonly CatalogInput[] = [ + [Text, textManifest], + [Image, imageManifest], + [Row, rowManifest], + [Column, columnManifest], + [List, listManifest], + [Card, cardManifest], + [Button, buttonManifest], + [Divider, dividerManifest], + [CheckBox, checkBoxManifest], + [RadioGroup, radioGroupManifest], +]; + interface InitData { messagesUrl?: string; messages?: unknown; @@ -22,17 +74,12 @@ interface InitData { } type A2uiMessage = Record & { messageId?: string }; - -type ActionMocks = Record; - type ResponseMessages = A2uiMessage[]; - -const DEFAULT_STREAM_DELAY_MS = 800; - -function randomId(prefix: string) { - return prefix + Date.now().toString(36) - + Math.random().toString(36).slice(2, 10); -} +type ActionMocks = Record< + string, + | ServerToClientMessage[] + | ((ctx: UserActionPayload) => ServerToClientMessage[]) +>; function parseJsonLikeString(input: string): unknown { try { @@ -41,7 +88,8 @@ function parseJsonLikeString(input: string): unknown { // ignore } - // Query params may arrive URL-encoded one or more times in native globalProps. + // Query params may arrive URL-encoded one or more times in native + // globalProps. let current = input; for (let i = 0; i < 3; i++) { try { @@ -61,37 +109,18 @@ function parseJsonLikeString(input: string): unknown { return input; } -function decodeUrlLikeString(input: string): string { - let current = input; - for (let i = 0; i < 3; i++) { - try { - const decoded = decodeURIComponent(current); - if (decoded === current) break; - current = decoded; - } catch { - break; - } - } - return current; -} - function normalizeInitDataLike(raw: unknown): InitData { if (raw === null || raw === undefined) return {}; - if (typeof raw !== 'object') return {}; const obj = raw as Record; const out: InitData = {}; const messagesUrl = obj.messagesUrl; - if (typeof messagesUrl === 'string') { - out.messagesUrl = decodeUrlLikeString(messagesUrl); - } + if (typeof messagesUrl === 'string') out.messagesUrl = messagesUrl; const actionMocksUrl = obj.actionMocksUrl; - if (typeof actionMocksUrl === 'string') { - out.actionMocksUrl = decodeUrlLikeString(actionMocksUrl); - } + if (typeof actionMocksUrl === 'string') out.actionMocksUrl = actionMocksUrl; const messages = obj.messages; if (messages !== undefined) { @@ -126,14 +155,8 @@ function mergeInitDataPreferLeft(a: InitData, b: InitData): InitData { } function normalizePayloadToMessages(payload: unknown): ResponseMessages { - if (payload === null || payload === undefined) { - return []; - } - - if (Array.isArray(payload)) { - return payload as ResponseMessages; - } - + if (payload === null || payload === undefined) return []; + if (Array.isArray(payload)) return payload as ResponseMessages; if (typeof payload === 'string') { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -143,14 +166,12 @@ function normalizePayloadToMessages(payload: unknown): ResponseMessages { return []; } } - if ( typeof payload === 'object' && Array.isArray((payload as Record).messages) ) { return (payload as Record).messages as ResponseMessages; } - return []; } @@ -165,15 +186,15 @@ async function loadMessages(initData: InitData): Promise { return normalizePayloadToMessages(text); } } - if (initData.messages !== undefined) { return normalizePayloadToMessages(initData.messages); } - return []; } -async function loadActionMocks(initData: InitData): Promise { +async function loadActionMocks( + initData: InitData, +): Promise> { if (initData.actionMocksUrl) { // eslint-disable-next-line n/no-unsupported-features/node-builtins const res = await fetch(initData.actionMocksUrl, { cache: 'no-store' }); @@ -182,23 +203,22 @@ async function loadActionMocks(initData: InitData): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(text); if (parsed && typeof parsed === 'object') { - return parsed as ActionMocks; + return parsed as Record; } return {}; } catch { return {}; } } - if (initData.actionMocks && typeof initData.actionMocks === 'object') { - return initData.actionMocks as ActionMocks; + return initData.actionMocks as Record; } - return {}; } export function App() { const globalProps = useGlobalProps(); + const rawInitData = useInitData(); const initData = useMemo(() => { @@ -217,14 +237,22 @@ export function App() { [globalProps], ); - // Native in-app preview passes A2UI payload via `globalProps` (often from URL query). - // Web preview may still provide `initData`, so keep fallback for compatibility. + // Native in-app preview passes A2UI payload via `globalProps` (often + // from URL query). Web preview may still provide `initData`, so keep the + // fallback for compatibility. const effectiveData = useMemo( () => mergeInitDataPreferLeft(globalPropsData, initData), [globalPropsData, initData], ); - // Speed multiplier from URL query (e.g. ?speed=2 → 2x faster). + const storeRef = useRef(null); + const agentRef = useRef | null>(null); + const [store, setStore] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + // Per-batch delay (ms) the mock agent waits between successive + // protocol messages. Configurable via `?speed=2` (faster) etc. const streamDelay = useMemo(() => { const raw = (globalProps as Record | null)?.speed ?? (rawInitData as Record | null)?.speed; @@ -235,12 +263,8 @@ export function App() { return DEFAULT_STREAM_DELAY_MS / speed; }, [globalProps, rawInitData]); - // biome-ignore lint/suspicious/noExplicitAny: - const clientRef = useRef(null); - - const [resource, setResource] = useState(null); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); + // `?instant=1` (or `instant: true`) paints the final state with no + // pacing — used by the examples-list thumbnails. const isInstantPreview = useMemo( () => effectiveData.instant === true, [effectiveData.instant], @@ -253,107 +277,58 @@ export function App() { setLoading(true); setError(''); - const [rawMessages, actionMocks] = await Promise.all([ + const [rawMessages, rawActionMocks] = await Promise.all([ loadMessages(effectiveData ?? {}), loadActionMocks(effectiveData ?? {}), ]); - const messageId = randomId('demo_'); - const messages = rawMessages.map((msg) => ({ - ...msg, - messageId: messageId, - })); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const client = clientRef.current ?? new BaseClient(''); - - clientRef.current ??= client; + const initialMessages = rawMessages as ServerToClientMessage[]; + const actionMocks: ActionMocks = {}; + for (const [name, value] of Object.entries(rawActionMocks)) { + actionMocks[name] = normalizePayloadToMessages( + value, + ) as ServerToClientMessage[]; + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - client.processUserAction = async ( - userAction: Record, - ) => { - const name = userAction?.name as string | undefined; - if (!name || !actionMocks[name]) { - return []; - } + const next = createMessageStore(); + const agent = createMockAgent(next, { + initialMessages, + actionMocks, + // `delayMs: 0` makes `agent.start()` push every message into the + // buffer in a tight loop, effectively a static "final state" + // paint that matches upstream's `isInstantPreview` mode. + delayMs: isInstantPreview ? 0 : streamDelay, + }); - const rawResponseMessages = normalizePayloadToMessages( - actionMocks[name], - ); - const actionMessageId = randomId('action_'); - const responseMessages = rawResponseMessages.map((msg) => ({ - ...msg, - messageId: actionMessageId, - })); - - void (async () => { - for (const msg of responseMessages) { - if (cancelled) break; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.processor?.processMessages?.([msg]); - await new Promise((resolve) => setTimeout(resolve, streamDelay)); - } - })(); - - return responseMessages; - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.processor?.clearSurfaces?.(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.resources?.clear?.(); - - const sendResult = await ( - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.send( - '' as unknown, - messageId, - ) as Promise<{ resource: Resource }> - ); - const newResource = sendResult.resource; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.resources?.set?.(messageId, newResource); - - if (!cancelled) { - setResource(newResource); - } + // Begin streaming the demo's initial messages into the buffer. + void agent.start(); - if (isInstantPreview) { - // Static preview mode: paint the final state immediately. - // This is used by the examples list thumbnails. - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.processor?.processMessages?.(messages); - } else { - const simulateStream = async () => { - for (const msg of messages) { - if (cancelled) break; - if (!msg) continue; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.processor?.processMessages?.([msg]); - await new Promise((resolve) => setTimeout(resolve, streamDelay)); - } - }; - - void simulateStream(); + if (cancelled) { + agent.stop(); + return; } + agentRef.current?.stop(); + storeRef.current = next; + agentRef.current = agent; + setStore(next); }; run() .catch((e) => { if (!cancelled) { setError(String(e)); - setResource(null); + setStore(null); } }) .finally(() => { - if (!cancelled) { - setLoading(false); - } + if (!cancelled) setLoading(false); }); return () => { cancelled = true; + agentRef.current?.stop(); + storeRef.current = null; + agentRef.current = null; }; }, [effectiveData, isInstantPreview, streamDelay]); @@ -369,22 +344,43 @@ export function App() { ) : null} - - {loading + {loading && !store ? ( Loading... ) : null} - - {resource + {store ? ( - + { + // Forward user actions to the mock agent — it pushes the + // canned response messages back into the same store. + void agentRef.current?.onAction(action); + }} + wrapSurface={(c) => {c}} + renderEmpty={() => ( + + Loading... + + )} + renderFallback={() => ( + + Streaming... + + )} + /> ) - : null} + : ( + + Loading... + + )} ); } diff --git a/packages/genui/a2ui-playground/lynx-src/tsconfig.json b/packages/genui/a2ui-playground/lynx-src/tsconfig.json index 7d8eed26ef..491b46eca4 100644 --- a/packages/genui/a2ui-playground/lynx-src/tsconfig.json +++ b/packages/genui/a2ui-playground/lynx-src/tsconfig.json @@ -6,7 +6,13 @@ "jsxImportSource": "@lynx-js/react", "module": "ESNext", "moduleResolution": "Bundler", + "resolveJsonModule": true, "noEmit": true, }, - "include": ["./**/*.ts", "./**/*.tsx"], + "include": [ + "./**/*.ts", + "./**/*.tsx", + "../examples/**/*.ts", + "../examples/**/*.tsx", + ], } diff --git a/packages/genui/a2ui/AGENTS.md b/packages/genui/a2ui/AGENTS.md index cb835e6d63..6aceaadcc3 100644 --- a/packages/genui/a2ui/AGENTS.md +++ b/packages/genui/a2ui/AGENTS.md @@ -1,118 +1,167 @@ # a2ui (packages/genui/a2ui) -This package (`@lynx-js/a2ui-reactlynx`) is a ReactLynx-oriented component library inspired by Google A2UI. +This package (`@lynx-js/a2ui-reactlynx`) is a **headless** ReactLynx +renderer for the A2UI v0.9 protocol. ## How It Works (High Level) -This package is primarily a renderer + state/runtime for the A2UI v0.9 protocol: +The package is split into three independently composable layers: -- A2UI messages (e.g. `createSurface`, `updateComponents`, `updateDataModel`) are processed by `src/core/processor.ts` into local state (`Surface`). -- Each `Surface` owns: - - `components`: a map of v0.9 component instances (`ComponentInstance`) - - `resources`: per-component `Resource` objects used to drive incremental rendering - - `store`: a `SignalStore` for data bindings (JSON-pointer-like paths) -- `src/core/A2UIRender.tsx` renders a tree by looking up component instances and delegating to the registered ReactLynx component implementation. +- **Store layer** (`src/store/`) — pure data logic. Owns the protocol + message buffer, surface state machine, signal-backed data model, and + per-component resources. No React. +- **React layer** (`src/react/`) — `` / `` and the + hooks (`useAction`, `useDataBinding`, `useCatalog`) that turn the + store's surface state into a ReactLynx tree. +- **Catalog layer** (`src/catalog/`) — built-in components plus the + `defineCatalog` API consumers use to compose their per-instance + catalog (no global registry). -In short: protocol messages update a surface model; resources notify; `A2UIRender` turns the model into UI. +In short: the developer's IO module pushes raw v0.9 messages into a +`MessageStore`. `` subscribes, owns a `MessageProcessor` that +turns the stream into surface state, and renders via the catalog the +consumer provided. ## Architecture & Data Flow Core pieces: -- `processor` (`src/core/processor.ts`) +- `MessageStore` (`src/store/MessageStore.ts`) + - Append-only buffer of raw protocol messages. + - `useSyncExternalStore`-friendly `subscribe` / `getSnapshot` API. + - The developer's transport (fetch / SSE / WebSocket / in-process + mock) calls `store.push(msg)` — the store is intentionally dumb + about protocol semantics. +- `MessageProcessor` (`src/store/MessageProcessor.ts`) - Owns all `Surface` instances. - - Applies `updateComponents` into `surface.components` + creates `surface.resources`. - - Applies `updateDataModel` into `surface.store`, and may expand templates into concrete components. -- `BaseClient` (`src/core/BaseClient.ts`) - - Streams server output (SSE) and normalizes payloads into v0.9 message arrays. - - Feeds messages into `processor.processMessages`. - - Listens to processor updates and completes `Resource` objects. -- `A2UIRender` (`src/core/A2UIRender.tsx`) - - Subscribes to `Resource` completion/update and renders either: - - `beginRendering`: mounts the surface root resource - - `surfaceUpdate`: renders a single component node - - `deleteSurface`: unmounts -- `componentRegistry` (`src/core/ComponentRegistry.ts`) - - Maps protocol `component` names (e.g. `"Button"`) to actual ReactLynx renderers. - - Catalog modules register themselves by side effect when imported. + - Applies `createSurface` / `updateComponents` / `updateDataModel` / + `deleteSurface` into surface state. + - Emits typed update events (`beginRendering`, `surfaceUpdate`, + `deleteSurface`) consumed by the React layer. + - `dispatch({ userAction })` fans out to `onEvent` listeners. +- `Resource` (`src/store/Resource.ts`) + - `pending` / `success` / `error` state machine. Snapshot reference + changes on every transition so `useSyncExternalStore` doesn't bail + out on `pending → error`. + - One per surface root + per component instance. +- `SignalStore` (`src/store/SignalStore.ts`) + - `@preact/signals` wrapper used as the per-surface data model + (JSON-pointer-style paths). +- `` (`src/react/A2UI.tsx`) + - All-in-one renderer. Per-mount `MessageProcessor`. Subscribes to + the developer-supplied `MessageStore`, processes new tail messages + on each render, and renders the most recent surface. + - Wires `processor.onEvent` → the consumer's `onAction` prop. +- `` / `NodeRenderer` (`src/react/A2UIRenderer.tsx`) + - Subscribes to a `Resource` and renders either: + - `beginRendering`: mounts the surface root resource. + - `surfaceUpdate`: re-renders a single component node. + - `deleteSurface`: unmounts. + - Used internally by ``; not part of the public surface. +- `defineCatalog` (`src/catalog/defineCatalog.ts`) + - Builds the runtime catalog the renderer uses. Inputs can mix bare + components, `[component, manifest]` tuples, and already-resolved + entries from `mergeCatalogs`. ## Rendering Model -- Each protocol component instance references children via IDs (e.g. `child: "text-1"`, `children: ["a","b"]`). -- Catalog components typically render their child ID by calling `A2UIRender` on the child `Resource`. -- Unknown component tags will log a warning and render `null`. +- Each protocol component instance references children via IDs (e.g. + `child: "text-1"`, `children: ["a","b"]`). +- Catalog components render their child IDs by calling `` + for each child against the same surface. +- Unknown component tags log a warning (once per tag) and render `null`. ## Data Binding -Data binding is path-based: - -- A binding is usually `{ path: string }` and resolves against the `Surface.store`. -- `useResolvedProps` (`src/core/useDataBinding.ts`) resolves bound props into concrete values, and keeps them up to date via `@preact/signals`. -- Relative paths are resolved against the component's `dataContextPath` (used heavily for templates / repeated structures). +- A binding is `{ path: string }` and resolves against + `Surface.store` (a `SignalStore`). +- `useResolvedProps` (`src/react/useDataBinding.ts`) resolves bound + props into concrete values and keeps them up to date via + `@preact/signals`. +- Relative paths resolve against the component's `dataContextPath` + (used heavily for templates / repeated structures). ## Template Expansion (Dynamic Children) -To support repeated UI from data: - -- When `updateComponents` contains a "templated children" placeholder, `processor` stores internal metadata on the component (`__template`). -- When `updateDataModel` updates the template data path, `processor`: - - reads the data at the template path - - clones the template component subtree using `cloneComponentTree` - - rewrites child IDs and sets `dataContextPath` to an item-specific scope - - updates `children` to the generated concrete IDs +- When `updateComponents` contains a "templated children" placeholder, + `MessageProcessor` stores `__template` metadata on the component. +- When `updateDataModel` updates the template's data path, the + processor: + - reads the data at the path, + - clones the template subtree via `cloneComponentTree`, + - rewrites child IDs and sets `dataContextPath` to an item-specific + scope, + - sets `children` to the generated concrete IDs. -This is why some components can appear/disappear when only the data model changes. +This is why some components can appear/disappear when only the data +model changes. ## Action Dispatch User interactions are reported as `userAction` events: -- Catalog components call `sendAction(action)` passed from `A2UIRender`. -- `useAction` (`src/core/useAction.ts`) converts v0.9 `Action` into a `UserActionPayload`: - - resolves dynamic values (bindings / function calls) from `Surface.store` - - calls `processor.dispatch({ userAction })` -- The "host" (app/client) is responsible for handling `processor.onEvent` and returning new messages. +- Catalog components call `sendAction(action)` (passed in through the + internal renderer plumbing). +- `useAction` (`src/react/useAction.ts`) resolves dynamic values + (bindings / function calls) against `Surface.store`, builds a + `UserActionPayload`, and calls `processor.dispatch({ userAction })`. +- `` listens to `processor.onEvent` and forwards to the + developer's `onAction` prop. Responses (if any) come back as new + protocol messages the developer pushes into the same `MessageStore`. ## What To Edit -- `src/catalog/*`: UI catalog components (also used for schema generation). -- `src/core/*`: A2UI runtime/rendering and component registry. -- `src/chat/*`: Chat-related helpers. -- `src/utils/*`: Shared utilities. +- `src/catalog/*`: built-in UI components (also drives schema + generation). +- `src/store/*`: protocol-side data layer (buffer, processor, + resources, signals). +- `src/react/*`: ReactLynx renderer + hooks. ## Build Run from repo root: ```bash -pnpm -C packages/genui/a2ui build +pnpm -F @lynx-js/a2ui-reactlynx build ``` Notes: -- Build outputs go to `dist/` (including `dist/catalog/...` schemas). +- The package's `build` script runs the + `@lynx-js/a2ui-catalog-extractor` to produce + `dist/catalog//catalog.json` manifests. +- The root `tsc --build` (registered as `//#build`) is what produces + `dist//index.{js,d.ts}` for each catalog component (project + references). ## Catalog Schema Generation The build generates JSON schemas for catalog components: -- Script: `tools/catalog_generator.ts` -- Inputs: `src/catalog/*/index.tsx` -- Outputs: `dist/catalog//catalog.json` +- Tool: `@lynx-js/a2ui-catalog-extractor` (TypeDoc-driven). +- Inputs: `src/catalog//index.tsx` files annotated with + `@a2uiCatalog ` JSDoc tags on the props interface. +- Outputs: `dist/catalog//catalog.json`. -Important constraints: +Constraints: -1. Component folder name must match the function declaration name in `index.tsx`. - - Example: `src/catalog/Button/index.tsx` should export `function Button(...) { ... }`. -2. Framework-level props are intentionally excluded (e.g. `GenericComponentProps` / `ComponentProps`). -3. Schema output is written under `dist/` and should be treated as build output. +1. The component folder name must match the function declaration name + in `index.tsx` (e.g. `src/catalog/Button/index.tsx` exports + `function Button(...) { ... }`). +2. Framework-level props (`GenericComponentProps`) are intentionally + excluded from the emitted schema. +3. Schema output is build output — don't commit `dist/`. ## Adding A New Catalog Component When adding `src/catalog//index.tsx`: -1. Ensure the component function is named `` (matches folder name). -2. Add `src/catalog/.ts` (follow the existing pattern). -3. Export it from `src/catalog/all.ts` (this file also triggers registration side effects). -4. Add `./catalog/` to the `exports` map in `package.json`. -5. Run `pnpm -C packages/genui/a2ui build` and confirm `dist/catalog//catalog.json` is generated. +1. Ensure the component function is named `` (matches folder + name). +2. Annotate the props interface with `@a2uiCatalog ` so the + extractor picks it up. +3. Re-export the component from `src/catalog/index.ts`. +4. Add `./catalog/` and `./catalog//catalog.json` entries + to the `exports` map in `package.json`. +5. Run `pnpm -F @lynx-js/a2ui-reactlynx build` and confirm + `dist/catalog//catalog.json` is generated. diff --git a/packages/genui/a2ui/README.md b/packages/genui/a2ui/README.md index 85c7da019c..4f3456124e 100644 --- a/packages/genui/a2ui/README.md +++ b/packages/genui/a2ui/README.md @@ -1,129 +1,142 @@ # @lynx-js/a2ui-reactlynx -ReactLynx helpers for rendering A2UI responses. +ReactLynx renderer for the A2UI v0.9 protocol. **Headless** — the package +ships no styles or chrome; consumers wrap surfaces themselves. This package includes: -- `A2UIRender`: render streaming A2UI UI updates. -- `BaseClient`: a minimal client to request A2UI responses. -- `catalog/*`: built-in component renderers (import to register). -- `Conversation`: an optional chat UI wrapper. +- ``: all-in-one component that owns a `MessageProcessor`, + subscribes to a developer-supplied `MessageStore`, and renders the + most recent surface. +- `MessageStore`: an append-only buffer of raw protocol messages the + developer pushes into from any IO transport (fetch, SSE, WebSocket, + in-process mock, …). +- `defineCatalog` / `mergeCatalogs` / `serializeCatalog`: the pluggable + catalog API. No global registry — every consumer composes the set of + components they want available. +- `catalog/`: built-in component renderers (`Text`, `Button`, + `Card`, `Column`, `Row`, `List`, `CheckBox`, `RadioGroup`, `Image`, + `Divider`). +- `catalog//catalog.json`: per-component JSON-Schema manifests + for the agent handshake. ## Exports -- `@lynx-js/a2ui-reactlynx/core`: `A2UIRender`, `BaseClient`, registry utilities. -- `@lynx-js/a2ui-reactlynx/chat`: `Conversation` and related types/hooks. -- `@lynx-js/a2ui-reactlynx/catalog/all`: registers all built-in catalog components. -- `@lynx-js/a2ui-reactlynx/catalog/`: registers a single catalog component (tree-shake friendly). +- `@lynx-js/a2ui-reactlynx`: ``, `createMessageStore`, + `defineCatalog`, the built-ins, plus protocol types. +- `@lynx-js/a2ui-reactlynx/catalog`: re-exports of the catalog API and + built-ins for tree-shake-friendly subpath access. +- `@lynx-js/a2ui-reactlynx/catalog/`: import a single built-in. +- `@lynx-js/a2ui-reactlynx/catalog//catalog.json`: import the + per-component manifest. +- `@lynx-js/a2ui-reactlynx/store`: `MessageStore`, `MessageProcessor`, + `Resource`, payload normalizers — the pure data layer. +- `@lynx-js/a2ui-reactlynx/react`: lower-level renderer pieces for + consumers that want manual surface lifecycle control. ## Installation Make sure your app provides the peer dependencies: - `@lynx-js/react` -- `@lynx-js/lynx-ui` -- `@lynx-js/lynx-ui-input` -## Quick Start (Core) +## Quick Start -1. Register components you want to render. -2. Create a client and start a request. -3. Render the returned `resource` using `A2UIRender`. +1. Create a `MessageStore`. +2. Wire your IO module (mock / SSE / fetch / …) to push raw protocol + messages into the store. +3. Render ``. ```tsx -import { useEffect, useMemo, useState } from '@lynx-js/react'; +import { + A2UI, + Button, + Text, + createMessageStore, +} from '@lynx-js/a2ui-reactlynx'; -import { A2UIRender, BaseClient } from '@lynx-js/a2ui-reactlynx/core'; +const store = createMessageStore(); -// Option A: register everything in the built-in catalog (easiest). -import '@lynx-js/a2ui-reactlynx/catalog/all'; +// Your IO module pushes raw v0.9 protocol messages into the store. +// async function streamFromAgent(input: string) { +// for await (const msg of myAgent.stream(input)) store.push(msg); +// } export function A2UIScreen(): import('@lynx-js/react').ReactNode { - const client = useMemo( - () => new BaseClient('http:///sse'), - [], + return ( + { + // Forward to your agent — push the response messages back into + // the same store. Fire-and-forget; the renderer never awaits. + }} + wrapSurface={(c) => {c}} + /> ); - const [resource, setResource] = useState(null); - - useEffect(() => { - void (async () => { - const { resource } = await client.makeRequest('Hello'); - setResource(resource); - })(); - }, [client]); - - if (!resource) return null; - return ; } ``` -### Catalog Registration Options +The `` component is intentionally minimal: -Register all built-in components: +- It owns its own `MessageProcessor` per mount; passing a different + `messageStore` instance does **not** reset internal state — use a + `key` prop derived from your turn/session id when you want a fresh + session. +- `onAction` is fire-and-forget. The renderer doesn't wait for a + response — your agent pushes follow-up messages back into the same + `messageStore`. +- `wrapSurface` is the only way the renderer surfaces theming. The + default doesn't wrap. -```ts -import '@lynx-js/a2ui-reactlynx/catalog/all'; -``` +## Catalogs + +The package intentionally **does not** ship an "all-in-one" aggregate. +Composition is per-component so bundlers can tree-shake what isn't +referenced. -Or register only what you need: +### Bare components (renderer-only) ```ts -import '@lynx-js/a2ui-reactlynx/catalog/Text'; -import '@lynx-js/a2ui-reactlynx/catalog/Button'; -import '@lynx-js/a2ui-reactlynx/catalog/Card'; -``` +import { defineCatalog, Text, Button } from '@lynx-js/a2ui-reactlynx'; -## Chat SDK +const catalog = defineCatalog([Text, Button]); +``` -`Conversation` offers a ready-to-use chat UI in two modes: +The protocol name comes from `displayName ?? component.name`. -- Uncontrolled: pass `url`. -- Controlled: pass `messages` + `sendMessage`. +> ⚠️ Production minifiers rewrite `function` names. For production +> safety, set an explicit `displayName` on every custom component, or +> pair it with its `catalog.json` manifest (the manifest key is +> authoritative). -Uncontrolled: +### Paired with manifests (renderer + agent handshake) -```tsx -import { Conversation } from '@lynx-js/a2ui-reactlynx/chat'; -import '@lynx-js/a2ui-reactlynx/catalog/all'; +```ts +import { Text, defineCatalog } from '@lynx-js/a2ui-reactlynx'; +import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json' + with { type: 'json' }; -export function Chat(): import('@lynx-js/react').ReactNode { - return ; -} +const catalog = defineCatalog([[Text, textManifest]]); +agentChannel.handshake({ catalog: serializeCatalog(catalog) }); ``` -Controlled: - -```tsx -import { useState } from '@lynx-js/react'; -import type { Message } from '@lynx-js/a2ui-reactlynx/chat'; -import { Conversation } from '@lynx-js/a2ui-reactlynx/chat'; - -export function ChatControlled(): import('@lynx-js/react').ReactNode { - const [messages, setMessages] = useState([]); - - return ( - { - // Your own networking + resource wiring here. - setMessages((prev) => [ - ...prev, - { id: String(Date.now()), role: 'user', content }, - ]); - }} - /> - ); -} -``` +See [`src/catalog/README.md`](src/catalog/README.md) for the full +recipe (including the paste-able "every built-in" snippet). ## Custom Components -If your server emits a `component` tag that is not in the built-in catalog, register your own renderer: +Any function returning a `ReactNode` works. The function's name (or +`displayName`) is the protocol name the agent will use: ```tsx -import { componentRegistry } from '@lynx-js/a2ui-reactlynx/core'; +function MyChart(props: { data: number[] }) { ... } +MyChart.displayName = 'MyChart'; -componentRegistry.register('MyWidget', (props) => { - return {String(props.component?.id ?? 'MyWidget')}; -}); +; +// Agent emits `{ component: 'MyChart', data: [...] }` → renders MyChart. ``` + +If you want schema introspection for a custom component, generate the +manifest with `@lynx-js/a2ui-catalog-extractor` against your interface +and pair it with the component the same way as the built-ins. diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index 0b464c15fd..1d0dcdcfb7 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -3,91 +3,76 @@ "version": "0.0.0", "private": true, "license": "Apache-2.0", + "sideEffects": [ + "**/*.css" + ], "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./core": { - "types": "./dist/core/index.d.ts", - "default": "./dist/core/index.js" + "./store": { + "types": "./dist/store/index.d.ts", + "default": "./dist/store/index.js" }, - "./chat": { - "types": "./dist/chat/index.d.ts", - "default": "./dist/chat/index.js" - }, - "./catalog/all": { - "types": "./dist/catalog/all.d.ts", - "default": "./dist/catalog/all.js" + "./catalog": { + "types": "./dist/catalog/index.d.ts", + "default": "./dist/catalog/index.js" }, "./catalog/Text": { - "types": "./dist/catalog/Text.d.ts", - "default": "./dist/catalog/Text.js" + "types": "./dist/catalog/Text/index.d.ts", + "default": "./dist/catalog/Text/index.js" }, + "./catalog/Text/catalog.json": "./dist/catalog/Text/catalog.json", "./catalog/Image": { - "types": "./dist/catalog/Image.d.ts", - "default": "./dist/catalog/Image.js" + "types": "./dist/catalog/Image/index.d.ts", + "default": "./dist/catalog/Image/index.js" }, + "./catalog/Image/catalog.json": "./dist/catalog/Image/catalog.json", "./catalog/Button": { - "types": "./dist/catalog/Button.d.ts", - "default": "./dist/catalog/Button.js" + "types": "./dist/catalog/Button/index.d.ts", + "default": "./dist/catalog/Button/index.js" }, + "./catalog/Button/catalog.json": "./dist/catalog/Button/catalog.json", "./catalog/Row": { - "types": "./dist/catalog/Row.d.ts", - "default": "./dist/catalog/Row.js" + "types": "./dist/catalog/Row/index.d.ts", + "default": "./dist/catalog/Row/index.js" }, + "./catalog/Row/catalog.json": "./dist/catalog/Row/catalog.json", "./catalog/Column": { - "types": "./dist/catalog/Column.d.ts", - "default": "./dist/catalog/Column.js" + "types": "./dist/catalog/Column/index.d.ts", + "default": "./dist/catalog/Column/index.js" }, + "./catalog/Column/catalog.json": "./dist/catalog/Column/catalog.json", "./catalog/List": { - "types": "./dist/catalog/List.d.ts", - "default": "./dist/catalog/List.js" + "types": "./dist/catalog/List/index.d.ts", + "default": "./dist/catalog/List/index.js" }, + "./catalog/List/catalog.json": "./dist/catalog/List/catalog.json", "./catalog/Card": { - "types": "./dist/catalog/Card.d.ts", - "default": "./dist/catalog/Card.js" + "types": "./dist/catalog/Card/index.d.ts", + "default": "./dist/catalog/Card/index.js" }, + "./catalog/Card/catalog.json": "./dist/catalog/Card/catalog.json", "./catalog/Divider": { - "types": "./dist/catalog/Divider.d.ts", - "default": "./dist/catalog/Divider.js" - }, - "./catalog/TextField": { - "types": "./dist/catalog/TextField.d.ts", - "default": "./dist/catalog/TextField.js" - }, - "./catalog/Icon": { - "types": "./dist/catalog/Icon.d.ts", - "default": "./dist/catalog/Icon.js" - }, - "./catalog/Tabs": { - "types": "./dist/catalog/Tabs.d.ts", - "default": "./dist/catalog/Tabs.js" - }, - "./catalog/Modal": { - "types": "./dist/catalog/Modal.d.ts", - "default": "./dist/catalog/Modal.js" - }, - "./catalog/AudioPlayer": { - "types": "./dist/catalog/AudioPlayer.d.ts", - "default": "./dist/catalog/AudioPlayer.js" - }, - "./catalog/Slider": { - "types": "./dist/catalog/Slider.d.ts", - "default": "./dist/catalog/Slider.js" - }, - "./catalog/DateTimeInput": { - "types": "./dist/catalog/DateTimeInput.d.ts", - "default": "./dist/catalog/DateTimeInput.js" - }, - "./catalog/ChoicePicker": { - "types": "./dist/catalog/ChoicePicker.d.ts", - "default": "./dist/catalog/ChoicePicker.js" + "types": "./dist/catalog/Divider/index.d.ts", + "default": "./dist/catalog/Divider/index.js" }, + "./catalog/Divider/catalog.json": "./dist/catalog/Divider/catalog.json", "./catalog/CheckBox": { - "types": "./dist/catalog/CheckBox.d.ts", - "default": "./dist/catalog/CheckBox.js" + "types": "./dist/catalog/CheckBox/index.d.ts", + "default": "./dist/catalog/CheckBox/index.js" + }, + "./catalog/CheckBox/catalog.json": "./dist/catalog/CheckBox/catalog.json", + "./catalog/RadioGroup": { + "types": "./dist/catalog/RadioGroup/index.d.ts", + "default": "./dist/catalog/RadioGroup/index.js" + }, + "./catalog/RadioGroup/catalog.json": "./dist/catalog/RadioGroup/catalog.json", + "./react": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" } }, "main": "./dist/index.js", @@ -103,7 +88,6 @@ "devDependencies": { "@lynx-js/a2ui-catalog-extractor": "workspace:*", "@lynx-js/lynx-ui": "^3.130.0", - "@lynx-js/lynx-ui-input": "^3.130.0", "@lynx-js/react": "workspace:*", "@lynx-js/types": "3.7.0", "@rstest/core": "catalog:rstest", @@ -111,7 +95,11 @@ }, "peerDependencies": { "@lynx-js/lynx-ui": "^3.130.0", - "@lynx-js/lynx-ui-input": "^3.130.0", "@lynx-js/react": "workspace:^" + }, + "peerDependenciesMeta": { + "@lynx-js/lynx-ui": { + "optional": true + } } } diff --git a/packages/genui/a2ui/src/catalog/Button.ts b/packages/genui/a2ui/src/catalog/Button.ts deleted file mode 100755 index 655fcf6f70..0000000000 --- a/packages/genui/a2ui/src/catalog/Button.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { Button } from './Button/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Button', Button as unknown as ComponentRenderer); - -export { Button }; diff --git a/packages/genui/a2ui/src/catalog/Card.ts b/packages/genui/a2ui/src/catalog/Card.ts deleted file mode 100755 index 8046e98e46..0000000000 --- a/packages/genui/a2ui/src/catalog/Card.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { Card } from './Card/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Card', Card as unknown as ComponentRenderer); - -export { Card }; diff --git a/packages/genui/a2ui/src/catalog/CheckBox.ts b/packages/genui/a2ui/src/catalog/CheckBox.ts deleted file mode 100755 index 03c4ac6567..0000000000 --- a/packages/genui/a2ui/src/catalog/CheckBox.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { CheckBox } from './CheckBox/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register( - 'CheckBox', - CheckBox as unknown as ComponentRenderer, -); - -export { CheckBox }; diff --git a/packages/genui/a2ui/src/catalog/Column.ts b/packages/genui/a2ui/src/catalog/Column.ts deleted file mode 100755 index 04a7c48ff5..0000000000 --- a/packages/genui/a2ui/src/catalog/Column.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { Column } from './Column/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Column', Column as unknown as ComponentRenderer); - -export { Column }; diff --git a/packages/genui/a2ui/src/catalog/Divider.ts b/packages/genui/a2ui/src/catalog/Divider.ts deleted file mode 100755 index 44daa6fcf8..0000000000 --- a/packages/genui/a2ui/src/catalog/Divider.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { Divider } from './Divider/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Divider', Divider as unknown as ComponentRenderer); - -export { Divider }; diff --git a/packages/genui/a2ui/src/catalog/Image.ts b/packages/genui/a2ui/src/catalog/Image.ts deleted file mode 100755 index 96fcf68142..0000000000 --- a/packages/genui/a2ui/src/catalog/Image.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { Image } from './Image/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Image', Image as unknown as ComponentRenderer); - -export { Image }; diff --git a/packages/genui/a2ui/src/catalog/List.ts b/packages/genui/a2ui/src/catalog/List.ts deleted file mode 100755 index 4b0460fa0a..0000000000 --- a/packages/genui/a2ui/src/catalog/List.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { List } from './List/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('List', List as unknown as ComponentRenderer); - -export { List }; diff --git a/packages/genui/a2ui/src/catalog/README.md b/packages/genui/a2ui/src/catalog/README.md index cf447761a6..deedb6e474 100644 --- a/packages/genui/a2ui/src/catalog/README.md +++ b/packages/genui/a2ui/src/catalog/README.md @@ -1,17 +1,44 @@ # Catalog composition -`defineCatalog` builds the runtime catalog the renderer uses. Composition -is per-component so bundlers can tree-shake what you don't reference. +The package intentionally **does not** ship an "all-in-one" catalog +constant. A top-level array referencing every built-in defeats +tree-shaking — every consumer of such an aggregate would bundle every +component, even the nine you don't use. Composition is per-component, and +the cost is visible at the import site. ## The minimum a renderer needs -If you only need to render, names alone are enough. Pass bare components — -the protocol name comes from `displayName ?? component.name`: +If your app only renders, names alone are enough. Pass bare components — +the protocol name comes from `displayName ?? component.name`. + +> ⚠️ **Production minifiers will rename function declarations**, which breaks +> the `component.name` fallback. For production safety, set an explicit +> `displayName` on every custom component (the string literal survives +> minification), or pair the component with its `catalog.json` manifest +> using the tuple form below — the manifest key is authoritative. ```tsx -import { defineCatalog, Text, Button } from '@lynx-js/a2ui-reactlynx'; +import { + A2UI, + Text, + Button, + createMessageStore, +} from '@lynx-js/a2ui-reactlynx'; + +const store = createMessageStore(); + +// Push raw protocol messages from your IO module (fetch, SSE, ...). +// async function streamFromAgent(input) { +// for await (const msg of myAgent.stream(input)) store.push(msg); +// } -const catalog = defineCatalog([Text, Button]); + { + /* forward to your agent and push response messages back */ + }} +/>; ``` Bundlers tree-shake unused components — pulling `Text` does not drag in @@ -20,8 +47,8 @@ Bundlers tree-shake unused components — pulling `Text` does not drag in ## Adding schemas for the agent handshake If you want `serializeCatalog(...)` to emit JSON Schema for each component -(so the agent knows what props to send), pair each component with the JSON -the extractor emitted at `dist/catalog//catalog.json`: +(for the agent to know what props to send), pair each component with the +JSON the extractor emitted at `dist/catalog//catalog.json`: ```tsx import { Text } from '@lynx-js/a2ui-reactlynx/catalog/Text'; @@ -107,8 +134,11 @@ agent will use: ```tsx function MyChart(props: { data: number[] }) { ... } +// Required for production-safe naming — minifiers rewrite `function` +// names, but the `displayName` string literal survives. +MyChart.displayName = 'MyChart'; -const catalog = defineCatalog([Text, Button, MyChart]); + // Agent sends `{ component: 'MyChart', data: [...] }` → renders MyChart. ``` diff --git a/packages/genui/a2ui/src/catalog/RadioGroup.ts b/packages/genui/a2ui/src/catalog/RadioGroup.ts deleted file mode 100644 index 1f2e2f2a14..0000000000 --- a/packages/genui/a2ui/src/catalog/RadioGroup.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { RadioGroup } from './RadioGroup/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register( - 'RadioGroup', - RadioGroup as unknown as ComponentRenderer, -); - -export { RadioGroup }; diff --git a/packages/genui/a2ui/src/catalog/Row.ts b/packages/genui/a2ui/src/catalog/Row.ts deleted file mode 100755 index 6a025a3221..0000000000 --- a/packages/genui/a2ui/src/catalog/Row.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { Row } from './Row/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Row', Row as unknown as ComponentRenderer); - -export { Row }; diff --git a/packages/genui/a2ui/src/catalog/Text.ts b/packages/genui/a2ui/src/catalog/Text.ts deleted file mode 100755 index 5e61540015..0000000000 --- a/packages/genui/a2ui/src/catalog/Text.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { Text } from './Text/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Text', Text as unknown as ComponentRenderer); - -export { Text }; diff --git a/packages/genui/a2ui/src/catalog/all.ts b/packages/genui/a2ui/src/catalog/all.ts deleted file mode 100755 index bf0c2369e9..0000000000 --- a/packages/genui/a2ui/src/catalog/all.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -// Exporting from these modules also runs their side effects (component registration). -export * from './Text.js'; -export * from './Image.js'; -export * from './Row.js'; -export * from './Column.js'; -export * from './List.js'; -export * from './Card.js'; -export * from './Button.js'; -export * from './Divider.js'; -export * from './CheckBox.js'; -export * from './RadioGroup.js'; diff --git a/packages/genui/a2ui/src/catalog/index.ts b/packages/genui/a2ui/src/catalog/index.ts index 5f1f1657cd..021ab941ba 100755 --- a/packages/genui/a2ui/src/catalog/index.ts +++ b/packages/genui/a2ui/src/catalog/index.ts @@ -17,7 +17,16 @@ export type { SerializedCatalog, } from './defineCatalog.js'; -// Existing global-registry exports — kept for back-compat with the -// current `core/A2UIRender` path. The new `defineCatalog` API above is -// the pluggable, side-effect-free alternative. -export * from './all.js'; +// Per-component re-exports so consumers can pick exactly what they need. +// Each is an independently tree-shakeable ESM re-export — pulling `Text` +// does not drag `Button` into the bundle. +export { Button } from './Button/index.jsx'; +export { Card } from './Card/index.jsx'; +export { CheckBox } from './CheckBox/index.jsx'; +export { Column } from './Column/index.jsx'; +export { Divider } from './Divider/index.jsx'; +export { Image } from './Image/index.jsx'; +export { List } from './List/index.jsx'; +export { RadioGroup } from './RadioGroup/index.jsx'; +export { Row } from './Row/index.jsx'; +export { Text } from './Text/index.jsx'; diff --git a/packages/genui/a2ui/src/chat/Conversation.tsx b/packages/genui/a2ui/src/chat/Conversation.tsx deleted file mode 100755 index aa07e4ab42..0000000000 --- a/packages/genui/a2ui/src/chat/Conversation.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { - Input, - KeyboardAwareResponder, - KeyboardAwareRoot, - KeyboardAwareTrigger, -} from '@lynx-js/lynx-ui-input'; -import type { InputRef } from '@lynx-js/lynx-ui-input'; -import { useCallback, useRef, useState } from '@lynx-js/react'; - -import { useLynxClient } from './useLynxClient.js'; -import { A2UIRender } from '../core/A2UIRender.jsx'; -import type { Resource } from '../core/types.js'; - -export interface Message { - id: string; - role: 'user' | 'agent'; - content: string; - resource?: Resource; -} - -export interface ConversationProps { - initialInput?: string; - messages?: Message[]; - sendMessage?: (content: string) => Promise; - url?: string; -} - -export function Conversation( - props: ConversationProps, -): import('@lynx-js/react').ReactNode { - const { initialInput, url } = props; - - // Logic to handle self-managed state if url is provided - // We pass a dummy string if url is missing to satisfy the hook type, but we won't use the result if controlled - const hookResult = useLynxClient(url ?? ''); - - // If controlled props are provided, use them; otherwise use hook result if url is present - const messages = props.messages ?? (url ? hookResult.messages : []); - const sendMessage = props.sendMessage - ?? (url ? hookResult.sendMessage : undefined); - const inputRef = useRef(null); - - if (!sendMessage && !props.messages) { - // Fallback or error if neither controlled nor uncontrolled props are valid - console.warn( - 'Conversation requires either `messages` and `sendMessage` OR `url`.', - ); - } - - const [inputValue, setInputValue] = useState(initialInput); - const [isLoading, setIsLoading] = useState(false); - - const handleInput = useCallback((e: string) => { - setInputValue(e); - }, []); - - const handleSend = useCallback(() => { - setIsLoading(true); - const content = inputValue ?? 'Introduce yourself.'; - setInputValue(''); - void inputRef.current?.blur(); - void inputRef.current?.setValue(''); - try { - if (sendMessage) { - void sendMessage(content); - } - } catch (e) { - console.error('sendMessage error:', e); - } finally { - setIsLoading(false); - } - }, [inputValue, sendMessage]); - - return ( - - - - {messages?.map((item: Message) => - item.role === 'user' - ? ( - - {item.content} - - ) - : ( - - ( - Thinking... - )} - /> - - ) - )} - {isLoading && ( - - Thinking... - - )} - - - - - { - void handleSend(); - }} - > - - - - - - - ); -} diff --git a/packages/genui/a2ui/src/chat/index.ts b/packages/genui/a2ui/src/chat/index.ts deleted file mode 100644 index 107469a136..0000000000 --- a/packages/genui/a2ui/src/chat/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -export { Conversation, type Message } from './Conversation.jsx'; -export { useLynxClient } from './useLynxClient.js'; diff --git a/packages/genui/a2ui/src/chat/useLynxClient.ts b/packages/genui/a2ui/src/chat/useLynxClient.ts deleted file mode 100644 index b18375f069..0000000000 --- a/packages/genui/a2ui/src/chat/useLynxClient.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { useCallback, useRef, useState } from '@lynx-js/react'; - -import type { Message } from './Conversation.jsx'; -import { BaseClient } from '../core/BaseClient.js'; -import type { Resource } from '../core/types.js'; - -export interface UseLynxClientOptions { - keepHistory?: boolean; -} - -export interface UseLynxClientResult { - messages: Message[]; - sendMessage: ( - content: string, - ) => Promise<{ messageId: string; resource: Resource }>; - setMessages: import('@lynx-js/react').Dispatch< - import('@lynx-js/react').SetStateAction - >; - clientRef: import('@lynx-js/react').MutableRefObject; -} - -export function useLynxClient( - url: string, - options: UseLynxClientOptions = {}, -): UseLynxClientResult { - const { keepHistory = true } = options; - const clientRef = useRef(null); - const [messages, setMessages] = useState([]); - - const getClient = useCallback(() => { - if (!clientRef.current) { - const client = new BaseClient(url); - - client.onResourceCreated = (resource, id) => { - if (!keepHistory) return; - setMessages((prev) => [ - ...prev, - { - id: `${id}-agent-new`, - role: 'agent', - content: '', - resource, - }, - ]); - }; - - client.onResponseComplete = (messageId, { hasBeginRendering }) => { - if (!keepHistory) return; - if (!hasBeginRendering) { - setMessages((prev) => - prev.filter( - (m) => - m.id !== `${messageId}-agent` - && m.id !== `${messageId}-agent-new`, - ) - ); - } - }; - - clientRef.current = client; - } - return clientRef.current; - }, [url, keepHistory]); - - const sendMessage = useCallback( - async (content: string) => { - const client = getClient(); - - if (keepHistory) { - const userMsgId = 'user_' - + Date.now().toString(36) - + Math.random().toString(36).slice(2, 10); - setMessages((prev) => [ - ...prev, - { - id: userMsgId, - role: 'user', - content, - }, - ]); - } - - const result = await client.makeRequest(content); - const { messageId, resource } = result; - - if (keepHistory) { - setMessages((prev) => [ - ...prev, - { - id: `${messageId}-agent`, - role: 'agent', - content: '', - resource, - }, - ]); - } - - return result; - }, - [getClient, keepHistory], - ); - - return { - messages, - sendMessage, - setMessages, - clientRef, - }; -} diff --git a/packages/genui/a2ui/src/core/A2UIRender.tsx b/packages/genui/a2ui/src/core/A2UIRender.tsx deleted file mode 100644 index 8981189f02..0000000000 --- a/packages/genui/a2ui/src/core/A2UIRender.tsx +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { memo, useEffect, useState } from '@lynx-js/react'; -import type { ReactNode } from '@lynx-js/react'; - -import { componentRegistry } from './ComponentRegistry.js'; -import type { ComponentInstance, Resource, Surface } from './types.js'; -import { useAction } from './useAction.js'; -import { useResolvedProps } from './useDataBinding.js'; - -function Loading(props: { id: string }) { - const content = `loading ${props.id}...`; - return ( - - {content} - - ); -} - -function buildNodeRecursive( - component: ComponentInstance, - surface: Surface, - resolvedProps?: Record, - setValue?: (key: string, value: unknown) => void, - sendAction?: (action: Record) => void, -): ReactNode { - const tag = component.component; - - if (componentRegistry.has(tag)) { - const Component = componentRegistry.get(tag)! as unknown as ( - props: Record, - ) => import('@lynx-js/react').ReactNode; - return ( - ) => { - void sendAction?.(a); - }} - dataContextPath={component.dataContextPath} - {...resolvedProps} - /> - ); - } - - console.warn(`Component ${tag} not registered in v0.9 registry.`); - return null; -} - -function A2UIRenderImpl( - props: { - resource: Resource; - renderFallback?: () => import('@lynx-js/react').ReactNode; - }, -): import('@lynx-js/react').ReactNode { - const { resource } = props; - const [data, setData] = useState(() => { - if (resource.completed) { - try { - return resource.read(); - } catch { - return null; - } - } - return null; - }); - const [loading, setLoading] = useState(!resource.completed); - const [error, setError] = useState(null); - - useEffect(() => { - let active = true; - - if (resource.completed) { - try { - const res = resource.read(); - if (active) { - setData(res); - setLoading(false); - } - } catch (err) { - if (active) { - setError(err as Error); - setLoading(false); - } - } - } else { - resource.promise - .then((res) => { - if (active) { - setData(res); - setLoading(false); - } - }) - .catch((err) => { - if (active) { - setError(err as Error); - setLoading(false); - } - }); - } - - const unsubscribe = resource.onUpdate((newData) => { - if (active) { - setData(newData); - } - }); - - return () => { - active = false; - unsubscribe(); - }; - }, [resource]); - - if (loading) { - return props.renderFallback?.() ?? ; - } - - if (error) { - return Error: {String(error)}; - } - - if (!data) return null; - - const dataObj = data as Record; - const type = dataObj['type'] as string; - const surfaceId = dataObj['surfaceId'] as string; - const surface = dataObj['surface'] as Surface; - const component = dataObj['component'] as ComponentInstance | undefined; - if (type === 'beginRendering') { - const id = surface.rootComponentId!; - const childResource = surface.resources.get(id); - if (!childResource) return null; - return ( - - - - ); - } - - if (type === 'surfaceUpdate' && component) { - return ; - } - - if (type === 'deleteSurface') { - return null; - } - - return null; -} - -export const A2UIRender = memo(A2UIRenderImpl); - -export function NodeRenderer( - props: { component: ComponentInstance; surface: Surface }, -): import('@lynx-js/react').ReactNode { - const { component: initialComponent, surface } = props; - const [component, setComponent] = useState(initialComponent); - - useEffect(() => { - setComponent(initialComponent); - }, [initialComponent]); - - useEffect(() => { - const resource = surface.resources.get(component.id!); - if (!resource) return; - - return resource.onUpdate((data) => { - const dataMap = data as unknown as Readonly>; - if (dataMap['type'] === 'surfaceUpdate' && dataMap['component']) { - setComponent({ ...(dataMap['component'] as ComponentInstance) }); - } - }); - }, [component.id, surface]); - - const [resolvedProps, setValue] = useResolvedProps( - component, - surface, - component.dataContextPath, - ); - - const { sendAction } = useAction({ - id: component.id!, - surfaceId: surface.surfaceId, - dataContext: component.dataContextPath, - }); - - return ( - <> - {buildNodeRecursive( - component, - surface, - resolvedProps, - setValue, - (a: Record) => { - void sendAction(a as unknown as Parameters[0]); - }, - )} - - ); -} diff --git a/packages/genui/a2ui/src/core/BaseClient.ts b/packages/genui/a2ui/src/core/BaseClient.ts deleted file mode 100755 index dcbdf52248..0000000000 --- a/packages/genui/a2ui/src/core/BaseClient.ts +++ /dev/null @@ -1,590 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import type * as v0_9 from '@a2ui/web_core/v0_9'; - -import { processor } from './processor.js'; -import type { MessageProcessor } from './processor.js'; -import type { - A2UIClientEventMessage, - Resource, - ServerToClientMessage, - UserActionPayload, -} from './types.js'; -import { createResource } from '../utils/createResource.js'; - -const MESSAGE_PROCESS_DELAY = 300; - -function randomId(prefix = '') { - return prefix + Date.now().toString(36) - + Math.random().toString(36).slice(2, 10); -} - -function buildSseParams( - message: A2UIClientEventMessage, - messageId: string, -): Record { - const params: Record = { messageId }; - const anyMessage: Record = message as Record< - string, - unknown - >; - - if (typeof message === 'string') { - params['text'] = message; - } else if (anyMessage) { - if (typeof anyMessage['text'] === 'string') { - params['text'] = anyMessage['text']; - } else if (anyMessage['text']) { - params['text'] = JSON.stringify(anyMessage['text']); - } else if (anyMessage['userAction']) { - const userAction = anyMessage['userAction'] as { - name: string; - context?: Record; - }; - const actionName = userAction.name || 'unknownAction'; - const context = userAction.context ?? {}; - params['text'] = `USER_ACTION: ${actionName}, Context: ${ - JSON.stringify(context) - }`; - } else { - params['text'] = JSON.stringify(message); - } - - if (typeof anyMessage['sessionId'] === 'string') { - params['sessionId'] = anyMessage['sessionId']; - } else if (anyMessage['sessionId']) { - params['sessionId'] = JSON.stringify(anyMessage['sessionId']); - } - } - - return params; -} - -function createFallbackMessagesFromPlainText(text: string) { - const surfaceId = 'default'; - const rootId = 'root-text'; - - return [ - { - createSurface: { - surfaceId, - catalogId: 'inline-text', - }, - }, - { - updateComponents: { - surfaceId, - components: [ - { - id: rootId, - component: 'Text', - text, - }, - ], - }, - }, - ]; -} - -function createTextCardMessages(text: string) { - const surfaceId = randomId('text_surface_'); - const rootId = 'root'; - const textId = 'text'; - - return [ - { - createSurface: { - surfaceId, - catalogId: 'inline-text', - }, - }, - { - updateComponents: { - surfaceId, - components: [ - { - id: rootId, - component: 'Card', - child: textId, - }, - { - id: textId, - component: 'Text', - text, - variant: 'body', - }, - ], - }, - }, - ]; -} - -function normalizePayloadToMessages(payload: unknown): ServerToClientMessage[] { - const messages: ServerToClientMessage[] = []; - - const add = (value: unknown) => { - if (!value) return; - if (Array.isArray(value)) { - for (const item of value) { - add(item); - } - } else { - messages.push(value as ServerToClientMessage); - } - }; - - const handle = (value: unknown): void => { - if (value === null || value === undefined) return; - - if (Array.isArray(value)) { - for (const item of value) { - handle(item); - } - return; - } - - if (typeof value === 'string') { - add(createFallbackMessagesFromPlainText(value)); - return; - } - - if (typeof value === 'object') { - const v = value as Record; - - if ( - v['createSurface'] || v['updateComponents'] || v['updateDataModel'] - || v['deleteSurface'] - ) { - add(v); - return; - } - - if ('kind' in v && 'data' in v) { - if (v['kind'] === 'data') { - handle(v['data']); - } else if (v['kind'] === 'text') { - add( - createTextCardMessages( - typeof v['data'] === 'string' ? v['data'] : String(v['data']), - ), - ); - } - return; - } - - if (Array.isArray(v['messages'])) { - handle(v['messages']); - return; - } - - add(createFallbackMessagesFromPlainText(JSON.stringify(v))); - return; - } - }; - - handle(payload); - return messages; -} - -function prepareMessagesForProcessing( - rawMessages: ServerToClientMessage[], - messageId: string, - activeSurfaceIds: Set, -) { - let hasComponentUpdate = false; - const messages = rawMessages.filter((msg: ServerToClientMessage) => { - const deletedSurfaceId = (msg as { deleteSurface?: { surfaceId?: string } }) - .deleteSurface?.surfaceId; - if (typeof deletedSurfaceId === 'string') { - activeSurfaceIds.delete(deletedSurfaceId); - } - - const createdSurfaceId = (msg as { createSurface?: { surfaceId?: string } }) - .createSurface?.surfaceId; - if (typeof createdSurfaceId === 'string') { - if (activeSurfaceIds.has(createdSurfaceId)) { - return false; - } - activeSurfaceIds.add(createdSurfaceId); - } - - if ( - ((msg as { updateComponents?: { components?: unknown[] } }) - .updateComponents - && Array.isArray( - (msg as { updateComponents?: { components?: unknown[] } }) - .updateComponents?.components, - ) - && (((msg as { updateComponents?: { components: unknown[] } }) - .updateComponents?.components ?? []).length > 0)) - ) { - hasComponentUpdate = true; - } - - msg.messageId ??= messageId; - - return true; - }); - - return { messages, hasComponentUpdate }; -} - -export class BaseClient { - protected processor: MessageProcessor; - protected resources: Map; - protected resolves: Map void>; - protected baseUrl: string; - public onResponseComplete?: ( - messageId: string, - info: { hasBeginRendering: boolean }, - ) => void; - public onResourceCreated?: (resource: Resource, messageId: string) => void; - - constructor(baseUrl: string) { - this.processor = processor as unknown as MessageProcessor; - this.resources = new Map(); - this.resolves = new Map(); - this.baseUrl = baseUrl; - - this.processor.onUpdate((data) => { - const { type, surfaceId, messageId, updates = [] } = data as { - surfaceId: string; - type: string; - messageId: string; - updates?: v0_9.AnyComponent[]; - targetId?: string; - }; - - const surface = this.processor.getOrCreateSurface(surfaceId); - - if (type === 'beginRendering') { - const resource = this.resources.get(messageId); - resource?.complete({ type: 'beginRendering', surfaceId, surface }); - } else if (type === 'surfaceUpdate') { - (updates || []).forEach((update) => { - if (!update.id) return; - const resource = surface.resources.get(update.id); - resource?.complete({ - type: 'surfaceUpdate', - surfaceId, - surface, - component: update as import('./types.js').ComponentInstance, - }); - }); - } else if (type === 'deleteSurface') { - const { targetId } = data as { targetId?: string }; - const target = targetId ?? surface.rootComponentId; - if (target && surface.resources.has(target)) { - const resource = surface.resources.get(target)!; - resource.complete({ type: 'deleteSurface', surfaceId, surface }); - } - } - - const resolve = this.resolves.get(messageId); - if (resolve) { - resolve({ type, surfaceId, surface }); - this.resolves.delete(messageId); - } - }); - - this.processor.onEvent( - ({ message, resolve }: import('./processor.js').A2UIEvent) => { - void (async () => { - if ( - typeof message === 'object' && message !== null - && 'userAction' in message - && (message as { userAction: unknown }).userAction - ) { - try { - const response = await this.processUserAction( - (message as { userAction: unknown }) - .userAction as UserActionPayload, - ); - resolve(response); - } catch (e) { - console.error('Error processing userAction', e); - resolve([]); - } - } else { - resolve([]); - } - })(); - }, - ); - } - - async processUserAction(userAction: UserActionPayload): Promise { - const response = await this.send({ userAction } as A2UIClientEventMessage); - const { messageId, resource, startStreaming, promise } = response; - this.resources.set(messageId, resource); - if (this.onResourceCreated) { - this.onResourceCreated(resource, messageId); - } - startStreaming(); - return promise; - } - - async makeRequest( - request: string, - ): Promise< - { messageId: string; resource: Resource; promise: Promise } - > { - const response = await this.send( - request as unknown as A2UIClientEventMessage, - ); - const { messageId, resource, startStreaming, promise } = response; - this.resources.set(messageId, resource); - startStreaming(); - return { messageId, resource, promise }; - } - - async send( - message: A2UIClientEventMessage, - id?: string, - ): Promise<{ - messageId: string; - resource: Resource; - startStreaming: () => void; - promise: Promise; - }> { - const messageId = id ?? randomId('task_'); - const promise = new Promise((resolve) => { - this.resolves.set(messageId, resolve); - }); - - const resource = createResource(messageId) as unknown as Resource; - - const startStreaming = () => { - void (async () => { - const params = new URLSearchParams(buildSseParams(message, messageId)); - - interface TypedEventSource { - addEventListener( - type: string, - listener: ( - event: { data?: unknown; target?: unknown; type?: string }, - ) => void, - ): void; - close(): void; - readyState: number; - } - const g = globalThis as Record; - const tsKey = 'Event' + 'Source'; - const NativeES = g[tsKey] as - | (new(url: string) => TypedEventSource) - | undefined; - const EventSourceImpl = NativeES - ?? (lynx.EventSource as unknown as new( - url: string, - ) => TypedEventSource); - - const url = `${this.baseUrl}?${params.toString()}`; - - console.info('[BaseClient v0.9] streaming answer message', message); - - if (url.includes('localhost') && typeof lynx !== 'undefined') { - console.warn( - '[BaseClient v0.9] You are using \'localhost\' in Lynx environment. This may not work on a physical device. Please use your computer\'s IP address.', - ); - } - - if (url.length > 2048) { - console.warn( - `[BaseClient v0.9] URL is too long (${url.length} chars), request might fail. Consider using POST or shortening the payload.`, - ); - } - - console.info( - '[BaseClient v0.9] Using EventSource implementation:', - EventSourceImpl === NativeES - ? 'Native EventSource' - : 'Custom/Lynx EventSource', - ); - - const eventSource = new EventSourceImpl(url); - - console.info( - '[BaseClient v0.9] EventSource created, readyState:', - (eventSource as unknown as { readyState: number }).readyState, - ); - - let isCompleted = false; - let hasBeginRendering = false; - let hasReceivedProcessedPayload = false; - const activeSurfaceIds = new Set(this.processor.getSurfaces().keys()); - - const messageQueue: ServerToClientMessage[][] = []; - let isProcessingQueue = false; - - const processQueue = async () => { - if (isProcessingQueue) return; - isProcessingQueue = true; - while (messageQueue.length > 0) { - const msgs = messageQueue.shift(); - if (msgs && msgs.length > 0) { - this.processor.processMessages(msgs); - } - await new Promise((resolve) => - setTimeout(resolve, MESSAGE_PROCESS_DELAY) - ); - } - isProcessingQueue = false; - }; - - eventSource.addEventListener( - 'open', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.info('[BaseClient v0.9] SSE connection opened', event); - }, - ); - - eventSource.addEventListener( - 'update', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.info( - '[BaseClient v0.9] SSE update event', - event.data, - event, - ); - }, - ); - - eventSource.addEventListener( - 'delta', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.info( - '[BaseClient v0.9] SSE delta event', - event.data, - event, - ); - try { - let payload = event.data; - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload); - } catch { - // ignore - } - } - - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload); - } catch { - // ignore - } - } - - const messages = normalizePayloadToMessages(payload); - console.info( - '[BaseClient v0.9] Normalized delta messages', - messages, - ); - const prepared = prepareMessagesForProcessing( - messages, - messageId, - activeSurfaceIds, - ); - if (prepared.hasComponentUpdate) { - hasBeginRendering = true; - } - - if (prepared.messages.length > 0) { - hasReceivedProcessedPayload = true; - messageQueue.push(prepared.messages); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - processQueue(); - } - } catch (e) { - console.error('Error processing delta', e); - } - }, - ); - - eventSource.addEventListener( - 'complete', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.info( - '[BaseClient v0.9] SSE complete event', - event.data, - event, - ); - if (isCompleted) return; - isCompleted = true; - - try { - let payload = event.data; - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload); - } catch { - // ignore - } - } - - const messages = normalizePayloadToMessages(payload); - console.info( - '[BaseClient v0.9] Normalized complete messages', - messages, - ); - - if (!hasReceivedProcessedPayload && messages.length > 0) { - const prepared = prepareMessagesForProcessing( - messages, - messageId, - activeSurfaceIds, - ); - if (prepared.hasComponentUpdate) { - hasBeginRendering = true; - } - - if (prepared.messages.length > 0) { - messageQueue.push(prepared.messages); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - processQueue(); - } - } - } catch (e) { - console.error( - '[BaseClient v0.9] Error processing complete payload', - e, - ); - } - - eventSource.close(); - - if (this.onResponseComplete) { - this.onResponseComplete(messageId, { hasBeginRendering }); - } - }, - ); - - eventSource.addEventListener( - 'error', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.error('[BaseClient v0.9] SSE error details:', event); - if ( - event && typeof event === 'object' && 'target' in event - && event.target && typeof event.target === 'object' - && 'readyState' in event.target - && typeof (event.target as Record)['readyState'] - !== 'undefined' - ) { - const target = event.target as Record; - console.error( - '[BaseClient v0.9] SSE readyState:', - target['readyState'], - ); - } - eventSource.close(); - }, - ); - })(); - }; - - return { messageId, resource, startStreaming, promise }; - } -} diff --git a/packages/genui/a2ui/src/core/ComponentRegistry.ts b/packages/genui/a2ui/src/core/ComponentRegistry.ts deleted file mode 100644 index 10daf8f8fd..0000000000 --- a/packages/genui/a2ui/src/core/ComponentRegistry.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import type { ComponentType } from '@lynx-js/react'; - -import type { ComponentInstance, Surface } from './types.js'; - -export class BaseComponentRegistry { - private registry = new Map(); - register(name: string, component: T): void { - this.registry.set(name, component); - } - has(name: string): boolean { - return this.registry.has(name); - } - get(name: string): T | undefined { - return this.registry.get(name); - } -} - -export interface ComponentProps { - id: string; - surfaceId: string; - surface: Surface; - /** - * The full v0.9 component instance as defined by the protocol. - */ - component: ComponentInstance; -} - -export type ComponentRenderer = ComponentType; - -export class ComponentRegistry - extends BaseComponentRegistry -{} - -export const componentRegistry = new ComponentRegistry(); diff --git a/packages/genui/a2ui/src/core/index.ts b/packages/genui/a2ui/src/core/index.ts deleted file mode 100644 index ddcd260280..0000000000 --- a/packages/genui/a2ui/src/core/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -export * from './types.js'; -export { A2UIRender } from './A2UIRender.jsx'; -export { BaseClient } from './BaseClient.js'; -export { componentRegistry, type ComponentProps } from './ComponentRegistry.js'; -export { processor, type MessageProcessor } from './processor.js'; -export { useAction } from './useAction.js'; -export { useDataBinding, useResolvedProps } from './useDataBinding.js'; diff --git a/packages/genui/a2ui/src/core/types.ts b/packages/genui/a2ui/src/core/types.ts deleted file mode 100644 index 2a7bfd4db9..0000000000 --- a/packages/genui/a2ui/src/core/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import type * as v0_9 from '@a2ui/web_core/v0_9'; - -import type { Resource as GenericResource } from '../utils/createResource.js'; -import type { SignalStore } from '../utils/SignalStore.js'; - -export type SurfaceId = string; - -export type ComponentInstance = v0_9.AnyComponent & { - /** - * Absolute data context path for this component when created via a template. - * Used for resolving relative bindings inside the component tree. - */ - dataContextPath?: string; - /** - * Internal metadata for templated containers so we can re-expand on - * data model updates. - */ - __template?: { - componentId: v0_9.ComponentId; - path: string; - }; -}; - -export interface Surface { - surfaceId: SurfaceId; - catalogId?: string; - theme?: Readonly>; - sendDataModel?: boolean | undefined; - /** id of the root component for this surface (must be 'root'). */ - rootComponentId?: string | null; - components: Map; - resources: Map; - store: SignalStore; -} - -export interface ResourceInfo { - /** - * Internal event type emitted by the processor. - */ - type: 'beginRendering' | 'surfaceUpdate' | 'deleteSurface'; - surfaceId: string; - surface: Surface; - component?: ComponentInstance; -} - -export type Resource = GenericResource; - -export type ServerToClientMessage = v0_9.A2uiMessage & { - /** - * Message id injected by the client (SSE messageId / task id). - */ - messageId?: string; -}; - -export interface UserActionPayload { - name: string; - surfaceId: string; - sourceComponentId: string; - timestamp: string; // ISO 8601 - context: Record; -} - -export type A2UIClientEventMessage = - | string - | { - text?: string; - sessionId?: string; - } - | { - userAction: UserActionPayload; - sessionId?: string; - }; - -export interface GenericComponentProps { - id?: string; - surface: Surface; - setValue?: (key: string, value: unknown) => void; - sendAction?: (action: Record) => void; - dataContextPath?: string; - [key: string]: unknown; -} diff --git a/packages/genui/a2ui/src/core/useAction.ts b/packages/genui/a2ui/src/core/useAction.ts deleted file mode 100644 index cd318e301b..0000000000 --- a/packages/genui/a2ui/src/core/useAction.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import type * as v0_9 from '@a2ui/web_core/v0_9'; - -import { processor } from './processor.js'; -import type { UserActionPayload } from './types.js'; - -export interface ActionProps { - id: string; - surfaceId: string; - dataContext?: string | undefined; -} - -function isDataBinding(value: unknown): value is v0_9.DataBinding { - return !!value && typeof value === 'object' - && 'path' in (value as Record); -} - -function isFunctionCall(value: unknown): value is v0_9.FunctionCall { - return !!value && typeof value === 'object' - && 'call' in (value as Record); -} - -function resolveFromStore( - path: string, - surfaceId: string, - dataContextPath?: string, -): unknown { - const surface = processor.getOrCreateSurface(surfaceId); - const store = surface.store; - const resolvedPath = processor.resolvePath(path, dataContextPath); - const signal = store.getSignal(resolvedPath); - const raw = signal.value; - if (!raw) return raw; - try { - return JSON.parse(raw as string); - } catch { - return raw; - } -} - -function resolveDynamicValue( - value: v0_9.DynamicValue, - surfaceId: string, - dataContextPath?: string, -): unknown { - if ( - typeof value === 'string' || typeof value === 'number' - || typeof value === 'boolean' - ) { - return value; - } - - if (Array.isArray(value)) { - return value.map((v: unknown) => v); - } - - if (isDataBinding(value)) { - return resolveFromStore(value.path, surfaceId, dataContextPath); - } - - if (isFunctionCall(value)) { - return resolveFunctionCall(value, surfaceId, dataContextPath); - } - - return value; -} - -function resolveFunctionArguments( - args: Record | undefined, - surfaceId: string, - dataContextPath?: string, -): Record | undefined { - if (!args) return undefined; - const resolved: Record = {}; - for (const [key, val] of Object.entries(args)) { - if (isObject(val) && !isDataBinding(val) && !isFunctionCall(val)) { - // Literal object configuration; do a shallow copy. - resolved[key] = { ...val }; - } else { - resolved[key] = resolveDynamicValue( - val as v0_9.DynamicValue, - surfaceId, - dataContextPath, - ); - } - } - return resolved; -} - -function resolveFunctionCall( - fn: v0_9.FunctionCall, - surfaceId: string, - dataContextPath?: string, -): Record { - return { - call: fn.call, - args: resolveFunctionArguments(fn.args, surfaceId, dataContextPath), - returnType: fn.returnType, - }; -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -export function useAction( - props: ActionProps, -): { sendAction: (action: v0_9.Action) => Promise } { - const { id, surfaceId, dataContext } = props; - - const sendAction = (action: v0_9.Action) => { - let name = 'unknownAction'; - let context: Record = {}; - - if ('event' in action && action.event) { - name = action.event.name; - const ctx = action.event.context as Record | undefined; - if (ctx) { - const resolvedContext: Record = {}; - for (const [key, value] of Object.entries(ctx)) { - resolvedContext[key] = resolveDynamicValue( - value as v0_9.DynamicValue, - surfaceId, - dataContext, - ); - } - context = resolvedContext; - } - } else if ('functionCall' in action && action.functionCall) { - const fn = action.functionCall; - name = fn.call; - context = { - functionCall: resolveFunctionCall(fn, surfaceId, dataContext), - }; - } - - const userAction: UserActionPayload = { - name, - surfaceId, - sourceComponentId: id, - timestamp: new Date().toISOString(), - context, - }; - - return processor.dispatch({ userAction }); - }; - - return { - sendAction, - }; -} diff --git a/packages/genui/a2ui/src/core/useDataBinding.ts b/packages/genui/a2ui/src/core/useDataBinding.ts deleted file mode 100644 index 52d96c4415..0000000000 --- a/packages/genui/a2ui/src/core/useDataBinding.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { effect } from '@preact/signals'; - -import { useEffect, useState } from '@lynx-js/react'; - -import type { Surface } from './types.js'; - -export function useDataBinding( - dynamicValue: unknown, - surface: Surface | undefined, - dataContextPath?: string, - fallbackValue?: T, -): [T | undefined, (newValue: T) => void, string | undefined] { - let path: string | undefined; - let initialValue: string | undefined; - - if (typeof dynamicValue === 'string') { - initialValue = dynamicValue; - } else if ( - dynamicValue - && typeof dynamicValue === 'object' - && 'path' in dynamicValue - ) { - path = (dynamicValue as Record)['path'] as - | string - | undefined; - } else if ( - typeof dynamicValue === 'number' || typeof dynamicValue === 'boolean' - ) { - initialValue = String(dynamicValue); - } - - if (path && !path.startsWith('/')) { - if (dataContextPath) { - path = `${dataContextPath}/${path}`; - } else { - path = `/${path}`; - } - } - - const signal = surface?.store && path - ? surface.store.getSignal(path, initialValue) - : undefined; - - const [signalValue, setSignalValue] = useState(() => - signal?.value as T | undefined - ); - - useEffect(() => { - if (!signal) return; - const dispose = effect(() => { - setSignalValue(signal.value as T | undefined); - }); - return dispose; - }, [signal]); - - const currentValue = path - ? (signalValue ?? (initialValue as T | undefined)) - : (initialValue as T | undefined ?? fallbackValue); - - const setValue = (newValue: T) => { - if (path && surface?.store) { - surface.store.update(path, newValue); - } - }; - - return [currentValue, setValue, path]; -} - -function isDataBinding(prop: unknown): boolean { - // In 0.9, a binding is typically { path: string } - // Exclude template configurations like { path: string, componentId: string } - return Boolean( - prop - && typeof prop === 'object' - && 'path' in prop - && !('componentId' in prop), - ); -} - -function resolveProperties( - properties: Record, - surface: Surface | undefined, - dataContextPath?: string, -) { - if (!properties) return properties; - const result: Record = {}; - for (const key in properties) { - const prop = properties[key]; - if (isDataBinding(prop)) { - let path = (prop as Record)['path'] as - | string - | undefined; - if (path && typeof path === 'string' && !path.startsWith('/')) { - path = dataContextPath ? `${dataContextPath}/${path}` : `/${path}`; - } - - if (path && surface?.store) { - const signal = surface.store.getSignal(path); - result[key] = signal.value; - } else { - result[key] = undefined; - } - } else if ( - typeof prop === 'string' - || typeof prop === 'number' - || typeof prop === 'boolean' - ) { - result[key] = prop; - } else { - result[key] = prop; - } - } - return result; -} - -export function useResolvedProps( - properties: Record, - surface: Surface | undefined, - dataContextPath?: string, -): readonly [Record, (key: string, value: unknown) => void] { - const [resolved, setResolved] = useState(() => - resolveProperties(properties, surface, dataContextPath) - ); - - useEffect(() => { - if (!surface?.store) return; - const dispose = effect(() => { - setResolved(resolveProperties(properties, surface, dataContextPath)); - }); - return dispose; - }, [properties, surface, dataContextPath]); - - const setValue = (key: string, value: unknown) => { - const prop = properties?.[key]; - if (isDataBinding(prop)) { - let path = (prop as Record)['path'] as - | string - | undefined; - if (path && surface?.store) { - if (!path.startsWith('/')) { - path = dataContextPath ? `${dataContextPath}/${path}` : `/${path}`; - } - surface.store.update(path, value); - } - } - }; - - return [resolved, setValue] as readonly [ - Record, - (key: string, value: unknown) => void, - ]; -} diff --git a/packages/genui/a2ui/src/index.ts b/packages/genui/a2ui/src/index.ts new file mode 100644 index 0000000000..66af08327a --- /dev/null +++ b/packages/genui/a2ui/src/index.ts @@ -0,0 +1,79 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +// React surface — `` is the all-in-one entry point. The hooks + +// `NodeRenderer` are the contract for custom catalog components. +export { + A2UI, + NodeRenderer, + useAction, + useDataBinding, + useResolvedProps, +} from './react/index.js'; +export type { A2UIProps, ActionProps } from './react/index.js'; + +// Store — a pure raw-message buffer. The developer's IO module pushes +// protocol messages into it; `` subscribes and processes them. +// `MessageProcessor` is exposed for protocol-aware consumers who want to +// build their own renderer instead of using ``. +export { createMessageStore, MessageProcessor } from './store/index.js'; +export type { + A2UIClientEventMessage, + ComponentInstance, + GenericComponentProps, + MessageStore, + MessageStoreOptions, + Resource, + ResourceInfo, + ServerToClientMessage, + Surface, + SurfaceId, + UserActionPayload, +} from './store/index.js'; +// Helpers for IO that returns free-form text instead of structured +// protocol messages. +export { + createFallbackMessagesFromPlainText, + createTextCardMessages, + normalizePayloadToMessages, + prepareMessagesForProcessing, +} from './store/index.js'; + +// Catalog — declarative composition. +export { + defineCatalog, + mergeCatalogs, + resolveCatalog, + serializeCatalog, +} from './catalog/index.js'; +export type { + Catalog, + CatalogComponent, + CatalogInput, + CatalogManifest, + CatalogSchema, + ResolvedCatalogEntry, + SerializedCatalog, +} from './catalog/index.js'; + +// Built-in components — re-exported individually so apps can pick exactly +// what they need: +// +// import { Text, Button } from '@lynx-js/a2ui-reactlynx'; +// +// +// There is intentionally no all-in-one aggregate — see +// `packages/genui/a2ui/src/catalog/README.md`. +export { + Button, + Card, + CheckBox, + Column, + Divider, + Image, + List, + RadioGroup, + Row, + Text, +} from './catalog/index.js'; diff --git a/packages/genui/a2ui/src/react/A2UI.tsx b/packages/genui/a2ui/src/react/A2UI.tsx new file mode 100644 index 0000000000..12a6c94de2 --- /dev/null +++ b/packages/genui/a2ui/src/react/A2UI.tsx @@ -0,0 +1,264 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import * as v0_9 from '@a2ui/web_core/v0_9'; + +import { + memo, + useEffect, + useMemo, + useReducer, + useRef, + useSyncExternalStore, +} from '@lynx-js/react'; +import type { ReactNode } from '@lynx-js/react'; + +import { A2UIProvider } from './A2UIProvider.jsx'; +import { A2UIRenderer } from './A2UIRenderer.jsx'; +import type { Catalog, CatalogInput } from '../catalog/defineCatalog.js'; +import { defineCatalog } from '../catalog/defineCatalog.js'; +import { MessageProcessor } from '../store/MessageProcessor.js'; +import type { MessageStore } from '../store/MessageStore.js'; +import { createResource } from '../store/Resource.js'; +import type { + Resource, + ResourceInfo, + UserActionPayload, +} from '../store/types.js'; + +// Mark v0_9 used so the namespace import doesn't get pruned. Kept around so +// future enhancements (typed protocol message guards) don't need a fresh +// import diff. +void v0_9; + +export interface A2UIProps { + /** + * The raw-message buffer the developer pushes protocol messages into. + * `` subscribes via `useSyncExternalStore` and processes new + * messages incrementally. + * + * The internal `MessageProcessor` (surfaces, signals, resources) is + * created once per mount and is **not reset** if `messageStore` is + * later replaced with a different instance. Pass a `key` prop derived + * from the store's identity if you want a fresh session, e.g. + * ``. + */ + messageStore: MessageStore; + /** + * Components the renderer is allowed to instantiate. Each item is either + * a bare component (name read from `displayName ?? name`) or a tuple + * `[component, manifest]` where the manifest is the JSON the extractor + * emits at `dist/catalog//catalog.json`. + */ + catalogs: readonly CatalogInput[]; + /** + * Called when a user action fires inside the rendered tree (button + * tap, etc.). Forward to your agent and push the response messages + * back into the same `messageStore` to update the UI. Fire-and-forget; + * the renderer never awaits this. + */ + onAction?: (action: UserActionPayload) => void; + /** + * Wrap each top-level surface so consumers can apply theme/wrapper + * className/styles. The renderer ships no className of its own. + */ + wrapSurface?: ( + children: ReactNode, + ctx: { surfaceId: string }, + ) => ReactNode; + /** Render before the first `beginRendering` arrives from the buffer. */ + renderEmpty?: () => ReactNode; + /** Render while the active resource is pending. */ + renderFallback?: () => ReactNode; + /** Render when the active resource fails. */ + renderError?: (err: unknown) => ReactNode; +} + +interface InternalSession { + processor: MessageProcessor; + resources: Map; + activeMessageId: string | null; + /** How many messages from the buffer have been processed so far. */ + processedCount: number; +} + +/** + * The all-in-one A2UI component. Owns a `MessageProcessor` internally, + * processes raw messages from the buffer on each render, and renders + * the most recent `beginRendering` surface tree. + * + * @example + * const store = createMessageStore(); + * + * // Developer's IO module pushes raw messages into the store. + * async function streamFromAgent(input: string) { + * for await (const msg of myAgent.stream(input)) store.push(msg); + * } + * + * { void streamFromAgent(serializeAction(action)); }} + * wrapSurface={(c) => {c}} + * /> + */ +function A2UIImpl(props: A2UIProps): import('@lynx-js/react').ReactNode { + const { + messageStore, + catalogs, + onAction, + wrapSurface, + renderEmpty, + renderFallback, + renderError, + } = props; + + // Keep the latest onAction in a ref so the once-mounted processor.onEvent + // listener always calls the up-to-date prop without re-binding. + const onActionRef = useRef(onAction); + + useEffect(() => { + onActionRef.current = onAction; + }, [onAction]); + + // Per-instance session. Created once; mutated as messages arrive. + const sessionRef = useRef(null); + sessionRef.current ??= { + processor: new MessageProcessor(), + resources: new Map(), + activeMessageId: null, + processedCount: 0, + }; + const session = sessionRef.current; + + // Counter used to force a re-render whenever the processor emits an + // update (new beginRendering, surfaceUpdate that mutated the active + // surface, deleteSurface). The processor's per-resource `onUpdate` + // already handles fine-grained updates inside the active surface; + // this is just for the activeResource pointer + first paint. + const [, forceUpdate] = useReducer((n: number) => n + 1, 0); + + // Subscribe to the developer's raw buffer. + const messages = useSyncExternalStore( + messageStore.subscribe, + messageStore.getSnapshot, + messageStore.getSnapshot, + ); + + // One-time wiring of processor → resource bookkeeping + onAction + // dispatch. The processor instance itself is stable across renders. + + useEffect(() => { + const proc = session.processor; + + const offUpdate = proc.onUpdate((data) => { + const { type, surfaceId, messageId } = data as { + type: string; + surfaceId: string; + messageId: string; + targetId?: string; + }; + const surface = proc.getOrCreateSurface(surfaceId); + + if (type === 'beginRendering') { + let resource = session.resources.get(messageId); + if (!resource) { + resource = createResource(messageId); + session.resources.set(messageId, resource); + } + resource.complete({ type: 'beginRendering', surfaceId, surface }); + session.activeMessageId = messageId; + forceUpdate(); + } else if (type === 'surfaceUpdate') { + const updates = + (data as { updates?: ReadonlyArray<{ id?: string }> }).updates ?? []; + for (const update of updates) { + if (!update.id) continue; + const r = surface.resources.get(update.id); + r?.complete({ + type: 'surfaceUpdate', + surfaceId, + surface, + component: update as ResourceInfo['component'] & object, + } as ResourceInfo); + } + } else if (type === 'deleteSurface') { + const targetId = (data as { targetId?: string }).targetId + ?? surface.rootComponentId; + if (targetId && surface.resources.has(targetId)) { + surface.resources.get(targetId)!.complete({ + type: 'deleteSurface', + surfaceId, + surface, + }); + } + forceUpdate(); + } + }); + + const offEvent = proc.onEvent(({ message, resolve }) => { + // Empty resolve — there is no "response" channel from the renderer + // back into the protocol. Responses arrive via the buffer. + resolve([]); + if ( + typeof message === 'object' && message !== null + && 'userAction' in message + && (message as { userAction: unknown }).userAction + ) { + const action = + (message as { userAction: UserActionPayload }).userAction; + try { + onActionRef.current?.(action); + } catch (e) { + console.error('[a2ui] onAction handler threw:', e); + } + } + }); + + return () => { + offUpdate(); + offEvent(); + }; + }, [session]); + + // Process any new messages in the buffer. + + useEffect(() => { + if (messages.length === session.processedCount) return; + const slice = messages.slice( + session.processedCount, + ); + session.processedCount = messages.length; + if (slice.length > 0) { + session.processor.processMessages(slice); + } + }, [messages, session]); + + const catalog = useMemo( + () => defineCatalog(catalogs), + [catalogs], + ); + + const activeResource = session.activeMessageId + ? (session.resources.get(session.activeMessageId) ?? null) + : null; + + if (!activeResource) { + return renderEmpty?.() ?? null; + } + + const rendererProps: import('./A2UIRenderer.jsx').A2UIRendererProps = { + resource: activeResource, + }; + if (wrapSurface) rendererProps.wrapSurface = wrapSurface; + if (renderFallback) rendererProps.renderFallback = renderFallback; + if (renderError) rendererProps.renderError = renderError; + + return ( + + + + ); +} + +export const A2UI = memo(A2UIImpl); diff --git a/packages/genui/a2ui/src/react/A2UIProvider.tsx b/packages/genui/a2ui/src/react/A2UIProvider.tsx new file mode 100644 index 0000000000..c96b8e9444 --- /dev/null +++ b/packages/genui/a2ui/src/react/A2UIProvider.tsx @@ -0,0 +1,47 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { createContext, useMemo } from '@lynx-js/react'; +import type { ReactNode } from '@lynx-js/react'; + +import type { Catalog, CatalogComponent } from '../catalog/defineCatalog.js'; +import { resolveCatalog } from '../catalog/defineCatalog.js'; +import type { MessageProcessor } from '../store/MessageProcessor.js'; + +/** + * The context value `` provides to its catalog-component subtree. + * Internal — neither the context nor the provider is part of the public + * API. Catalog components reach this value via `useAction`, + * `useDataBinding`, etc. + */ +export interface A2UIInternalContext { + processor: MessageProcessor; + catalog: Catalog; + catalogMap: ReadonlyMap; +} + +export const A2UIContext = createContext(null); + +interface ProviderProps { + processor: MessageProcessor; + catalog: Catalog; + children: ReactNode; +} + +/** + * Internal provider mounted by ``. Not exported from the package. + */ +export function A2UIProvider( + props: ProviderProps, +): import('@lynx-js/react').ReactNode { + const { processor, catalog, children } = props; + const value = useMemo( + () => ({ + processor, + catalog, + catalogMap: resolveCatalog(catalog), + }), + [processor, catalog], + ); + return {children}; +} diff --git a/packages/genui/a2ui/src/react/A2UIRenderer.tsx b/packages/genui/a2ui/src/react/A2UIRenderer.tsx index 4dd71310d1..b99fe10a28 100644 --- a/packages/genui/a2ui/src/react/A2UIRenderer.tsx +++ b/packages/genui/a2ui/src/react/A2UIRenderer.tsx @@ -1,12 +1,221 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -// Compatibility re-export. The headless renderer refactor exposes the -// renderer under `src/react/`; this barrel keeps the new import path -// stable while the implementation still lives under `core/`. The -// renamed export (`A2UIRenderer`) matches the post-refactor symbol so -// catalog components can adopt the new path now. -export { - A2UIRender as A2UIRenderer, - NodeRenderer, -} from '../core/A2UIRender.jsx'; +import { memo, useEffect, useMemo, useSyncExternalStore } from '@lynx-js/react'; +import type { ReactNode } from '@lynx-js/react'; + +import { useAction } from './useAction.js'; +import { useCatalog } from './useCatalog.js'; +import { useResolvedProps } from './useDataBinding.js'; +import type { ComponentInstance, Resource, Surface } from '../store/types.js'; + +const noop = () => { + /* no-op subscribe disposer */ +}; +const noopSubscribe = (): () => void => noop; +const emptySnapshot = { + status: 'pending' as const, + value: undefined, + error: undefined, +}; +const returnEmptySnapshot = () => emptySnapshot; +const warnedTags = new Set(); + +function DefaultLoading(props: { id: string }) { + const content = `loading ${props.id}...`; + return ( + + {content} + + ); +} + +function buildNodeRecursive( + component: ComponentInstance, + surface: Surface, + catalog: ReadonlyMap) => ReactNode>, + resolvedProps?: Record, + setValue?: (key: string, value: unknown) => void, + sendAction?: (action: Record) => void, +): ReactNode { + const tag = component.component; + const Component = catalog.get(tag); + if (!Component) return null; + + return ( + ) => { + void sendAction?.(a); + }} + dataContextPath={component.dataContextPath} + /> + ); +} + +export interface A2UIRendererProps { + resource: Resource; + renderFallback?: () => ReactNode; + renderError?: (e: unknown) => ReactNode; + /** + * Wrap each top-level surface so consumers can apply theme/wrapper + * className/styles. The default does not wrap — that is intentional, the + * renderer is headless. Lynx-themed shells should pass + * `wrapSurface={(c, ctx) => {c}}`. + */ + wrapSurface?: ( + children: ReactNode, + ctx: { surfaceId: string }, + ) => ReactNode; +} + +function A2UIRendererImpl( + props: A2UIRendererProps, +): import('@lynx-js/react').ReactNode { + const { resource, wrapSurface, renderFallback, renderError } = props; + // Eagerly read context so the renderer fails clearly outside . + useCatalog(); + + const snapshot = useSyncExternalStore( + resource.subscribe, + resource.getSnapshot, + resource.getSnapshot, + ); + const data = snapshot.value; + const status = snapshot.status; + const error = snapshot.error; + + if (status === 'pending' && data === undefined) { + // Use a ternary instead of `??` so consumers can return `null` from + // the override callback to suppress the built-in placeholder. + return renderFallback + ? renderFallback() + : ; + } + + if (status === 'error') { + return renderError + ? renderError(error) + : Error: {String(error)}; + } + + if (!data) return null; + + const dataObj = data as unknown as Record; + const type = dataObj['type'] as string; + const surfaceId = dataObj['surfaceId'] as string; + const surface = dataObj['surface'] as Surface; + const component = dataObj['component'] as ComponentInstance | undefined; + + if (type === 'beginRendering') { + const id = surface.rootComponentId!; + const childResource = surface.resources.get(id); + if (!childResource) return null; + const childProps: A2UIRendererProps = { resource: childResource }; + if (wrapSurface) childProps.wrapSurface = wrapSurface; + if (renderFallback) childProps.renderFallback = renderFallback; + if (renderError) childProps.renderError = renderError; + const inner = ( + + + + ); + return wrapSurface ? wrapSurface(inner, { surfaceId }) : inner; + } + + if (type === 'surfaceUpdate' && component) { + return ; + } + + if (type === 'deleteSurface') { + return null; + } + + return null; +} + +export const A2UIRenderer = memo(A2UIRendererImpl); + +function NodeRendererImpl( + props: { + component: ComponentInstance; + surface: Surface; + }, +): import('@lynx-js/react').ReactNode { + const { component: initialComponent, surface } = props; + const catalog = useCatalog(); + + const resource = surface.resources.get(initialComponent.id!); + + const latest = useSyncExternalStore( + resource ? resource.subscribe : noopSubscribe, + resource ? resource.getSnapshot : returnEmptySnapshot, + resource ? resource.getSnapshot : returnEmptySnapshot, + ); + + const component = + (latest.value as { component?: ComponentInstance } | undefined)?.component + ?? initialComponent; + + useEffect(() => { + const tag = component.component; + if (!catalog.has(tag) && !warnedTags.has(tag)) { + warnedTags.add(tag); + console.warn(`[a2ui] Component "${tag}" is not in the active catalog.`); + } + }, [component.component, catalog]); + + const [resolvedProps, setValue] = useResolvedProps( + component, + surface, + component.dataContextPath, + ); + + const actionProps = useMemo( + () => ({ + id: component.id!, + surfaceId: surface.surfaceId, + dataContext: component.dataContextPath, + }), + [component.id, surface.surfaceId, component.dataContextPath], + ); + const { sendAction } = useAction(actionProps); + + return ( + <> + {buildNodeRecursive( + component, + surface, + catalog as ReadonlyMap< + string, + (props: Record) => ReactNode + >, + resolvedProps, + setValue, + (a: Record) => { + void sendAction(a as unknown as Parameters[0]); + }, + )} + + ); +} + +export const NodeRenderer = NodeRendererImpl; diff --git a/packages/genui/a2ui/src/react/index.ts b/packages/genui/a2ui/src/react/index.ts new file mode 100644 index 0000000000..285641b4de --- /dev/null +++ b/packages/genui/a2ui/src/react/index.ts @@ -0,0 +1,17 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +// +// Public React surface. `` is the all-in-one component for +// developers without protocol knowledge. The hooks + `NodeRenderer` are +// the contract that custom catalog components plug into. +// +// `A2UIProvider`, `A2UIRenderer`, `A2UIContext`, `useA2UIContext`, and +// `useCatalog` are intentionally NOT exported — they're internal details +// of how `` mounts itself. Custom components don't need them. +export { A2UI } from './A2UI.jsx'; +export type { A2UIProps } from './A2UI.jsx'; +export { NodeRenderer } from './A2UIRenderer.jsx'; +export { useAction } from './useAction.js'; +export type { ActionProps } from './useAction.js'; +export { useDataBinding, useResolvedProps } from './useDataBinding.js'; diff --git a/packages/genui/a2ui/src/react/useA2UIContext.ts b/packages/genui/a2ui/src/react/useA2UIContext.ts new file mode 100644 index 0000000000..2f238992ff --- /dev/null +++ b/packages/genui/a2ui/src/react/useA2UIContext.ts @@ -0,0 +1,23 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { useContext } from '@lynx-js/react'; + +import { A2UIContext } from './A2UIProvider.jsx'; +import type { A2UIInternalContext } from './A2UIProvider.jsx'; + +/** + * Internal helper used by catalog-component hooks (`useAction`, the + * renderer, …) to reach the ``-owned context. NOT exported from + * the package. + */ +export function useA2UIContext(): A2UIInternalContext { + const ctx = useContext(A2UIContext); + if (!ctx) { + throw new Error( + '[a2ui] Catalog-component hooks must be used inside a tree rendered ' + + 'by ``.', + ); + } + return ctx; +} diff --git a/packages/genui/a2ui/src/react/useAction.ts b/packages/genui/a2ui/src/react/useAction.ts new file mode 100644 index 0000000000..6860da4a17 --- /dev/null +++ b/packages/genui/a2ui/src/react/useAction.ts @@ -0,0 +1,175 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type * as v0_9 from '@a2ui/web_core/v0_9'; + +import { useCallback } from '@lynx-js/react'; + +import { useA2UIContext } from './useA2UIContext.js'; +import type { UserActionPayload } from '../store/types.js'; + +export interface ActionProps { + id: string; + surfaceId: string; + dataContext?: string | undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isDataBinding(value: unknown): value is v0_9.DataBinding { + return !!value && typeof value === 'object' + && 'path' in (value as Record); +} + +function isFunctionCall(value: unknown): value is v0_9.FunctionCall { + return !!value && typeof value === 'object' + && 'call' in (value as Record); +} + +function makeResolvers(processor: { + getOrCreateSurface( + id: string, + ): { store: { getSignal(p: string): { value: unknown } } }; + resolvePath(path: string, ctx?: string): string; +}) { + const resolveFromStore = ( + path: string, + surfaceId: string, + dataContextPath?: string, + ): unknown => { + const surface = processor.getOrCreateSurface(surfaceId); + const store = surface.store; + const resolvedPath = processor.resolvePath(path, dataContextPath); + const signal = store.getSignal(resolvedPath); + const raw = signal.value; + if (!raw) return raw; + try { + return JSON.parse(raw as string); + } catch { + return raw; + } + }; + + const resolveDynamicValue = ( + value: v0_9.DynamicValue, + surfaceId: string, + dataContextPath?: string, + ): unknown => { + if ( + typeof value === 'string' || typeof value === 'number' + || typeof value === 'boolean' + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((v) => + resolveDynamicValue( + v as v0_9.DynamicValue, + surfaceId, + dataContextPath, + ) + ); + } + + if (isDataBinding(value)) { + return resolveFromStore(value.path, surfaceId, dataContextPath); + } + + if (isFunctionCall(value)) { + return resolveFunctionCall(value, surfaceId, dataContextPath); + } + + return value; + }; + + const resolveFunctionArguments = ( + args: Record | undefined, + surfaceId: string, + dataContextPath?: string, + ): Record | undefined => { + if (!args) return undefined; + const resolved: Record = {}; + for (const [key, val] of Object.entries(args)) { + if (isObject(val) && !isDataBinding(val) && !isFunctionCall(val)) { + resolved[key] = { ...val }; + } else { + resolved[key] = resolveDynamicValue( + val as v0_9.DynamicValue, + surfaceId, + dataContextPath, + ); + } + } + return resolved; + }; + + const resolveFunctionCall = ( + fn: v0_9.FunctionCall, + surfaceId: string, + dataContextPath?: string, + ): Record => ({ + call: fn.call, + args: resolveFunctionArguments(fn.args, surfaceId, dataContextPath), + returnType: fn.returnType, + }); + + return { resolveDynamicValue, resolveFunctionCall }; +} + +export function useAction( + props: ActionProps, +): { sendAction: (action: v0_9.Action) => Promise } { + const { id, surfaceId, dataContext } = props; + const { processor } = useA2UIContext(); + + const sendAction = useCallback( + (action: v0_9.Action) => { + let name = 'unknownAction'; + let context: Record = {}; + const { resolveDynamicValue, resolveFunctionCall } = makeResolvers( + processor, + ); + + if ('event' in action && action.event) { + name = action.event.name; + const ctx = action.event.context as Record | undefined; + if (ctx) { + const resolvedContext: Record = {}; + for (const [key, value] of Object.entries(ctx)) { + resolvedContext[key] = resolveDynamicValue( + value as v0_9.DynamicValue, + surfaceId, + dataContext, + ); + } + context = resolvedContext; + } + } else if ('functionCall' in action && action.functionCall) { + const fn = action.functionCall; + name = fn.call; + context = { + functionCall: resolveFunctionCall(fn, surfaceId, dataContext), + }; + } + + const userAction: UserActionPayload = { + name, + surfaceId, + sourceComponentId: id, + timestamp: new Date().toISOString(), + context, + }; + + // Dispatch through the processor — `` listens via + // `processor.onEvent` and forwards the action to its `onAction` + // prop, which the developer wires to their agent. + return processor.dispatch({ userAction }); + }, + [id, surfaceId, dataContext, processor], + ); + + return { sendAction }; +} diff --git a/packages/genui/a2ui/src/react/useCatalog.ts b/packages/genui/a2ui/src/react/useCatalog.ts new file mode 100644 index 0000000000..bac97ce9b6 --- /dev/null +++ b/packages/genui/a2ui/src/react/useCatalog.ts @@ -0,0 +1,14 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { useA2UIContext } from './useA2UIContext.js'; +import type { CatalogComponent } from '../catalog/defineCatalog.js'; + +/** + * Internal hook — returns the resolved name → component map. Used by the + * renderer and exposed for advanced custom components that want to peek + * at the active catalog. + */ +export function useCatalog(): ReadonlyMap { + return useA2UIContext().catalogMap; +} diff --git a/packages/genui/a2ui/src/react/useDataBinding.ts b/packages/genui/a2ui/src/react/useDataBinding.ts index 5c7b1c3900..e9e611db02 100644 --- a/packages/genui/a2ui/src/react/useDataBinding.ts +++ b/packages/genui/a2ui/src/react/useDataBinding.ts @@ -1,5 +1,217 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -// Compatibility re-export. See `react/A2UIRenderer.tsx` for context. -export * from '../core/useDataBinding.js'; +import { effect } from '@preact/signals'; +import type { Signal } from '@preact/signals'; + +import { + useCallback, + useMemo, + useRef, + useSyncExternalStore, +} from '@lynx-js/react'; + +import type { Surface } from '../store/types.js'; + +const noop = () => { + /* no-op subscribe disposer */ +}; +const noopSubscribe = (): () => void => noop; + +function subscribeToSignal( + signal: Signal | undefined, +): (cb: () => void) => () => void { + if (!signal) return noopSubscribe; + return (cb: () => void) => + effect(() => { + void signal.value; + cb(); + }); +} + +export function useDataBinding( + dynamicValue: unknown, + surface: Surface | undefined, + dataContextPath?: string, + fallbackValue?: T, +): [T | undefined, (newValue: T) => void, string | undefined] { + let path: string | undefined; + let initialValue: T | undefined; + + if ( + typeof dynamicValue === 'string' + || typeof dynamicValue === 'number' + || typeof dynamicValue === 'boolean' + ) { + // Preserve primitive type. A static `false` must stay falsy and + // numeric props must stay numeric — stringifying breaks consumers that + // expect `boolean | number` (e.g., `if (props.disabled)`). + initialValue = dynamicValue as T; + } else if ( + dynamicValue + && typeof dynamicValue === 'object' + && 'path' in dynamicValue + ) { + path = (dynamicValue as Record)['path'] as + | string + | undefined; + } + + if (path && !path.startsWith('/')) { + if (dataContextPath) { + path = `${dataContextPath}/${path}`; + } else { + path = `/${path}`; + } + } + + const signal = surface?.store && path + ? surface.store.getSignal( + path, + typeof initialValue === 'string' ? initialValue : undefined, + ) + : undefined; + + const subscribe = useMemo(() => subscribeToSignal(signal), [signal]); + const getSnapshot = useCallback( + () => signal?.value as T | undefined, + [signal], + ); + + const signalValue = useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot, + ); + + const currentValue = path + ? (signalValue ?? initialValue ?? fallbackValue) + : (initialValue ?? fallbackValue); + + const setValue = useCallback( + (newValue: T) => { + if (path && surface?.store) { + surface.store.update(path, newValue); + } + }, + [path, surface], + ); + + return [currentValue, setValue, path]; +} + +function isDataBinding(prop: unknown): boolean { + return Boolean( + prop + && typeof prop === 'object' + && 'path' in prop + && !('componentId' in prop), + ); +} + +function resolveProperties( + properties: Record, + surface: Surface | undefined, + dataContextPath?: string, +) { + if (!properties) return properties; + const result: Record = {}; + for (const key in properties) { + const prop = properties[key]; + if (isDataBinding(prop)) { + let path = (prop as Record)['path'] as + | string + | undefined; + if (path && typeof path === 'string' && !path.startsWith('/')) { + path = dataContextPath ? `${dataContextPath}/${path}` : `/${path}`; + } + + if (path && surface?.store) { + const signal = surface.store.getSignal(path); + result[key] = signal.value; + } else { + result[key] = undefined; + } + } else if ( + typeof prop === 'string' + || typeof prop === 'number' + || typeof prop === 'boolean' + ) { + result[key] = prop; + } else { + result[key] = prop; + } + } + return result; +} + +function shallowEqual( + a: Record, + b: Record, +): boolean { + if (a === b) return true; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const k of keysA) { + if (a[k] !== b[k]) return false; + } + return true; +} + +export function useResolvedProps( + properties: Record, + surface: Surface | undefined, + dataContextPath?: string, +): readonly [Record, (key: string, value: unknown) => void] { + const cacheRef = useRef | null>(null); + + const computeSnapshot = useCallback(() => { + const next = resolveProperties(properties, surface, dataContextPath); + if (cacheRef.current && shallowEqual(cacheRef.current, next)) { + return cacheRef.current; + } + cacheRef.current = next; + return next; + }, [properties, surface, dataContextPath]); + + const subscribe = useCallback( + (cb: () => void) => { + if (!surface?.store) return noop; + return effect(() => { + resolveProperties(properties, surface, dataContextPath); + cb(); + }); + }, + [properties, surface, dataContextPath], + ); + + const resolved = useSyncExternalStore( + subscribe, + computeSnapshot, + computeSnapshot, + ); + + const setValue = useCallback( + (key: string, value: unknown) => { + const prop = properties?.[key]; + if (isDataBinding(prop)) { + let path = (prop as Record)['path'] as + | string + | undefined; + if (path && surface?.store) { + if (!path.startsWith('/')) { + path = dataContextPath ? `${dataContextPath}/${path}` : `/${path}`; + } + surface.store.update(path, value); + } + } + }, + [properties, surface, dataContextPath], + ); + + return [resolved, setValue] as readonly [ + Record, + (key: string, value: unknown) => void, + ]; +} diff --git a/packages/genui/a2ui/src/core/processor.ts b/packages/genui/a2ui/src/store/MessageProcessor.ts similarity index 77% rename from packages/genui/a2ui/src/core/processor.ts rename to packages/genui/a2ui/src/store/MessageProcessor.ts index 883d9860fe..265ae94082 100644 --- a/packages/genui/a2ui/src/core/processor.ts +++ b/packages/genui/a2ui/src/store/MessageProcessor.ts @@ -3,13 +3,13 @@ // LICENSE file in the root directory of this source tree. import type * as v0_9 from '@a2ui/web_core/v0_9'; +import { createResource } from './Resource.js'; +import { SignalStore } from './SignalStore.js'; import type { ComponentInstance, ServerToClientMessage, Surface, } from './types.js'; -import { createResource } from '../utils/createResource.js'; -import { SignalStore } from '../utils/SignalStore.js'; export interface A2UIEvent { message: Record; @@ -20,35 +20,89 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +/** + * `null` / `undefined` / empty array / empty object → `false`. + * Used by `dispatch()` to decide whether a listener's response should + * win over later responses from other listeners. + */ +function isMeaningfulResponse(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') { + return Object.keys(value as Record).length > 0; + } + return true; +} + export class MessageProcessor { surfaces: Map; - private listener: ((event: A2UIEvent) => void) | null = null; - private updateListener: ((data: Record) => void) | null = - null; + private eventListeners: Set<(event: A2UIEvent) => void> = new Set(); + private updateListeners: Set<(data: Record) => void> = + new Set(); constructor() { this.surfaces = new Map(); } - onUpdate(callback: (data: Record) => void): void { - this.updateListener = callback; + onUpdate(callback: (data: Record) => void): () => void { + this.updateListeners.add(callback); + return () => { + this.updateListeners.delete(callback); + }; + } + + private emitUpdate(data: Record): void { + for (const cb of this.updateListeners) cb(data); } dispatch(message: Record): Promise { return new Promise((resolve) => { - if (this.listener) { - this.listener({ message, resolve }); - } else { - console.warn('No host listener attached!'); + if (this.eventListeners.size === 0) { resolve([]); + return; + } + // Each listener gets its own one-shot resolver so multiple + // subscribers don't race on the shared outer `resolve` (which + // would let whoever calls it first decide the dispatch result and + // silently drop responses from the rest). We resolve with the + // first non-empty response, falling back to an empty array if + // every listener yielded nothing. + const total = this.eventListeners.size; + let settled = 0; + let firstResponse: unknown; + let hasResponse = false; + const tryResolve = () => { + settled += 1; + if (settled >= total) { + resolve(hasResponse ? firstResponse : []); + } + }; + for (const cb of this.eventListeners) { + let called = false; + cb({ + message, + resolve: (value) => { + if (called) return; + called = true; + // Only treat the first **meaningful** response as the result. + // Listeners that resolve `[]` / `null` / `{}` (the no-op + // pattern from ``'s internal listener) shouldn't + // shadow a real response from another subscriber. + if (!hasResponse && isMeaningfulResponse(value)) { + hasResponse = true; + firstResponse = value; + } + tryResolve(); + }, + }); } }); } onEvent(callback: (event: A2UIEvent) => void): () => void { - this.listener = callback; + this.eventListeners.add(callback); return () => { - this.listener = null; + this.eventListeners.delete(callback); }; } @@ -127,7 +181,6 @@ export class MessageProcessor { surface.resources.set(newId, createResource(newId)); } - // Recursively clone the subtree below this component. const childIds: string[] = []; const anyCloned = cloned as unknown as Record; @@ -228,7 +281,6 @@ export class MessageProcessor { return; } - // Primitive at the base path. updates.push({ path: normalizedBase, value: String(value) }); } @@ -292,8 +344,6 @@ export class MessageProcessor { }; } - // this.resolveComponentPaths(instance, dataContextPath); - surface.components.set(instance.id!, instance); updatesMap.set(instance.id!, instance); @@ -302,12 +352,10 @@ export class MessageProcessor { } } - // Determine the root component if not already set. if (!surface.rootComponentId) { if (surface.components.has('root')) { surface.rootComponentId = 'root'; } else if (updatesMap.size > 0) { - // Fallback: use the first updated component as root if not specified surface.rootComponentId = updatesMap.keys().next().value ?? null; } @@ -318,20 +366,23 @@ export class MessageProcessor { createResource(surface.rootComponentId), ); } - // Signal that rendering can begin for this surface. - if (this.updateListener) { - this.updateListener({ - type: 'beginRendering', - surfaceId, - messageId: (message as { messageId?: string }).messageId, - }); - } + // Fall back to a surface-derived id so consumers that key + // resources by `messageId` still get a non-empty key when the + // protocol message lacks one (the v0.9 stream does not require + // `messageId` on every message). + const messageId = (message as { messageId?: string }).messageId + ?? `surface:${surfaceId}`; + this.emitUpdate({ + type: 'beginRendering', + surfaceId, + messageId, + }); } } const updates = Array.from(updatesMap.values()); - if (updates.length > 0 && this.updateListener) { - this.updateListener({ + if (updates.length > 0) { + this.emitUpdate({ type: 'surfaceUpdate', updates, surfaceId, @@ -353,7 +404,6 @@ export class MessageProcessor { const basePath = path && path !== '' ? path : '/'; this.flattenValue(value, basePath, updates); } else if (path) { - // Deletion semantics: we simply clear the value at the path. updates.push({ path, value: '' }); } @@ -361,7 +411,6 @@ export class MessageProcessor { surface.store.updateBatch(updates); } - // Re-expand any templated containers based on the updated data model. const componentUpdates: ComponentInstance[] = []; for (const component of surface.components.values()) { @@ -424,8 +473,8 @@ export class MessageProcessor { } } - if (componentUpdates.length > 0 && this.updateListener) { - this.updateListener({ + if (componentUpdates.length > 0) { + this.emitUpdate({ type: 'surfaceUpdate', updates: componentUpdates, surfaceId, @@ -437,20 +486,20 @@ export class MessageProcessor { const { surfaceId } = message.deleteSurface; const surface = this.surfaces.get(surfaceId); - if (this.updateListener) { - this.updateListener({ - type: 'deleteSurface', - surfaceId, - targetId: surface?.rootComponentId ?? surfaceId, - messageId: (message as { messageId?: string }).messageId, - }); - } + // Same fallback as the synthesized `beginRendering` event so + // consumers that key lifecycle state by `messageId` always see + // a non-empty key when the protocol message lacks one. + const messageId = (message as { messageId?: string }).messageId + ?? `surface:${surfaceId}`; + this.emitUpdate({ + type: 'deleteSurface', + surfaceId, + targetId: surface?.rootComponentId ?? surfaceId, + messageId, + }); - // Optionally clear local state for this surface. this.surfaces.delete(surfaceId); } } } } - -export const processor: MessageProcessor = new MessageProcessor(); diff --git a/packages/genui/a2ui/src/store/Resource.ts b/packages/genui/a2ui/src/store/Resource.ts new file mode 100644 index 0000000000..0ab59693cb --- /dev/null +++ b/packages/genui/a2ui/src/store/Resource.ts @@ -0,0 +1,127 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export type ResourceStatus = 'pending' | 'success' | 'error'; + +/** + * Immutable, transition-keyed snapshot returned by + * {@link Resource.getSnapshot}. The object reference changes on every + * `complete()` / `fail()` call so `useSyncExternalStore` re-renders even + * for `pending → error` transitions where `value` stays `undefined`. + */ +export interface ResourceSnapshot { + status: ResourceStatus; + value: T | undefined; + error: unknown; +} + +export interface Resource { + id: string; + readonly completed: boolean; + readonly status: ResourceStatus; + readonly value: T | undefined; + readonly error: unknown; + /** + * @deprecated Read `value` / `status` directly. Retained for backwards + * compatibility; in the success state it returns the value, otherwise it + * returns undefined. It does not throw. + */ + read: () => T | undefined; + complete: (result: T) => void; + fail: (err: unknown) => void; + /** + * @deprecated Use `subscribe`. Behaves identically. + */ + onUpdate: (callback: (result: T) => void) => () => void; + /** + * Subscribe to state changes. The callback is invoked after every + * `complete()` or `fail()` (the same value may be re-published when the + * processor re-emits an update for the same id). Returns a disposer. + */ + subscribe: (callback: () => void) => () => void; + /** + * Synchronous read of the current snapshot, suitable for + * `useSyncExternalStore`. The returned object reference changes on every + * transition. + */ + getSnapshot: () => ResourceSnapshot; + promise: Promise; +} + +export function createResource(id: string): Resource { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Swallow unhandled rejection — listeners may attach later. + promise.catch(() => { + /* deferred: callers attach via subscribe / onUpdate */ + }); + + let status: ResourceStatus = 'pending'; + let value: T | undefined; + let error: unknown; + // Snapshot object whose reference changes on every transition so that + // `useSyncExternalStore`'s `Object.is` bail-out doesn't miss + // `pending → error` (where `value` stays `undefined`). + let snapshot: ResourceSnapshot = { status, value, error }; + const listeners = new Set<() => void>(); + + const notify = () => { + snapshot = { status, value, error }; + for (const fn of listeners) fn(); + }; + + return { + id, + promise, + get completed() { + return status === 'success'; + }, + get status() { + return status; + }, + get value() { + return value; + }, + get error() { + return error; + }, + read: () => value, + complete: (res: T) => { + // `error` is terminal — refuse error → success transitions so + // observers don't see `{ status: 'success', error: }`. + if (status === 'error') return; + const wasPending = status === 'pending'; + value = res; + status = 'success'; + if (wasPending) resolve(res); + notify(); + }, + fail: (err: unknown) => { + if (status !== 'pending') return; + error = err; + status = 'error'; + reject(err); + notify(); + }, + onUpdate: (fn: (data: T) => void) => { + const wrapped = () => { + if (value !== undefined) fn(value); + }; + listeners.add(wrapped); + return () => { + listeners.delete(wrapped); + }; + }, + subscribe: (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + getSnapshot: () => snapshot, + }; +} diff --git a/packages/genui/a2ui/src/utils/SignalStore.ts b/packages/genui/a2ui/src/store/SignalStore.ts similarity index 100% rename from packages/genui/a2ui/src/utils/SignalStore.ts rename to packages/genui/a2ui/src/store/SignalStore.ts diff --git a/packages/genui/a2ui/src/store/index.ts b/packages/genui/a2ui/src/store/index.ts index 7b57fe01d1..b8c7d6882b 100644 --- a/packages/genui/a2ui/src/store/index.ts +++ b/packages/genui/a2ui/src/store/index.ts @@ -3,10 +3,25 @@ // LICENSE file in the root directory of this source tree. export { createMessageStore } from './MessageStore.js'; export type { MessageStore, MessageStoreOptions } from './MessageStore.js'; +export { MessageProcessor } from './MessageProcessor.js'; +export type { A2UIEvent } from './MessageProcessor.js'; +export { createResource } from './Resource.js'; +export type { Resource as RawResource, ResourceStatus } from './Resource.js'; +export { SignalStore } from './SignalStore.js'; export { createFallbackMessagesFromPlainText, createTextCardMessages, normalizePayloadToMessages, prepareMessagesForProcessing, } from './payloadNormalizer.js'; -export type { ServerToClientMessage } from './types.js'; +export type { + A2UIClientEventMessage, + ComponentInstance, + GenericComponentProps, + Resource, + ResourceInfo, + ServerToClientMessage, + Surface, + SurfaceId, + UserActionPayload, +} from './types.js'; diff --git a/packages/genui/a2ui/src/store/types.ts b/packages/genui/a2ui/src/store/types.ts index c4b7efe110..9adce8aa90 100644 --- a/packages/genui/a2ui/src/store/types.ts +++ b/packages/genui/a2ui/src/store/types.ts @@ -1,7 +1,84 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -// Compatibility re-export. The headless renderer refactor moves these -// types to `src/store/`; this barrel keeps the new import path stable -// while the implementation still lives under `core/`. -export * from '../core/types.js'; +import type * as v0_9 from '@a2ui/web_core/v0_9'; + +import type { Resource as GenericResource } from './Resource.js'; +import type { SignalStore } from './SignalStore.js'; + +export type SurfaceId = string; + +export type ComponentInstance = v0_9.AnyComponent & { + /** + * Absolute data context path for this component when created via a template. + * Used for resolving relative bindings inside the component tree. + */ + dataContextPath?: string; + /** + * Internal metadata for templated containers so we can re-expand on + * data model updates. + */ + __template?: { + componentId: v0_9.ComponentId; + path: string; + }; +}; + +export interface Surface { + surfaceId: SurfaceId; + catalogId?: string; + theme?: Readonly>; + sendDataModel?: boolean | undefined; + /** id of the root component for this surface (must be 'root'). */ + rootComponentId?: string | null; + components: Map; + resources: Map; + store: SignalStore; +} + +export interface ResourceInfo { + /** + * Internal event type emitted by the processor. + */ + type: 'beginRendering' | 'surfaceUpdate' | 'deleteSurface'; + surfaceId: string; + surface: Surface; + component?: ComponentInstance; +} + +export type Resource = GenericResource; + +export type ServerToClientMessage = v0_9.A2uiMessage & { + /** + * Message id injected by the client. + */ + messageId?: string; +}; + +export interface UserActionPayload { + name: string; + surfaceId: string; + sourceComponentId: string; + timestamp: string; // ISO 8601 + context: Record; +} + +export type A2UIClientEventMessage = + | string + | { + text?: string; + sessionId?: string; + } + | { + userAction: UserActionPayload; + sessionId?: string; + }; + +export interface GenericComponentProps { + id?: string; + surface: Surface; + setValue?: (key: string, value: unknown) => void; + sendAction?: (action: Record) => void; + dataContextPath?: string; + [key: string]: unknown; +} diff --git a/packages/genui/a2ui/src/utils/ComponentRegistry.ts b/packages/genui/a2ui/src/utils/ComponentRegistry.ts deleted file mode 100644 index b16640d543..0000000000 --- a/packages/genui/a2ui/src/utils/ComponentRegistry.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -export class ComponentRegistry { - private components = new Map(); - - register(tag: string, component: T) { - this.components.set(tag, component); - } - - get(tag: string): T | undefined { - return this.components.get(tag); - } - - has(tag: string): boolean { - return this.components.has(tag); - } -} diff --git a/packages/genui/a2ui/src/utils/createResource.ts b/packages/genui/a2ui/src/utils/createResource.ts deleted file mode 100644 index c0f8d0eec3..0000000000 --- a/packages/genui/a2ui/src/utils/createResource.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -export interface Resource { - id: string; - readonly completed: boolean; - read: () => T; - complete: (result: T) => void; - onUpdate: (callback: (result: T) => void) => () => void; - promise: Promise; -} - -export function createResource(id: string): Resource { - let resolve: (value: T) => void; - const mockFn = new Promise((_resolve) => { - resolve = _resolve; - }); - let status: 'pending' | 'success' | 'error' = 'pending'; - let result: T; - let error: unknown; - let listeners: ((data: T) => void)[] = []; - - const promise = mockFn - .then((res) => { - status = 'success'; - result = res; - return res; - }) - .catch((err) => { - status = 'error'; - error = err; - throw err; - }); - - return { - id, - promise, - get completed() { - return status === 'success'; - }, - read: () => { - switch (status) { - case 'pending': - throw promise; - case 'error': - throw error; - case 'success': - return result; - default: - throw new Error('Unknown status'); - } - }, - complete: (res: T) => { - if (status === 'pending') { - resolve(res); - } else { - result = res; - listeners.forEach((fn) => fn(res)); - } - }, - onUpdate: (fn: (data: T) => void) => { - listeners.push(fn); - return () => { - listeners = listeners.filter((l) => l !== fn); - }; - }, - }; -} diff --git a/packages/genui/a2ui/src/utils/index.ts b/packages/genui/a2ui/src/utils/index.ts deleted file mode 100644 index 18876cfe32..0000000000 --- a/packages/genui/a2ui/src/utils/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -export * from './ComponentRegistry.js'; -export * from './createResource.js'; -export * from './SignalStore.js'; diff --git a/packages/genui/a2ui/test/catalog.test.ts b/packages/genui/a2ui/test/catalog.test.ts new file mode 100644 index 0000000000..d57bfdc173 --- /dev/null +++ b/packages/genui/a2ui/test/catalog.test.ts @@ -0,0 +1,137 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from '@rstest/core'; + +import { + defineCatalog, + mergeCatalogs, + resolveCatalog, + serializeCatalog, +} from '../src/catalog/defineCatalog.js'; +import type { + CatalogComponent, + CatalogManifest, +} from '../src/catalog/defineCatalog.js'; + +function namedStub(name: string): CatalogComponent { + // Use Object.defineProperty to give a stable function name across + // engines (function expressions can otherwise inherit the variable name). + const fn = (() => null) as unknown as CatalogComponent; + Object.defineProperty(fn, 'name', { value: name }); + return fn; +} + +const Text = namedStub('Text'); +const Button = namedStub('Button'); + +const TEXT_MANIFEST: CatalogManifest = { + Text: { type: 'object', properties: { text: { type: 'string' } } }, +}; +const BUTTON_MANIFEST: CatalogManifest = { + Button: { type: 'object', properties: { child: { type: 'string' } } }, +}; + +describe('defineCatalog', () => { + test('bare component derives name from displayName ?? function name', () => { + const cat = defineCatalog([Text, Button]); + expect(cat.map((e) => e.name)).toEqual(['Text', 'Button']); + expect(cat[0]!.component).toBe(Text); + expect(cat[0]!.schema).toBeUndefined(); + }); + + test('tuple form derives name + schema from manifest', () => { + const cat = defineCatalog([ + [Text, TEXT_MANIFEST], + [Button, BUTTON_MANIFEST], + ]); + expect(cat[0]!.name).toBe('Text'); + expect(cat[0]!.schema).toEqual(TEXT_MANIFEST.Text); + expect(cat[1]!.name).toBe('Button'); + expect(cat[1]!.schema).toEqual(BUTTON_MANIFEST.Button); + }); + + test('mixes bare and tuple inputs in one call', () => { + const cat = defineCatalog([Text, [Button, BUTTON_MANIFEST]]); + expect(cat[0]!.schema).toBeUndefined(); + expect(cat[1]!.schema).toEqual(BUTTON_MANIFEST.Button); + }); + + test('passes through already-resolved entries', () => { + const inner = defineCatalog([[Text, TEXT_MANIFEST]]); + const outer = defineCatalog(inner); + expect(outer).toEqual(inner); + }); + + test('rejects duplicate names', () => { + expect(() => defineCatalog([Text, Text])).toThrow(/Duplicate/); + }); + + test('rejects components with no derivable name', () => { + const anonymous = (() => null) as unknown as CatalogComponent; + Object.defineProperty(anonymous, 'name', { value: '' }); + expect(() => defineCatalog([anonymous])).toThrow(/displayName/); + }); + + test('respects displayName when set', () => { + const fn = (() => null) as unknown as CatalogComponent; + Object.defineProperty(fn, 'name', { value: 'Mangled' }); + (fn as { displayName?: string }).displayName = 'Custom'; + const cat = defineCatalog([fn]); + expect(cat[0]!.name).toBe('Custom'); + }); +}); + +describe('mergeCatalogs', () => { + test('last-write-wins on duplicate names', () => { + const a = defineCatalog([[Text, TEXT_MANIFEST]]); + const Override = namedStub('Text'); + const b = defineCatalog([Override]); + const merged = mergeCatalogs(a, b); + const text = merged.find((e) => e.name === 'Text')!; + expect(text.component).toBe(Override); + expect(text.schema).toBeUndefined(); + }); +}); + +describe('resolveCatalog', () => { + test('returns a name → component map', () => { + const cat = defineCatalog([Text, Button]); + const map = resolveCatalog(cat); + expect(map.get('Text')).toBe(Text); + expect(map.get('Button')).toBe(Button); + }); +}); + +describe('serializeCatalog', () => { + test('emits version + components, omitting schema when absent', () => { + const cat = defineCatalog([Text]); + const out = serializeCatalog(cat); + expect(out.version).toBe('0.9'); + expect(out.components).toEqual([{ name: 'Text' }]); + }); + + test('attaches schema when present', () => { + const cat = defineCatalog([[Text, TEXT_MANIFEST]]); + const out = serializeCatalog(cat); + expect(out.components[0]).toEqual({ + name: 'Text', + schema: TEXT_MANIFEST.Text, + }); + }); +}); + +describe('user composes their own all-builtins catalog', () => { + // Snapshot of the recipe documented in + // packages/genui/a2ui/src/catalog/README.md. If you change this list, + // update the README too. + test('paste-able recipe builds the expected manifest', () => { + const all = defineCatalog([ + [Text, TEXT_MANIFEST], + [Button, BUTTON_MANIFEST], + ]); + const manifest = serializeCatalog(all); + expect(manifest.components.map((c) => c.name)).toEqual(['Text', 'Button']); + expect(manifest.components.every((c) => c.schema)).toBe(true); + }); +}); diff --git a/packages/genui/a2ui/test/createResource.test.ts b/packages/genui/a2ui/test/createResource.test.ts new file mode 100644 index 0000000000..63b714e085 --- /dev/null +++ b/packages/genui/a2ui/test/createResource.test.ts @@ -0,0 +1,118 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from '@rstest/core'; + +import { createResource } from '../src/store/Resource.js'; + +describe('createResource', () => { + test('starts in pending status with undefined value', () => { + const r = createResource('a'); + expect(r.status).toBe('pending'); + expect(r.value).toBeUndefined(); + expect(r.completed).toBe(false); + expect(r.getSnapshot()).toEqual({ + status: 'pending', + value: undefined, + error: undefined, + }); + }); + + test('complete moves to success and exposes value via getSnapshot', () => { + const r = createResource('a'); + r.complete('hello'); + expect(r.status).toBe('success'); + expect(r.value).toBe('hello'); + expect(r.completed).toBe(true); + expect(r.getSnapshot()).toEqual({ + status: 'success', + value: 'hello', + error: undefined, + }); + }); + + test('getSnapshot reference changes on every transition', () => { + const r = createResource('a'); + const s1 = r.getSnapshot(); + r.complete('hello'); + const s2 = r.getSnapshot(); + expect(s2).not.toBe(s1); + }); + + test('pending → error transition produces a new snapshot reference', () => { + const r = createResource('a'); + const s1 = r.getSnapshot(); + r.fail(new Error('boom')); + const s2 = r.getSnapshot(); + expect(s2).not.toBe(s1); + expect(s2.status).toBe('error'); + expect((s2.error as Error).message).toBe('boom'); + }); + + test('complete after fail is rejected — error is terminal', () => { + const r = createResource('a'); + r.fail(new Error('boom')); + r.complete('hello'); + expect(r.status).toBe('error'); + expect(r.value).toBeUndefined(); + }); + + test('read does not throw when pending', () => { + const r = createResource('a'); + expect(() => r.read()).not.toThrow(); + expect(r.read()).toBeUndefined(); + }); + + test('subscribe is invoked on every complete and disposers stop firing', () => { + const r = createResource('a'); + let count1 = 0; + let count2 = 0; + const d1 = r.subscribe(() => { + count1++; + }); + r.subscribe(() => { + count2++; + }); + + r.complete('first'); + expect(count1).toBe(1); + expect(count2).toBe(1); + + d1(); + r.complete('second'); + expect(count1).toBe(1); + expect(count2).toBe(2); + }); + + test('promise resolves on first complete', async () => { + const r = createResource('a'); + setTimeout(() => r.complete('x'), 0); + const v = await r.promise; + expect(v).toBe('x'); + }); + + test('fail moves to error and rejects promise', async () => { + const r = createResource('a'); + r.fail(new Error('boom')); + expect(r.status).toBe('error'); + await expect(r.promise).rejects.toThrow('boom'); + }); + + test('subsequent completes update value but only first resolves promise', async () => { + const r = createResource('a'); + r.complete('first'); + r.complete('second'); + expect(r.value).toBe('second'); + const v = await r.promise; + expect(v).toBe('first'); + }); + + test('onUpdate (deprecated) still receives updates', () => { + const r = createResource('a'); + const seen: string[] = []; + r.onUpdate((v) => seen.push(v)); + r.complete('x'); + r.complete('y'); + expect(seen).toEqual(['x', 'y']); + }); +}); diff --git a/packages/genui/a2ui/test/processor.test.ts b/packages/genui/a2ui/test/processor.test.ts new file mode 100644 index 0000000000..47b534cad5 --- /dev/null +++ b/packages/genui/a2ui/test/processor.test.ts @@ -0,0 +1,147 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from '@rstest/core'; + +import { MessageProcessor } from '../src/store/MessageProcessor.js'; +import type { ServerToClientMessage } from '../src/store/types.js'; + +describe('MessageProcessor', () => { + test('onUpdate supports multiple listeners', () => { + const proc = new MessageProcessor(); + const calls1: unknown[] = []; + const calls2: unknown[] = []; + + proc.onUpdate((data) => calls1.push(data)); + proc.onUpdate((data) => calls2.push(data)); + + proc.processMessages([ + { createSurface: { surfaceId: 's1', catalogId: 'test' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text', text: 'hi' }], + }, + }, + ] as ServerToClientMessage[]); + + expect(calls1.length).toBeGreaterThan(0); + expect(calls2.length).toBe(calls1.length); + }); + + test('onUpdate disposer unsubscribes', () => { + const proc = new MessageProcessor(); + const calls1: unknown[] = []; + const calls2: unknown[] = []; + + const dispose1 = proc.onUpdate((d) => calls1.push(d)); + proc.onUpdate((d) => calls2.push(d)); + dispose1(); + + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text' }], + }, + }, + ] as ServerToClientMessage[]); + + expect(calls1.length).toBe(0); + expect(calls2.length).toBeGreaterThan(0); + }); + + test('beginRendering fires when root component lands', () => { + const proc = new MessageProcessor(); + const events: { type: string; surfaceId: string }[] = []; + proc.onUpdate((d) => { + events.push(d as { type: string; surfaceId: string }); + }); + + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text', text: 'hi' }], + }, + }, + ] as ServerToClientMessage[]); + + expect(events.some((e) => e.type === 'beginRendering')).toBe(true); + expect(events.some((e) => e.type === 'surfaceUpdate')).toBe(true); + }); + + test('deleteSurface emits and clears state', () => { + const proc = new MessageProcessor(); + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text' }], + }, + }, + ] as ServerToClientMessage[]); + + expect(proc.getSurfaces().has('s1')).toBe(true); + + const events: string[] = []; + proc.onUpdate((d) => events.push((d as { type: string }).type)); + proc.processMessages([ + { deleteSurface: { surfaceId: 's1' } }, + ] as ServerToClientMessage[]); + + expect(events).toContain('deleteSurface'); + expect(proc.getSurfaces().has('s1')).toBe(false); + }); + + test('updateDataModel writes to surface store', () => { + const proc = new MessageProcessor(); + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text' }], + }, + }, + { + updateDataModel: { surfaceId: 's1', path: '/title', value: 'hello' }, + }, + ] as ServerToClientMessage[]); + + const surface = proc.getOrCreateSurface('s1'); + expect(surface.store.getSignal('/title').value).toBe('hello'); + }); + + test('dispatch with no listeners resolves with empty array', async () => { + const proc = new MessageProcessor(); + const result = await proc.dispatch({ userAction: { name: 'x' } }); + expect(result).toEqual([]); + }); + + test('onEvent multi-listener with disposer', () => { + const proc = new MessageProcessor(); + const calls1: unknown[] = []; + const calls2: unknown[] = []; + const d1 = proc.onEvent((e) => { + calls1.push(e.message); + e.resolve(null); + }); + proc.onEvent((e) => { + calls2.push(e.message); + e.resolve(null); + }); + + void proc.dispatch({ x: 1 }); + expect(calls1.length).toBe(1); + expect(calls2.length).toBe(1); + + d1(); + void proc.dispatch({ x: 2 }); + expect(calls1.length).toBe(1); + expect(calls2.length).toBe(2); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df294616a4..dbdca21b4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -592,9 +592,6 @@ importers: '@lynx-js/lynx-ui': specifier: ^3.130.0 version: 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-input': - specifier: ^3.130.0 - version: 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': specifier: workspace:* version: link:../../react