Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1072,7 +1072,7 @@
},
{
"name": "catalog",
"type": "InputSignal<Readonly<Record<string, Type<unknown>>>>",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>>>",
"description": "",
"optional": false
},
Expand Down Expand Up @@ -1673,7 +1673,7 @@
},
{
"name": "views",
"type": "InputSignal<Readonly<Record<string, Type<unknown>>> | undefined>",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>> | undefined>",
"description": "",
"optional": false
},
Expand Down Expand Up @@ -2801,7 +2801,7 @@
},
{
"name": "views",
"type": "InputSignal<Readonly<Record<string, Type<unknown>>> | undefined>",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>> | undefined>",
"description": "A2UI component catalog forwarded to the inner <chat>. Without it,\nmessages classified as A2UI parse correctly but never mount a\nsurface. Pass `a2uiBasicCatalog()` from `@ngaf/chat`.",
"optional": false
}
Expand Down Expand Up @@ -3063,7 +3063,7 @@
},
{
"name": "views",
"type": "InputSignal<Readonly<Record<string, Type<unknown>>> | undefined>",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>> | undefined>",
"description": "A2UI component catalog forwarded to the inner <chat>. Without it,\nmessages classified as A2UI parse correctly but never mount a\nsurface. Pass `a2uiBasicCatalog()` from `@ngaf/chat`.",
"optional": false
}
Expand Down Expand Up @@ -3104,7 +3104,7 @@
},
{
"name": "resolvedRegistry",
"type": "Signal<Readonly<Record<string, Type<unknown>>>>",
"type": "Signal<Readonly<Record<string, Type<unknown> | RenderViewEntry>>>",
"description": "",
"optional": false
},
Expand All @@ -3122,7 +3122,7 @@
},
{
"name": "viewRegistry",
"type": "InputSignal<Readonly<Record<string, Type<unknown>>> | undefined>",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>> | undefined>",
"description": "",
"optional": false
}
Expand Down Expand Up @@ -5586,8 +5586,8 @@
{
"name": "ViewRegistry",
"kind": "type",
"description": "A registry of view components available for generative UI rendering.\nPlain frozen object mapping view names to Angular component types.\nCompose via object spread: `views({ ...base, ...more })`.",
"signature": "Readonly<Record<string, Type<unknown>>>",
"description": "A registry of view components available for generative UI rendering.\nEach entry is either a bare component Type (legacy shape) or a\n`RenderViewEntry` { component, fallback? }.",
"signature": "Readonly<Record<string, Type<unknown> | RenderViewEntry>>",
"examples": []
},
{
Expand Down Expand Up @@ -6141,11 +6141,11 @@
"name": "views",
"kind": "function",
"description": "Creates a view registry from a name → component map.",
"signature": "views(map: Record<string, Type<unknown>>): ViewRegistry",
"signature": "views(map: Record<string, Type<unknown> | RenderViewEntry>): ViewRegistry",
"params": [
{
"name": "map",
"type": "Record<string, Type<unknown>>",
"type": "Record<string, Type<unknown> | RenderViewEntry>",
"description": "",
"optional": false
}
Expand Down Expand Up @@ -6185,7 +6185,7 @@
"name": "withViews",
"kind": "function",
"description": "Adds views to a registry without overwriting existing entries.\nNew keys are added; keys that already exist in `base` are preserved.",
"signature": "withViews(base: ViewRegistry, additions: Record<string, Type<unknown>>): ViewRegistry",
"signature": "withViews(base: ViewRegistry, additions: Record<string, Type<unknown> | RenderViewEntry>): ViewRegistry",
"params": [
{
"name": "base",
Expand All @@ -6195,7 +6195,7 @@
},
{
"name": "additions",
"type": "Record<string, Type<unknown>>",
"type": "Record<string, Type<unknown> | RenderViewEntry>",
"description": "",
"optional": false
}
Expand Down
63 changes: 55 additions & 8 deletions apps/website/content/docs/render/api/api-docs.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
[
{
"name": "DefaultFallbackComponent",
"kind": "class",
"description": "",
"params": [],
"examples": [],
"properties": [],
"methods": []
},
{
"name": "RenderElementComponent",
"kind": "class",
Expand All @@ -24,6 +33,18 @@
"description": "",
"optional": false
},
{
"name": "mountClass",
"type": "Signal<AngularComponentRenderer | null>",
"description": "Picks fallback or real based on notReady. The mountedReal latch is\n driven by a constructor effect (not this computed) — Angular forbids\n signal writes inside computed.",
"optional": false
},
{
"name": "notReady",
"type": "Signal<boolean>",
"description": "True when ANY resolved prop value is undefined (i.e. a state\n binding points at a path the store hasn't populated). Framework-\n injected keys (bindings, emit, loading, childKeys, spec) are\n excluded — only consumer-resolved props matter for readiness.",
"optional": false
},
{
"name": "parentInjector",
"type": "Injector<>",
Expand Down Expand Up @@ -186,6 +207,12 @@
"description": "",
"optional": false
},
{
"name": "getFallback",
"type": "unknown",
"description": "",
"optional": false
},
{
"name": "names",
"type": "unknown",
Expand Down Expand Up @@ -391,6 +418,26 @@
],
"examples": []
},
{
"name": "RenderViewEntry",
"kind": "interface",
"description": "A view registry entry. Bare `Type` form is the legacy shape; the\nobject form lets consumers attach a per-component fallback that\nmounts while any state-bound prop on the element is still\nunresolved. The fallback is monotonic per element instance: once\nthe real component mounts, subsequent re-renders never revert to\nfallback even if a prop later resolves to undefined.",
"properties": [
{
"name": "component",
"type": "Type<unknown>",
"description": "",
"optional": false
},
{
"name": "fallback",
"type": "Type<unknown>",
"description": "",
"optional": true
}
],
"examples": []
},
{
"name": "RepeatScope",
"kind": "interface",
Expand Down Expand Up @@ -434,19 +481,19 @@
{
"name": "ViewRegistry",
"kind": "type",
"description": "A registry of view components available for generative UI rendering.\nPlain frozen object mapping view names to Angular component types.\nCompose via object spread: `views({ ...base, ...more })`.",
"signature": "Readonly<Record<string, Type<unknown>>>",
"description": "A registry of view components available for generative UI rendering.\nEach entry is either a bare component Type (legacy shape) or a\n`RenderViewEntry` { component, fallback? }.",
"signature": "Readonly<Record<string, Type<unknown> | RenderViewEntry>>",
"examples": []
},
{
"name": "defineAngularRegistry",
"kind": "function",
"description": "",
"signature": "defineAngularRegistry(componentMap: Record<string, AngularComponentRenderer>): AngularRegistry",
"signature": "defineAngularRegistry(componentMap: RegistryInput): AngularRegistry",
"params": [
{
"name": "componentMap",
"type": "Record<string, AngularComponentRenderer>",
"type": "RegistryInput",
"description": "",
"optional": false
}
Expand Down Expand Up @@ -537,11 +584,11 @@
"name": "views",
"kind": "function",
"description": "Creates a view registry from a name → component map.",
"signature": "views(map: Record<string, Type<unknown>>): ViewRegistry",
"signature": "views(map: Record<string, Type<unknown> | RenderViewEntry>): ViewRegistry",
"params": [
{
"name": "map",
"type": "Record<string, Type<unknown>>",
"type": "Record<string, Type<unknown> | RenderViewEntry>",
"description": "",
"optional": false
}
Expand Down Expand Up @@ -581,7 +628,7 @@
"name": "withViews",
"kind": "function",
"description": "Adds views to a registry without overwriting existing entries.\nNew keys are added; keys that already exist in `base` are preserved.",
"signature": "withViews(base: ViewRegistry, additions: Record<string, Type<unknown>>): ViewRegistry",
"signature": "withViews(base: ViewRegistry, additions: Record<string, Type<unknown> | RenderViewEntry>): ViewRegistry",
"params": [
{
"name": "base",
Expand All @@ -591,7 +638,7 @@
},
{
"name": "additions",
"type": "Record<string, Type<unknown>>",
"type": "Record<string, Type<unknown> | RenderViewEntry>",
"description": "",
"optional": false
}
Expand Down
5 changes: 4 additions & 1 deletion libs/chat/src/lib/markdown/markdown-children.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export class MarkdownChildrenComponent {
});

protected resolve(child: MarkdownNode): Type<unknown> | null {
return this.registry[child.type] ?? null;
const entry = this.registry[child.type];
if (!entry) return null;
// ViewRegistry entries are either a bare Type or { component, fallback? }.
return typeof entry === 'function' ? entry : entry.component;
}
}
2 changes: 1 addition & 1 deletion libs/render/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
ignoredDependencies: ['vite', '@nx/vite'],
ignoredDependencies: ['vite', '@nx/vite', '@analogjs/vite-plugin-angular'],
},
],
},
Expand Down
23 changes: 23 additions & 0 deletions libs/render/src/lib/default-fallback.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { DefaultFallbackComponent } from './default-fallback.component';

describe('DefaultFallbackComponent', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [DefaultFallbackComponent] }));

it('renders a region role with the Building UI status text', () => {
const fx = TestBed.createComponent(DefaultFallbackComponent);
fx.detectChanges();
const status = fx.nativeElement.querySelector('[role="status"]');
expect(status).toBeTruthy();
expect(status.textContent).toContain('Building UI');
});

it('renders three shimmer rows', () => {
const fx = TestBed.createComponent(DefaultFallbackComponent);
fx.detectChanges();
const rows = fx.nativeElement.querySelectorAll('.render-default-fallback__row');
expect(rows.length).toBe(3);
});
});
60 changes: 60 additions & 0 deletions libs/render/src/lib/default-fallback.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: MIT
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
selector: 'render-default-fallback',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
:host { display: block; width: 100%; }
.render-default-fallback {
border: 1px solid var(--ngaf-chat-separator, #303540);
border-radius: 10px;
padding: 14px;
background: var(--ngaf-chat-surface-alt, #1a1d23);
}
.render-default-fallback__label {
font-size: 12px;
color: var(--ngaf-chat-text-muted, #9aa0aa);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.render-default-fallback__rows {
display: flex; flex-direction: column; gap: 8px;
}
.render-default-fallback__row {
height: 10px; border-radius: 5px;
background: linear-gradient(
90deg,
var(--ngaf-chat-separator, #303540) 0%,
color-mix(in srgb, var(--ngaf-chat-separator, #303540) 70%, transparent) 50%,
var(--ngaf-chat-separator, #303540) 100%
);
background-size: 200% 100%;
animation: render-default-fallback-shimmer 1.4s ease-in-out infinite;
}
.render-default-fallback__row:nth-child(1) { width: 70%; }
.render-default-fallback__row:nth-child(2) { width: 90%; }
.render-default-fallback__row:nth-child(3) { width: 50%; }
@keyframes render-default-fallback-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`],
template: `
<div class="render-default-fallback" role="status" aria-live="polite">
<div class="render-default-fallback__label">
<span aria-hidden="true">✨</span>
<span>Building UI…</span>
</div>
<div class="render-default-fallback__rows">
<div class="render-default-fallback__row"></div>
<div class="render-default-fallback__row"></div>
<div class="render-default-fallback__row"></div>
</div>
</div>
`,
})
export class DefaultFallbackComponent {}
43 changes: 43 additions & 0 deletions libs/render/src/lib/define-angular-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@
import { describe, it, expect } from 'vitest';
import { Component } from '@angular/core';
import { defineAngularRegistry } from './define-angular-registry';
import { DefaultFallbackComponent } from './default-fallback.component';

@Component({ selector: 'render-test-card', standalone: true, template: '<div>card</div>' })
class TestCardComponent {}

@Component({ selector: 'render-test-button', standalone: true, template: '<button>btn</button>' })
class TestButtonComponent {}

@Component({ standalone: true, template: '<span>real</span>' })
class FakeRealComponent {}

@Component({ standalone: true, template: '<span>fallback</span>' })
class FakeFallbackComponent {}

describe('defineAngularRegistry', () => {
it('should create a registry mapping component names to Angular components', () => {
const registry = defineAngularRegistry({
Expand All @@ -32,3 +39,39 @@ describe('defineAngularRegistry', () => {
expect(registry.names()).toEqual(['Card', 'Button']);
});
});

describe('defineAngularRegistry — fallback API', () => {
it('bare type entry: get returns the type; getFallback returns the default', () => {
const reg = defineAngularRegistry({ button: FakeRealComponent });
expect(reg.get('button')).toBe(FakeRealComponent);
expect(reg.getFallback('button')).toBe(DefaultFallbackComponent);
});

it('object entry with fallback: get returns component; getFallback returns the configured fallback', () => {
const reg = defineAngularRegistry({
button: { component: FakeRealComponent, fallback: FakeFallbackComponent },
});
expect(reg.get('button')).toBe(FakeRealComponent);
expect(reg.getFallback('button')).toBe(FakeFallbackComponent);
});

it('object entry without fallback: getFallback returns the default', () => {
const reg = defineAngularRegistry({ button: { component: FakeRealComponent } });
expect(reg.get('button')).toBe(FakeRealComponent);
expect(reg.getFallback('button')).toBe(DefaultFallbackComponent);
});

it('unknown name: get returns undefined; getFallback returns undefined', () => {
const reg = defineAngularRegistry({ button: FakeRealComponent });
expect(reg.get('unknown')).toBeUndefined();
expect(reg.getFallback('unknown')).toBeUndefined();
});

it('names() returns all registered keys regardless of entry shape', () => {
const reg = defineAngularRegistry({
button: FakeRealComponent,
card: { component: FakeRealComponent, fallback: FakeFallbackComponent },
});
expect(reg.names().sort()).toEqual(['button', 'card']);
});
});
Loading
Loading