Skip to content

Commit d6f1726

Browse files
authored
fix(chat): keep streamed table rows attached
1 parent d8528a9 commit d6f1726

6 files changed

Lines changed: 84 additions & 28 deletions

File tree

libs/chat/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@cacheplane/partial-json": ">=0.1.1 <0.3.0",
13-
"@cacheplane/partial-markdown": "^0.5.3"
13+
"@cacheplane/partial-markdown": "^0.5.4"
1414
},
1515
"peerDependencies": {
1616
"zod": "^3.25.0",

libs/chat/src/lib/markdown/views/markdown-table.component.spec.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
44
import { TestBed } from '@angular/core/testing';
55
import { Component, signal } from '@angular/core';
66
import { views } from '@threadplane/render';
7-
import type { MarkdownTableNode } from '@cacheplane/partial-markdown';
7+
import type { MarkdownTableCellNode, MarkdownTableNode, MarkdownTableRowNode } from '@cacheplane/partial-markdown';
88
import { MarkdownTableComponent } from './markdown-table.component';
99
import { MarkdownTableRowComponent } from './markdown-table-row.component';
1010
import { MarkdownTableCellComponent } from './markdown-table-cell.component';
@@ -20,6 +20,28 @@ function makeTableNode(overrides: Partial<MarkdownTableNode> = {}): MarkdownTabl
2020
} as MarkdownTableNode;
2121
}
2222

