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/chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"dependencies": {
"@cacheplane/partial-json": ">=0.1.1 <0.3.0",
"@cacheplane/partial-markdown": "^0.5.3"
"@cacheplane/partial-markdown": "^0.5.4"
},
"peerDependencies": {
"zod": "^3.25.0",
Expand Down
55 changes: 38 additions & 17 deletions libs/chat/src/lib/markdown/views/markdown-table.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { views } from '@threadplane/render';
import type { MarkdownTableNode } from '@cacheplane/partial-markdown';
import type { MarkdownTableCellNode, MarkdownTableNode, MarkdownTableRowNode } from '@cacheplane/partial-markdown';
import { MarkdownTableComponent } from './markdown-table.component';
import { MarkdownTableRowComponent } from './markdown-table-row.component';
import { MarkdownTableCellComponent } from './markdown-table-cell.component';
Expand All @@ -20,6 +20,28 @@ function makeTableNode(overrides: Partial<MarkdownTableNode> = {}): MarkdownTabl
} as MarkdownTableNode;
}

function makeCellNode(id: number, alignment: MarkdownTableCellNode['alignment'] = null): MarkdownTableCellNode {
return {
id, type: 'table-cell', status: 'complete',
parent: null, index: null,
alignment,
children: [],
} as MarkdownTableCellNode;
}

function makeRowNode(
id: number,
isHeader: boolean,
children: MarkdownTableRowNode['children'] = [makeCellNode(id * 10), makeCellNode(id * 10 + 1)],
): MarkdownTableRowNode {
return {
id, type: 'table-row', status: 'complete',
parent: null, index: null,
isHeader,
children,
} as MarkdownTableRowNode;
}

@Component({
standalone: true,
imports: [MarkdownTableComponent],
Expand Down Expand Up @@ -62,28 +84,27 @@ describe('MarkdownTableComponent', () => {
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.
it('renders native table rows and cells directly under table sections', () => {
// Keep the browser's table layout tree native. Custom element hosts between
// <thead>/<tbody> and <tr>, or between <tr> and <td>/<th>, rely on
// display: contents and can make a just-streamed row appear detached.
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,
makeRowNode(2, true),
makeRowNode(3, false),
makeRowNode(4, false),
],
}));
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);
const table = fixture.nativeElement.querySelector('table') as HTMLTableElement;

expect(table.querySelectorAll(':scope > thead > tr').length).toBe(1);
expect(table.querySelectorAll(':scope > tbody > tr').length).toBe(2);
expect(table.querySelectorAll('chat-md-table-row').length).toBe(0);
expect(table.querySelectorAll('chat-md-table-cell').length).toBe(0);
expect(table.querySelectorAll(':scope > thead > tr > th').length).toBe(2);
expect(table.querySelectorAll(':scope > tbody > tr > td').length).toBe(4);
});
});
20 changes: 16 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,35 @@
// SPDX-License-Identifier: MIT
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
import type { MarkdownTableNode, MarkdownTableRowNode } from '@cacheplane/partial-markdown';
import { MarkdownTableRowComponent } from './markdown-table-row.component';
import { MarkdownChildrenComponent } from '../markdown-children.component';

@Component({
selector: 'chat-md-table',
standalone: true,
imports: [MarkdownTableRowComponent],
imports: [MarkdownChildrenComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<table class="chat-md-table">
<thead>
@if (headerRow(); as row) {
<chat-md-table-row [node]="row" />
<tr class="chat-md-table-row chat-md-table-row--header">
@for (cell of row.children; track $index) {
<th class="chat-md-table-cell" [style.text-align]="cell.alignment ?? null">
<chat-md-children [parent]="cell" />
</th>
}
</tr>
}
</thead>
<tbody>
@for (row of bodyRows(); track $index) {
<chat-md-table-row [node]="row" />
<tr class="chat-md-table-row">
@for (cell of row.children; track $index) {
<td class="chat-md-table-cell" [style.text-align]="cell.alignment ?? null">
<chat-md-children [parent]="cell" />
</td>
}
</tr>
Comment on lines 26 to +33

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.

track $index instead of stable node ID

Both @for loops use track $index. The parser nodes have a stable id field, so tracking by index means Angular tears down and recreates <tr>/<td> DOM nodes whenever a row is inserted before an existing one. During streaming, rows are appended at the tail so this is low-risk in practice — but streaming partial rows can shift a cell's index mid-update.

Suggested change
@for (row of bodyRows(); track $index) {
<chat-md-table-row [node]="row" />
<tr class="chat-md-table-row">
@for (cell of row.children; track $index) {
<td class="chat-md-table-cell" [style.text-align]="cell.alignment ?? null">
<chat-md-children [parent]="cell" />
</td>
}
</tr>
@for (row of bodyRows(); track row.id) {
<tr class="chat-md-table-row">
@for (cell of row.children; track cell.id) {
<td class="chat-md-table-cell" [style.text-align]="cell.alignment ?? null">
<chat-md-children [parent]="cell" />
</td>
}
</tr>

(Same applies to the header @for on line 17: track cell.id.)

}
</tbody>
</table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,27 @@ describe('ChatStreamingMdComponent — streaming table rendering', () => {
).toBe(false);
}
});

it('keeps a finalized partial body row in the table when the stream pauses', () => {
vi.useFakeTimers();
try {
host.streaming.set(false);
grow(
'| Name | Mental model | When to use |\n' +
'| --- | --- | --- |\n' +
'| Angular signals | Fine-grained values | Local state |\n' +
'| RxJS (Observables) [',
);
vi.advanceTimersByTime(650);
fixture.detectChanges();
expect(el.querySelectorAll('table').length).toBe(1);
expect(el.querySelectorAll('tbody tr').length).toBe(2);
expect(
[...el.querySelectorAll('p')].some((p) => (p.textContent || '').includes('| RxJS')),
'no raw-pipe paragraph after finalizing a partial body row',
).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
"@angular/platform-browser": "~21.1.0",
"@angular/router": "~21.1.0",
"@cacheplane/partial-json": "^0.1.1",
"@cacheplane/partial-markdown": "^0.5.0",
"@cacheplane/partial-markdown": "^0.5.4",
"@langchain/core": "^1.1.33",
"@langchain/langgraph-sdk": "^1.7.4",
"@neondatabase/serverless": "^0.10.0",
Expand Down
Loading