Skip to content

feat: Theater route — A2UI message-stream playback (native @a2ui/angular) - #130

Open
jerelvelarde wants to merge 10 commits into
a2ui-project:mainfrom
jerelvelarde:jerel/pr-theater
Open

feat: Theater route — A2UI message-stream playback (native @a2ui/angular)#130
jerelvelarde wants to merge 10 commits into
a2ui-project:mainfrom
jerelvelarde:jerel/pr-theater

Conversation

@jerelvelarde

Copy link
Copy Markdown
Contributor

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.

Depends on #121 (Custom Catalog). This branch is stacked on #121, so the foundation commits appear in this diff until #121 merges. The Theater-specific changes are the last 4 commits: theater/** plus the /theater route + nav entry.

What

  • /theater route with a playback component and a generation-based playback engine.
  • Recordings derived from the flight and sales example surfaces.
  • Renders natively via @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-shell unit tests pass (25 specs).


Contributed by CopilotKit.

Screenshots

/theater — A2UI message-stream playback:

Light Dark

@google-cla

google-cla Bot commented Jul 24, 2026

Copy link
Copy Markdown

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +88 to +93
protected readonly columns = computed(
() => (this.props()['columns']?.value() ?? []) as TableColumn[],
);
protected readonly rows = computed(
() => (this.props()['rows']?.value() ?? []) as Record<string, unknown>[],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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'),
);

Comment on lines +128 to +151
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;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
  });

Comment on lines +141 to +160
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,
};
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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,
};
});
});

Comment on lines +157 to +167
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
  }

Comment on lines +218 to +226
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);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.
@github-actions

Copy link
Copy Markdown
Contributor

⚡ A2UI Composer PR Preview

Your automated preview is successfully live (commit d00bd0e):
👉 Launch PR Preview

Note: This environment will be wiped automatically when the PR is merged or closed.

@jerelvelarde

Copy link
Copy Markdown
Contributor Author

Addressed the Gemini review — the catalog is driven by user-editable JSON in the Monaco editor, so these harden against malformed input:

  • Chart computeds guard with Array.isArray plus optional-chaining / Number(d?.value) || 0, so a non-array or non-object payload can't throw.
  • onEdit wraps JSON.parse in try/catch and rejects non-object / array payloads.
  • theater's currentData uses a typed cast plus a 'value' in update guard before reading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant