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
2 changes: 1 addition & 1 deletion libs/a2ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/a2ui",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/ag-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/ag-ui",
"version": "0.0.23",
"version": "0.0.24",
"peerDependencies": {
"@ngaf/chat": "*",
"@ngaf/licensing": "*",
Expand Down
2 changes: 1 addition & 1 deletion libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.23",
"version": "0.0.24",
"exports": {
".": {
"types": "./index.d.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,22 @@ describe('MarkdownCitationReferenceComponent', () => {
expect(a.getAttribute('href')).toBe('https://example.com');
expect(a.textContent).toContain('1');
});

it('renders <span> (not <a>) when citation has no URL — bug #197 regression', () => {
// Live Chrome smoke caught: a Pandoc def with bare URL (no <autolink> brackets)
// produces a Citation with url === undefined. Prior template rendered <a href="">
// which is a broken link. Fix renders <span class="chat-citation-marker--no-url">.
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ import { CitationsResolverService } from '../citations-resolver.service';
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (resolved(); as r) {
<a class="chat-citation-marker"
[href]="r.citation.url ?? null"
[attr.title]="r.citation.snippet ?? r.citation.url ?? null"
target="_blank" rel="noopener noreferrer">
<sup>[{{ node().index }}]</sup>
</a>
@if (r.citation.url; as href) {
<a class="chat-citation-marker"
[attr.href]="href"
[attr.title]="r.citation.snippet ?? href"
target="_blank" rel="noopener noreferrer">
<sup>[{{ node().index }}]</sup>
</a>
} @else {
<span class="chat-citation-marker chat-citation-marker--no-url"
[attr.title]="r.citation.snippet ?? r.citation.title ?? null">
<sup>[{{ node().index }}]</sup>
</span>
}
} @else {
<span class="chat-citation-marker chat-citation-marker--unresolved"
[attr.title]="'No source available'">
Expand Down
25 changes: 25 additions & 0 deletions libs/chat/src/lib/markdown/views/markdown-table.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <chat-md-children [parent]="row"> which
// walked row.children (cells) directly and skipped the row wrapper. Cells
// appeared bare under <thead>/<tbody>, no <chat-md-table-row> 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 <thead>; body rows in <tbody>.
expect(fixture.nativeElement.querySelectorAll('thead chat-md-table-row').length).toBe(1);
expect(fixture.nativeElement.querySelectorAll('tbody chat-md-table-row').length).toBe(2);
});
});
8 changes: 4 additions & 4 deletions libs/chat/src/lib/markdown/views/markdown-table.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<table class="chat-md-table">
<thead>
@if (headerRow(); as row) {
<chat-md-children [parent]="row" />
<chat-md-table-row [node]="row" />
}
</thead>
<tbody>
@for (row of bodyRows(); track $any(row)) {
<chat-md-children [parent]="row" />
<chat-md-table-row [node]="row" />
}
</tbody>
</table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<chat-citations [message]="message" />`,
})
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: `<chat-citations [message]="message" />`,
})
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Citation[]>(() => {
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);
});
}
29 changes: 26 additions & 3 deletions libs/chat/src/lib/styles/chat-markdown.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-docs",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-registry/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-registry",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-shell/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-shell",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-testing/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-testing",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-ui",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/db/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/db",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/design-tokens/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/design-tokens",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/example-layouts/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion libs/langgraph/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/langgraph",
"version": "0.0.23",
"version": "0.0.24",
"peerDependencies": {
"@ngaf/chat": "*",
"@ngaf/licensing": "*",
Expand Down
2 changes: 1 addition & 1 deletion libs/licensing/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/licensing",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/partial-json/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion libs/render/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion libs/ui-react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/ui-react",
"version": "0.0.23",
"version": "0.0.24",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
Loading