23+
function makeCellNode(id: number, alignment: MarkdownTableCellNode['alignment'] = null): MarkdownTableCellNode {
24+
return {
25+
id, type: 'table-cell', status: 'complete',
26+
parent: null, index: null,
27+
alignment,
28+
children: [],
29+
} as MarkdownTableCellNode;
30+
}
31+
32+
function makeRowNode(
33+
id: number,
34+
isHeader: boolean,
35+
children: MarkdownTableRowNode['children'] = [makeCellNode(id * 10), makeCellNode(id * 10 + 1)],
36+
): MarkdownTableRowNode {
37+
return {
38+
id, type: 'table-row', status: 'complete',
39+
parent: null, index: null,
40+
isHeader,
41+
children,
42+
} as MarkdownTableRowNode;
43+
}
44+
2345
@Component({
2446
standalone: true,
2547
imports: [MarkdownTableComponent],
@@ -62,28 +84,27 @@ describe('MarkdownTableComponent', () => {
6284
expect(fixture.nativeElement.querySelector('tbody')).toBeTruthy();
6385
});
6486

65-
it('dispatches each row through chat-md-table-row component', () => {
66-
// Regression: prior impl used <chat-md-children [parent]="row"> which
67-
// walked row.children (cells) directly and skipped the row wrapper. Cells
68-
// appeared bare under <thead>/<tbody>, no <chat-md-table-row> elements
69-
// existed. Live browser smoke caught this; the test below pins the fix.
87+
it('renders native table rows and cells directly under table sections', () => {
88+
// Keep the browser's table layout tree native. Custom element hosts between
89+
// <thead>/<tbody> and <tr>, or between <tr> and <td>/<th>, rely on
90+
// display: contents and can make a just-streamed row appear detached.
7091
const fixture = TestBed.createComponent(HostComponent);
7192
fixture.componentInstance.node.set(makeTableNode({
7293
alignments: [null, null],
7394
children: [
74-
{ id: 2, type: 'table-row', status: 'complete', parent: null, index: 0,
75-
isHeader: true, children: [] } as never,
76-
{ id: 3, type: 'table-row', status: 'complete', parent: null, index: 1,
77-
isHeader: false, children: [] } as never,
78-
{ id: 4, type: 'table-row', status: 'complete', parent: null, index: 2,
79-
isHeader: false, children: [] } as never,
95+
makeRowNode(2, true),
96+
makeRowNode(3, false),
97+
makeRowNode(4, false),
8098
],
8199
}));
82100
fixture.detectChanges();
83-
const rows = fixture.nativeElement.querySelectorAll('chat-md-table-row');
84-
expect(rows.length).toBe(3);
85-
// Header row goes in <thead>; body rows in <tbody>.
86-
expect(fixture.nativeElement.querySelectorAll('thead chat-md-table-row').length).toBe(1);
87-
expect(fixture.nativeElement.querySelectorAll('tbody chat-md-table-row').length).toBe(2);
101+
const table = fixture.nativeElement.querySelector('table') as HTMLTableElement;
102+
103+
expect(table.querySelectorAll(':scope > thead > tr').length).toBe(1);
104+
expect(table.querySelectorAll(':scope > tbody > tr').length).toBe(2);
105+
expect(table.querySelectorAll('chat-md-table-row').length).toBe(0);
106+
expect(table.querySelectorAll('chat-md-table-cell').length).toBe(0);
107+
expect(table.querySelectorAll(':scope > thead > tr > th').length).toBe(2);
108+
expect(table.querySelectorAll(':scope > tbody > tr > td').length).toBe(4);
88109
});
89110
});

libs/chat/src/lib/markdown/views/markdown-table.component.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,35 @@
22
// SPDX-License-Identifier: MIT
33
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
44
import type { MarkdownTableNode, MarkdownTableRowNode } from '@cacheplane/partial-markdown';
5-
import { MarkdownTableRowComponent } from './markdown-table-row.component';
5+
import { MarkdownChildrenComponent } from '../markdown-children.component';
66

77
@Component({
88
selector: 'chat-md-table',
99
standalone: true,
10-
imports: [MarkdownTableRowComponent],
10+
imports: [MarkdownChildrenComponent],
1111
changeDetection: ChangeDetectionStrategy.OnPush,
1212
template: `
1313
<table class="chat-md-table">
1414
<thead>
1515
@if (headerRow(); as row) {
16-
<chat-md-table-row [node]="row" />
16+
<tr class="chat-md-table-row chat-md-table-row--header">
17+
@for (cell of row.children; track $index) {
18+
<th class="chat-md-table-cell" [style.text-align]="cell.alignment ?? null">
19+
<chat-md-children [parent]="cell" />
20+
</th>
21+
}
22+
</tr>
1723
}
1824
</thead>
1925
<tbody>
2026
@for (row of bodyRows(); track $index) {
21-
<chat-md-table-row [node]="row" />
27+
<tr class="chat-md-table-row">
28+
@for (cell of row.children; track $index) {
29+
<td class="chat-md-table-cell" [style.text-align]="cell.alignment ?? null">
30+
<chat-md-children [parent]="cell" />
31+
</td>
32+
}
33+
</tr>
2234
}
2335
</tbody>
2436
</table>

libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,27 @@ describe('ChatStreamingMdComponent — streaming table rendering', () => {
110110
).toBe(false);
111111
}
112112
});
113+
114+
it('keeps a finalized partial body row in the table when the stream pauses', () => {
115+
vi.useFakeTimers();
116+
try {
117+
host.streaming.set(false);
118+
grow(
119+
'| Name | Mental model | When to use |\n' +
120+
'| --- | --- | --- |\n' +
121+
'| Angular signals | Fine-grained values | Local state |\n' +
122+
'| RxJS (Observables) [',
123+
);
124+
vi.advanceTimersByTime(650);
125+
fixture.detectChanges();
126+
expect(el.querySelectorAll('table').length).toBe(1);
127+
expect(el.querySelectorAll('tbody tr').length).toBe(2);
128+
expect(
129+
[...el.querySelectorAll('p')].some((p) => (p.textContent || '').includes('| RxJS')),
130+
'no raw-pipe paragraph after finalizing a partial body row',
131+
).toBe(false);
132+
} finally {
133+
vi.useRealTimers();
134+
}
135+
});
113136
});

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@
113113
"@angular/platform-browser": "~21.1.0",
114114
"@angular/router": "~21.1.0",
115115
"@cacheplane/partial-json": "^0.1.1",
116-
"@cacheplane/partial-markdown": "^0.5.0",
116+
"@cacheplane/partial-markdown": "^0.5.4",
117117
"@langchain/core": "^1.1.33",
118118
"@langchain/langgraph-sdk": "^1.7.4",
119119
"@neondatabase/serverless": "^0.10.0",

0 commit comments

Comments
 (0)