diff --git a/libs/a2ui/package.json b/libs/a2ui/package.json index 5638a5e8..d47134a0 100644 --- a/libs/a2ui/package.json +++ b/libs/a2ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/a2ui", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json index 4ca6733d..dd44dde0 100644 --- a/libs/ag-ui/package.json +++ b/libs/ag-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ag-ui", - "version": "0.0.23", + "version": "0.0.24", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/chat/package.json b/libs/chat/package.json index 5a0f4a5d..6d0670ef 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.23", + "version": "0.0.24", "exports": { ".": { "types": "./index.d.ts", diff --git a/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts index c1da58ac..e10c9282 100644 --- a/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts +++ b/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.spec.ts @@ -47,4 +47,22 @@ describe('MarkdownCitationReferenceComponent', () => { expect(a.getAttribute('href')).toBe('https://example.com'); expect(a.textContent).toContain('1'); }); + + it('renders (not ) when citation has no URL — bug #197 regression', () => { + // Live Chrome smoke caught: a Pandoc def with bare URL (no brackets) + // produces a Citation with url === undefined. Prior template rendered + // which is a broken link. Fix renders . + const fixture = TestBed.createComponent(HostComponent); + const svc = fixture.debugElement.injector.get(CitationsResolverService); + svc.message.set({ + id: 'm1', role: 'assistant', content: 'x', + citations: [{ id: 'src1', index: 1, title: 'Source title only, no URL' }], + }); + fixture.componentInstance.node.set(makeNode('src1', 1, true)); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('a.chat-citation-marker')).toBeNull(); + const span = fixture.nativeElement.querySelector('span.chat-citation-marker--no-url'); + expect(span).toBeTruthy(); + expect(span.textContent).toContain('1'); + }); }); diff --git a/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts b/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts index 8e11969c..42951037 100644 --- a/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts +++ b/libs/chat/src/lib/markdown/views/markdown-citation-reference.component.ts @@ -10,12 +10,19 @@ import { CitationsResolverService } from '../citations-resolver.service'; changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (resolved(); as r) { - - [{{ node().index }}] - + @if (r.citation.url; as href) { + + [{{ node().index }}] + + } @else { + + [{{ node().index }}] + + } } @else { diff --git a/libs/chat/src/lib/markdown/views/markdown-table.component.spec.ts b/libs/chat/src/lib/markdown/views/markdown-table.component.spec.ts index edcfb3ff..932b1fb9 100644 --- a/libs/chat/src/lib/markdown/views/markdown-table.component.spec.ts +++ b/libs/chat/src/lib/markdown/views/markdown-table.component.spec.ts @@ -61,4 +61,29 @@ describe('MarkdownTableComponent', () => { expect(fixture.nativeElement.querySelector('thead')).toBeTruthy(); expect(fixture.nativeElement.querySelector('tbody')).toBeTruthy(); }); + + it('dispatches each row through chat-md-table-row component', () => { + // Regression: prior impl used which + // walked row.children (cells) directly and skipped the row wrapper. Cells + // appeared bare under /, no elements + // existed. Live browser smoke caught this; the test below pins the fix. + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.node.set(makeTableNode({ + alignments: [null, null], + children: [ + { id: 2, type: 'table-row', status: 'complete', parent: null, index: 0, + isHeader: true, children: [] } as never, + { id: 3, type: 'table-row', status: 'complete', parent: null, index: 1, + isHeader: false, children: [] } as never, + { id: 4, type: 'table-row', status: 'complete', parent: null, index: 2, + isHeader: false, children: [] } as never, + ], + })); + fixture.detectChanges(); + const rows = fixture.nativeElement.querySelectorAll('chat-md-table-row'); + expect(rows.length).toBe(3); + // Header row goes in ; body rows in . + expect(fixture.nativeElement.querySelectorAll('thead chat-md-table-row').length).toBe(1); + expect(fixture.nativeElement.querySelectorAll('tbody chat-md-table-row').length).toBe(2); + }); }); diff --git a/libs/chat/src/lib/markdown/views/markdown-table.component.ts b/libs/chat/src/lib/markdown/views/markdown-table.component.ts index 71b661cf..96f3182a 100644 --- a/libs/chat/src/lib/markdown/views/markdown-table.component.ts +++ b/libs/chat/src/lib/markdown/views/markdown-table.component.ts @@ -2,23 +2,23 @@ // SPDX-License-Identifier: MIT import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; import type { MarkdownTableNode, MarkdownTableRowNode } from '@cacheplane/partial-markdown'; -import { MarkdownChildrenComponent } from '../markdown-children.component'; +import { MarkdownTableRowComponent } from './markdown-table-row.component'; @Component({ selector: 'chat-md-table', standalone: true, - imports: [MarkdownChildrenComponent], + imports: [MarkdownTableRowComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (headerRow(); as row) { - + } @for (row of bodyRows(); track $any(row)) { - + }
diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts index 90c53a36..73d512c6 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.spec.ts @@ -64,4 +64,62 @@ describe('ChatCitationsComponent', () => { expect(fixture.nativeElement.querySelector('.custom-card')?.textContent.trim()).toBe('Custom'); expect(fixture.nativeElement.querySelector('.chat-citations-card')).toBeNull(); }); + + it('merges markdown sidecar citations when resolver is available — bug #197 regression', async () => { + // Live Chrome smoke caught: when citations come from Pandoc-formatted + // [^id]: defs in content (no provider metadata), inline markers resolved + // correctly via the markdown sidecar but the sources panel never rendered. + const { CitationsResolverService } = await import('../../markdown/citations-resolver.service'); + @Component({ + standalone: true, + imports: [ChatCitationsComponent], + providers: [CitationsResolverService], + template: ``, + }) + class ResolverHost { + message: Message = msg(undefined); // no provider citations + } + const fixture = TestBed.createComponent(ResolverHost); + const resolver = fixture.debugElement.injector.get(CitationsResolverService); + resolver.markdownDefs.set(new Map([ + ['src1', { + id: 'src1', index: 1, status: 'complete', + children: [ + { id: 1, type: 'text', status: 'complete', parent: null, index: 0, text: 'Wikipedia ' }, + { id: 2, type: 'autolink', status: 'complete', parent: null, index: 1, + url: 'https://en.wikipedia.org/wiki/Coral_reef', + text: 'https://en.wikipedia.org/wiki/Coral_reef' }, + ], + } as never], + ])); + fixture.detectChanges(); + const cards = fixture.nativeElement.querySelectorAll('.chat-citations-card'); + expect(cards.length).toBe(1); + expect(fixture.nativeElement.textContent).toContain('Wikipedia'); + }); + + it('Message.citations takes precedence over markdown sidecar when ids overlap', async () => { + const { CitationsResolverService } = await import('../../markdown/citations-resolver.service'); + @Component({ + standalone: true, + imports: [ChatCitationsComponent], + providers: [CitationsResolverService], + template: ``, + }) + class PrecedenceHost { + message: Message = msg([{ id: 'src1', index: 1, title: 'From message' }]); + } + const fixture = TestBed.createComponent(PrecedenceHost); + const resolver = fixture.debugElement.injector.get(CitationsResolverService); + resolver.markdownDefs.set(new Map([ + ['src1', { id: 'src1', index: 1, status: 'complete', + children: [{ id: 1, type: 'text', status: 'complete', parent: null, index: 0, text: 'From markdown' }], + } as never], + ])); + fixture.detectChanges(); + const cards = fixture.nativeElement.querySelectorAll('.chat-citations-card'); + expect(cards.length).toBe(1); + expect(fixture.nativeElement.textContent).toContain('From message'); + expect(fixture.nativeElement.textContent).not.toContain('From markdown'); + }); }); diff --git a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts index dd00753f..a135cf39 100644 --- a/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts +++ b/libs/chat/src/lib/primitives/chat-citations/chat-citations.component.ts @@ -8,6 +8,7 @@ import { NgTemplateOutlet } from '@angular/common'; import type { Message } from '../../agent/message'; import type { Citation } from '../../agent/citation'; import { ChatCitationsCardComponent } from './chat-citations-card.component'; +import { CitationsResolverService, mdDefToCitation } from '../../markdown/citations-resolver.service'; /** * ContentChild template directive for custom citation card rendering. @@ -48,8 +49,33 @@ export class ChatCitationsComponent { @ContentChild(ChatCitationCardTemplateDirective) cardTpl: ChatCitationCardTemplateDirective | null = null; + /** + * Optional resolver — present when chat-citations is rendered inside a + * chat-message that provides CitationsResolverService (the standard + * placement). When absent, the panel reads only Message.citations. + */ + private readonly resolver = inject(CitationsResolverService, { optional: true }); + + /** + * Combined citation list: + * 1. Message.citations (provider-populated, takes precedence by id) + * 2. Markdown sidecar defs (Pandoc-formatted [^id]: lines), merged in + * for any id not already present. + * + * Sorted by index ascending. This guarantees the sources panel surfaces + * citations whether they come from message metadata, content syntax, or + * both — matching the same precedence as inline-marker resolution. + */ protected readonly citations = computed(() => { - const list = this.message().citations ?? []; - return [...list].sort((a, b) => a.index - b.index); + const fromMessage = this.message().citations ?? []; + const seenIds = new Set(fromMessage.map(c => c.id)); + const fromMarkdown: Citation[] = []; + const mdDefs = this.resolver?.markdownDefs(); + if (mdDefs) { + for (const def of mdDefs.values()) { + if (!seenIds.has(def.id)) fromMarkdown.push(mdDefToCitation(def)); + } + } + return [...fromMessage, ...fromMarkdown].sort((a, b) => a.index - b.index); }); } diff --git a/libs/chat/src/lib/styles/chat-markdown.styles.ts b/libs/chat/src/lib/styles/chat-markdown.styles.ts index efd16183..db75d541 100644 --- a/libs/chat/src/lib/styles/chat-markdown.styles.ts +++ b/libs/chat/src/lib/styles/chat-markdown.styles.ts @@ -114,9 +114,32 @@ export const CHAT_MARKDOWN_STYLES = ` chat-streaming-md chat-md-table { display: contents; } chat-streaming-md chat-md-table-row { display: contents; } chat-streaming-md chat-md-table-cell { display: contents; } - /* Task-list items */ - chat-streaming-md li.chat-md-list-item--task { list-style: none; margin-left: -1.25rem; } - chat-streaming-md li.chat-md-list-item--task > input[type="checkbox"] { margin-right: 0.5rem; vertical-align: middle; } + /* Task-list items: checkbox + first paragraph render inline; subsequent + blocks (sub-lists, multi-paragraph items) flow normally below. */ + chat-streaming-md li.chat-md-list-item--task { + list-style: none; + margin-left: -1.25rem; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; + } + chat-streaming-md li.chat-md-list-item--task > input[type="checkbox"] { + margin: 0; + flex: 0 0 auto; + transform: translateY(2px); + } + /* The chat-md-children wrapper around list-item content takes remaining width */ + chat-streaming-md li.chat-md-list-item--task > chat-md-children { + flex: 1 1 auto; + min-width: 0; + } + /* Tight task items: only the FIRST paragraph aligns inline with the + checkbox (margin collapsed). Subsequent paragraphs/blocks keep their + normal vertical spacing so multi-block items render readably. */ + chat-streaming-md li.chat-md-list-item--task > chat-md-children > chat-md-paragraph:first-child > p { + margin: 0; + } /* Media */ chat-streaming-md img { max-width: 100%; height: auto; border-radius: 6px; } diff --git a/libs/cockpit-docs/package.json b/libs/cockpit-docs/package.json index 0c47a0bb..bd79fe6c 100644 --- a/libs/cockpit-docs/package.json +++ b/libs/cockpit-docs/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-docs", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-registry/package.json b/libs/cockpit-registry/package.json index 9485f1a3..09c65328 100644 --- a/libs/cockpit-registry/package.json +++ b/libs/cockpit-registry/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-registry", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-shell/package.json b/libs/cockpit-shell/package.json index 6ce83043..a2e58829 100644 --- a/libs/cockpit-shell/package.json +++ b/libs/cockpit-shell/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-shell", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-testing/package.json b/libs/cockpit-testing/package.json index 7262b7a6..e1ef6b44 100644 --- a/libs/cockpit-testing/package.json +++ b/libs/cockpit-testing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-testing", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-ui/package.json b/libs/cockpit-ui/package.json index bcfa9c4c..3e46e4b6 100644 --- a/libs/cockpit-ui/package.json +++ b/libs/cockpit-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-ui", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/db/package.json b/libs/db/package.json index aee320e6..a14f756e 100644 --- a/libs/db/package.json +++ b/libs/db/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/db", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/design-tokens/package.json b/libs/design-tokens/package.json index e099fb72..5adea484 100644 --- a/libs/design-tokens/package.json +++ b/libs/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/design-tokens", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/example-layouts/package.json b/libs/example-layouts/package.json index 36d93472..4bf750cd 100644 --- a/libs/example-layouts/package.json +++ b/libs/example-layouts/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/example-layouts", - "version": "0.0.23", + "version": "0.0.24", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0" diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index 24f8432f..baf5ea4c 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.23", + "version": "0.0.24", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/licensing/package.json b/libs/licensing/package.json index e7c4a292..b8f803e8 100644 --- a/libs/licensing/package.json +++ b/libs/licensing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/licensing", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git", diff --git a/libs/partial-json/package.json b/libs/partial-json/package.json index 84d51e1b..a3285d05 100644 --- a/libs/partial-json/package.json +++ b/libs/partial-json/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/partial-json", - "version": "0.0.23", + "version": "0.0.24", "deprecated": "Replaced by @cacheplane/partial-json. No further versions will be published from this package.", "license": "MIT", "repository": { diff --git a/libs/render/package.json b/libs/render/package.json index b79ca2cc..08f2fbcb 100644 --- a/libs/render/package.json +++ b/libs/render/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/render", - "version": "0.0.23", + "version": "0.0.24", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", diff --git a/libs/ui-react/package.json b/libs/ui-react/package.json index 77f00678..3667e703 100644 --- a/libs/ui-react/package.json +++ b/libs/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ui-react", - "version": "0.0.23", + "version": "0.0.24", "license": "MIT", "repository": { "type": "git",