App render-substrate: consume the broker's RenderDataFrame
Story-1791 shipped the component catalog (what a
surface renders, bound to a real wire DTO) as schema + codegen only.
This change is the app consumer: the dock-chat rail now
takes the broker's RenderDataFrame
({component_id, query_ref, data, render_marker, state},
merged in functions #1793), validates the rows at the trust boundary
against the bound component's prop-schema, and renders them — or, when
the component is unknown or the rows are malformed, a
text fallback that is never blank (RFD-AGT-004 §6
D11/D12, §7.6). No new .tsx catalog components yet — the
substrate renders generically; the honest fallback / denied / empty
paths are the proof.
What changed
renderFromWire joins the existing
navigateFromWire / setViewFromWire /
proposeOperationFromWire family. A new
render.data case in reduceFrame sets
RailState.render; it is reset on
run.accepted like every other terminal intent.
validateRenderRows looks the
component_id up in the #1791 catalog and parses
data with z.array(propSchema). Unknown id
(D11) or a parse failure → a truncated JSON summary on
fallbackText, rendered as text. Reuses the SAME
GENERATED_PROP_SCHEMAS map check:catalog
already validates cards against — one authority.
The new componentResult
StoredThreadMessage variant carries only
{component_id, query_ref, capturedAt, renderCatalogVersion}.
No row data reaches sessionStorage; replay RE-RUNS the
bound intent (re-authorized server-side), so stale rows are never
resurrected.
default
ThreadItemView gets one explicit
componentResult case → ComponentResultView,
whose first branch is the explicit fallback (text summary). Denied →
"no longer available"; empty → empty-state. The switch
default stays only as the forward-compat unknown-KIND
guard — it never catches an unknown component_id.
RENDER_CATALOG_VERSION is stamped on every persisted
componentResult reference (AC4). There is NO
client→brain version carrier: the generated
DockChatContext is a STRICT envelope, and the app
already falls back on an unknown component_id (D11), so
the carrier is not needed for v1.
matter_table / matter_card bespoke
.tsx are out of scope. Valid rows render as a
token-styled grid keyed by DTO field names — enough to prove the
substrate end to end without inventing a catalog component.
Where it lives
src/lib/surface-registry/page-card/render-catalog.ts— NEW.RENDER_CATALOG_VERSION+validateRenderRows(component_id → row Zod schema, reusingGENERATED_PROP_SCHEMAS).src/lib/agents/atlas-rail.ts—WireRenderFrame,RailRender,renderFromWire; therender.datacase +RailState.render(reset on launch).src/components/agents/agent-thread-item.ts— newcomponentResultkind.src/components/agents/agent-chat-panel.tsx— explicitcomponentResultcase +ComponentResultView: text/JSON fallback + denied + empty ONLY (no generic table — typedmatter_table/matter_cardrendering is 003-components; a valid frame renders as a text/JSON summary until then).src/components/agents/agent-dock-mount.tsx(cont.) — the render reference is persisted in its OWNuseEffect(inlined), decoupled from the reply-commit gate: a render frame arriving after the same run's reply still persists.src/lib/agents/agent-thread-store.ts—componentResultREFERENCE variant (no rows). Persist/hydrate are generic — no per-kind logic change.src/lib/agents/use-dock-chat-rail.ts— exposesrail.render(the latest render frame, reset per launch).src/lib/agents/dock-render-bridge.ts— NEW, pure.liveRenderItem/mergeLiveRender(rail render → VISIBLEcomponentResult, rows or text fallback) +renderReference(intent-only persist, no rows).src/components/agents/agent-dock-mount.tsx— on a settled turn, persists the reference AND surfaces the live render item (override-by-id); a render frame now counts as turn content.toThreadItemmaps a rehydrated reference to an empty placeholder.messages/{en,hi}.json—componentFallbackLabel/componentDenied/componentEmpty(both locales).
Verify
pnpm lint # clean (1 pre-existing avatar.tsx warning, unrelated) pnpm typecheck # clean pnpm test # 882 passed pnpm build # green git diff --check # clean # NOTE: do NOT run pnpm codegen — import contracts from src/gen.
- AC2 (mounted dock)
agent-dock-mount.render.test.tsxMOUNTS the real<AgentDockMount>(RTL + react-query + intl;useDockChatRailmocked to return arender.data-derivedRailRender). It asserts the rail → items →AgentDock→ panel wiring end to end: a valid frame paints a VISIBLEcomponentResult(row text in the DOM), an unknowncomponent_idpaints a VISIBLE text/JSON fallback, and the reference is persisted tosessionStoragewith intent-only keys (no rows). Panel-level + pure-bridge coverage stays inagent-chat-panel.render.test.tsx/dock-render-bridge.test.ts. - No stale render a malformed
render.data(missing ids) CLEARS any priorrail.render— a stale result never lingers (atlas-rail.test.ts). - AC2 the persisted envelope carries
query_ref+component_idbut NOTclientName/rows; it rehydrates as a reference (replay re-runs the intent). - AC3 malformed rows for a known component fall back (no throw).
- AC4
state="denied"→ "no longer available"; a known component with zero rows → empty-state. - Persist race render persistence lives in its own
useEffect, not behind the reply gate, so a render frame arriving after the same run's text reply still persists the reference. - No regression all existing rail + store tests green; new coverage in
atlas-rail.test.ts,agent-thread-store.test.ts,dock-render-bridge.test.ts.
Reversible calls (stated for review): the render frame
envelope type is assumed render.data with the
RenderDataFrame fields flat on payload (consistent with
run.failed); the exact string reconciles with the functions
emission ticket and is forward-compatible (unknown frame types are
ignored today). No client→brain version carrier is sent — the generated
DockChatContext is a STRICT envelope, and D11 unknown-id
fallback covers v1.
Deferred (re-scope, AGENT1 blocker-2): the reference-only
persist is kept (security invariant satisfied), but replay
RE-RUN on rehydrate — re-materializing rows after a refresh — is DEFERRED
to a follow-on (coupled to live-rail re-invoke). A rehydrated reference
stays an empty placeholder until then (marked with a ponytail:
note in the dock).
Non-goal (RFD-AGT-004): no new .tsx catalog
components (003-components), no functions/agents changes (separate
emission tickets).
Files changed
15 file(s), +932 / -6 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +4 | -1 |
| messages/hi.json | Config | +4 | -1 |
| src/components/agents/agent-chat-panel.render.test.tsx | Component | +118 | -0 |
| src/components/agents/agent-chat-panel.tsx | Component | +45 | -1 |
| src/components/agents/agent-dock-mount.render.test.tsx | Component | +170 | -0 |
| src/components/agents/agent-dock-mount.tsx | Component | +50 | -2 |
| src/components/agents/agent-thread-item.ts | Component | +27 | -1 |
| src/lib/agents/__tests__/agent-thread-store.test.ts | Library | +27 | -0 |
| src/lib/agents/agent-thread-store.ts | Library | +18 | -0 |
| src/lib/agents/atlas-rail.test.ts | Library | +121 | -0 |
| src/lib/agents/atlas-rail.ts | Library | +104 | -0 |
| src/lib/agents/dock-render-bridge.test.ts | Library | +111 | -0 |
| src/lib/agents/dock-render-bridge.ts | Library | +79 | -0 |
| src/lib/agents/use-dock-chat-rail.ts | Library | +1 | -0 |
| src/lib/surface-registry/page-card/render-catalog.ts | Library | +53 | -0 |