feat: Theater route — A2UI message-stream playback (native @a2ui/angular) - #130
feat: Theater route — A2UI message-stream playback (native @a2ui/angular)#130jerelvelarde wants to merge 10 commits into
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request introduces a 'Custom Catalog' page and a 'Theater' playback route to the shell application, allowing users to render assembled components natively and replay recorded A2UI message streams. It adds several custom catalog components (such as charts, tables, and cards) along with their respective schemas, styles, and tests. The review feedback focuses on improving robustness against invalid or malformed user-provided JSON inputs. Specifically, the reviewer recommends adding type checks and optional chaining in the data table, pie chart, bar chart, catalog editor, and theater inspector to prevent potential runtime crashes and TypeErrors when parsing or binding dynamic data.
| protected readonly columns = computed( | ||
| () => (this.props()['columns']?.value() ?? []) as TableColumn[], | ||
| ); | ||
| protected readonly rows = computed( | ||
| () => (this.props()['rows']?.value() ?? []) as Record<string, unknown>[], | ||
| ); |
There was a problem hiding this comment.
Since the data table is driven by user-editable JSON in the Monaco editor, the columns and rows arrays could potentially contain null or undefined elements if the user inputs invalid structures. Filtering out any non-object elements from these arrays in the computed properties ensures that the template loops and the cell helper can safely access properties without throwing runtime TypeErrors.
| protected readonly columns = computed( | |
| () => (this.props()['columns']?.value() ?? []) as TableColumn[], | |
| ); | |
| protected readonly rows = computed( | |
| () => (this.props()['rows']?.value() ?? []) as Record<string, unknown>[], | |
| ); | |
| protected readonly columns = computed( | |
| () => ((this.props()['columns']?.value() ?? []) as TableColumn[]).filter(c => c && typeof c === 'object'), | |
| ); | |
| protected readonly rows = computed( | |
| () => ((this.props()['rows']?.value() ?? []) as Record<string, unknown>[]).filter(r => r && typeof r === 'object'), | |
| ); |
| protected readonly slices = computed<PieSlice[]>(() => { | ||
| const data = this.data(); | ||
| const innerR = this.innerRadius(); | ||
| const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0); | ||
| if (total <= 0) return []; | ||
|
|
||
| const slices: PieSlice[] = []; | ||
| let cursor = 0; | ||
| data.forEach((d, i) => { | ||
| const value = Number(d.value) || 0; | ||
| const start = (cursor / total) * 360; | ||
| cursor += value; | ||
| let end = (cursor / total) * 360; | ||
| // Keep a lone full-circle slice visible (an exact 360° arc is a no-op). | ||
| if (end - start >= 360) end = start + 359.999; | ||
| slices.push({ | ||
| path: this.arc(start, end, innerR), | ||
| color: d.color ?? PALETTE[i % PALETTE.length], | ||
| label: String(d.label ?? ''), | ||
| value, | ||
| }); | ||
| }); | ||
| return slices; | ||
| }); |
There was a problem hiding this comment.
The slices computed property accesses properties like d.value, d.color, and d.label directly on the elements of the data array. If the user-provided JSON contains null or undefined elements in the data array, this will cause a runtime crash. Using optional chaining (d?.value, d?.color, d?.label) prevents these crashes and ensures robust rendering.
protected readonly slices = computed<PieSlice[]>(() => {
const data = this.data();
const innerR = this.innerRadius();
const total = data.reduce((sum, d) => sum + (Number(d?.value) || 0), 0);
if (total <= 0) return [];
const slices: PieSlice[] = [];
let cursor = 0;
data.forEach((d, i) => {
const value = Number(d?.value) || 0;
const start = (cursor / total) * 360;
cursor += value;
let end = (cursor / total) * 360;
// Keep a lone full-circle slice visible (an exact 360° arc is a no-op).
if (end - start >= 360) end = start + 359.999;
slices.push({
path: this.arc(start, end, innerR),
color: d?.color ?? PALETTE[i % PALETTE.length],
label: String(d?.label ?? ''),
value,
});
});
return slices;
});| protected readonly bars = computed<Bar[]>(() => { | ||
| const data = this.data(); | ||
| const max = data.reduce((m, d) => Math.max(m, Number(d.value) || 0), 0); | ||
| return data.map((d, i) => { | ||
| const value = Number(d.value) || 0; | ||
| const height = max > 0 ? (value / max) * PLOT_H : 0; | ||
| const x = i * SLOT_W + (SLOT_W - BAR_W) / 2; | ||
| const y = TOP_PAD + (PLOT_H - height); | ||
| return { | ||
| x, | ||
| y, | ||
| width: BAR_W, | ||
| height, | ||
| label: String(d.label ?? ''), | ||
| labelX: x + BAR_W / 2, | ||
| valueLabel: `${this.prefix()}${value}${this.suffix()}`, | ||
| valueY: y - 6, | ||
| }; | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The bars computed property accesses d.value and d.label directly on the elements of the data array. If the user-provided JSON contains null or undefined elements in the data array, this will cause a runtime crash. Using optional chaining (d?.value, d?.label) prevents these crashes and ensures robust rendering.
| protected readonly bars = computed<Bar[]>(() => { | |
| const data = this.data(); | |
| const max = data.reduce((m, d) => Math.max(m, Number(d.value) || 0), 0); | |
| return data.map((d, i) => { | |
| const value = Number(d.value) || 0; | |
| const height = max > 0 ? (value / max) * PLOT_H : 0; | |
| const x = i * SLOT_W + (SLOT_W - BAR_W) / 2; | |
| const y = TOP_PAD + (PLOT_H - height); | |
| return { | |
| x, | |
| y, | |
| width: BAR_W, | |
| height, | |
| label: String(d.label ?? ''), | |
| labelX: x + BAR_W / 2, | |
| valueLabel: `${this.prefix()}${value}${this.suffix()}`, | |
| valueY: y - 6, | |
| }; | |
| }); | |
| }); | |
| protected readonly bars = computed<Bar[]>(() => { | |
| const data = this.data(); | |
| const max = data.reduce((m, d) => Math.max(m, Number(d?.value) || 0), 0); | |
| return data.map((d, i) => { | |
| const value = Number(d?.value) || 0; | |
| const height = max > 0 ? (value / max) * PLOT_H : 0; | |
| const x = i * SLOT_W + (SLOT_W - BAR_W) / 2; | |
| const y = TOP_PAD + (PLOT_H - height); | |
| return { | |
| x, | |
| y, | |
| width: BAR_W, | |
| height, | |
| label: String(d?.label ?? ''), | |
| labelX: x + BAR_W / 2, | |
| valueLabel: `${this.prefix()}${value}${this.suffix()}`, | |
| valueY: y - 6, | |
| }; | |
| }); | |
| }); |
| protected onEdit(text: string): void { | ||
| let parsed: Record<string, unknown>; | ||
| try { | ||
| parsed = JSON.parse(text) as Record<string, unknown>; | ||
| } catch { | ||
| return; | ||
| } | ||
| this.renderer.processMessages([ | ||
| {updateDataModel: {surfaceId: this.activeExampleId(), path: '/', op: 'replace', value: parsed}}, | ||
| ] as never); | ||
| } |
There was a problem hiding this comment.
The onEdit method parses the user-provided text directly using JSON.parse and casts it to Record<string, unknown>. However, if the user inputs a valid JSON literal that is not an object (such as a number, boolean, string, or null), JSON.parse will succeed but the resulting value will not be an object. This can cause runtime errors downstream when the renderer expects a dictionary. Adding a check to ensure the parsed value is a non-null object prevents these potential crashes.
protected onEdit(text: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(text);
}
catch {
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return;
}
this.renderer.processMessages([
{updateDataModel: {surfaceId: this.activeExampleId(), path: '/', op: 'replace', value: parsed as Record<string, unknown>}},
] as never);
}| protected readonly currentData = computed<string>(() => { | ||
| const messages = this.activeRecording().messages; | ||
| let value: unknown = {}; | ||
| for (let i = 0; i < this.step(); i++) { | ||
| const update = messages[i]['updateDataModel'] as {value?: unknown} | undefined; | ||
| if (update && 'value' in update) value = update.value; | ||
| } | ||
| return formatJson(value); | ||
| }); |
There was a problem hiding this comment.
The currentData computed property casts messages[i]['updateDataModel'] to {value?: unknown} and checks 'value' in update. If updateDataModel is not an object (e.g., if it is null or a primitive value), the in operator will throw a runtime TypeError. Adding a check to ensure update is a non-null object before using the in operator makes this computation safe.
protected readonly currentData = computed<string>(() => {
const messages = this.activeRecording().messages;
let value: unknown = {};
for (let i = 0; i < this.step(); i++) {
const update = messages[i]['updateDataModel'];
if (update && typeof update === 'object' && 'value' in update) {
value = (update as {value: unknown}).value;
}
}
return formatJson(value);
});Enables in-app (no-iframe) rendering of custom catalogs. Pin shell zod to
3.25.76 so app-authored catalog schemas share the exact zod instance the
@a2ui/web_core binder introspects (Angular's own zod 4 stays nested/isolated).
Validated end-to-end: a shell-authored custom Title resolves a {path} binding
via <a2ui-v09-surface>.
Port of the react-flight-catalog dashboard catalog to native @a2ui/angular renderers (Row/Column/Title/Badge/Metric/DashboardCard/FlightCard/DataTable/ Button + hand-rolled SVG PieChart/BarChart), themed via --cpk-* tokens. buildDashboardCatalog() registers all 11 into an AngularCatalog. Surface-render tests green (children, chart shapes, path bindings, action dispatch).
Native /custom-catalog page: renders the Flight Card + Sales Dashboard trees via <a2ui-v09-surface> (no iframe) from the 11-component dashboard catalog, with an editable Monaco data-model panel. Example toggle + data-state tabs (Morning/Afternoon, Q1-Q4); editing the JSON live-updates the surface via updateDataModel. Surfaces are built once per example (createSurface+ updateComponents), so data changes push updateDataModel only. Nav item added to Google's list; shell spec nav counts updated for the new route.
Transport (reset/step/play-pause), clickable message timeline, and an Events/Data/Config inspector over a native @a2ui/angular surface.
The native renderer.processMessages needs createSurface + updateComponents in one call (as the Assembled/Reference pages send); applying them incrementally left the surface root empty. Each seek now replays [0,target) as one batch on a fresh generation, creating the surface in the same tick the template switches to its id (no mount-before-create gap). Also drops the v0.9 envelope's top-level version field, which the native processor does not consume.
922f023 to
d00bd0e
Compare
⚡ A2UI Composer PR PreviewYour automated preview is successfully live (commit
|
…inst malformed JSON
b6ca3a6 to
4d7d5e2
Compare
|
Addressed the Gemini review — the catalog is driven by user-editable JSON in the Monaco editor, so these harden against malformed input:
|
A "theater" that replays a recorded A2UI message stream into a live native surface, message by message — useful for demoing and debugging how a surface builds up over a generation.
What
/theaterroute with a playback component and a generation-based playback engine.@a2ui/angular(no iframe); replays the message prefix as a batch so the native surface renders correctly at each step.Why
Makes the incremental, message-by-message nature of A2UI surfaces visible — a useful lens for demos and for debugging streamed generations.
Testing
ng build(lint + AOT) green on a clean build;theater+composer-shellunit tests pass (25 specs).Contributed by CopilotKit.
Screenshots
/theater— A2UI message-stream playback: