Skip to content

Commit 905e101

Browse files
committed
fix(chat): align streamed markdown table columns
1 parent 358c0ff commit 905e101

3 files changed

Lines changed: 62 additions & 7 deletions

File tree

examples/chat/angular/e2e/markdown-surfaces.spec.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
import { test, expect } from '@playwright/test';
2+
import { test, expect, type Locator } from '@playwright/test';
33
import { sendPromptAndWait } from './test-helpers';
44

55
test('heading: assistant bubble renders an <h1>', async ({ page }) => {
@@ -43,6 +43,7 @@ test('markdown checklist matrix: rich markdown renders with escaped html', async
4343
await expect(bubble.locator('table')).toBeVisible();
4444
await expect(bubble.locator('thead th')).toHaveText(['Name', 'Mental model', 'When to use']);
4545
await expect(bubble.locator('tbody tr')).toHaveCount(2);
46+
await expect.poll(async () => tableColumnsAlign(bubble)).toBe(true);
4647
await expect(bubble.locator('blockquote')).toBeVisible();
4748
await expect(bubble).toContainText('This is a blockquote.');
4849
await expect(bubble.locator('a', { hasText: 'Angular' })).toHaveAttribute(
@@ -53,3 +54,21 @@ test('markdown checklist matrix: rich markdown renders with escaped html', async
5354
await expect(bubble.locator('script')).toHaveCount(0);
5455
await expect(bubble).toContainText("<script>alert('xss')</script>");
5556
});
57+
58+
async function tableColumnsAlign(bubble: Locator): Promise<boolean> {
59+
const table = bubble.locator('table').first();
60+
return table.evaluate((el) => {
61+
const headerCells = Array.from(el.querySelectorAll('thead th'));
62+
const rows = Array.from(el.querySelectorAll('tbody tr'));
63+
if (headerCells.length === 0 || rows.length === 0) return false;
64+
65+
const headerLefts = headerCells.map((cell) => cell.getBoundingClientRect().left);
66+
return rows.every((row) => {
67+
const cells = Array.from(row.querySelectorAll('td'));
68+
if (cells.length !== headerLefts.length) return false;
69+
return cells.every((cell, index) => (
70+
Math.abs(cell.getBoundingClientRect().left - headerLefts[index]) <= 1
71+
));
72+
});
73+
});
74+
}

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

Lines changed: 23 additions & 1 deletion
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 { MarkdownTableRowNode } from '@cacheplane/partial-markdown';
7+
import type { MarkdownTableCellNode, MarkdownTableRowNode } from '@cacheplane/partial-markdown';
88
import { MarkdownTableRowComponent } from './markdown-table-row.component';
99
import { MarkdownTableCellComponent } from './markdown-table-cell.component';
1010
import { MARKDOWN_VIEW_REGISTRY } from '../markdown-view-registry';
@@ -18,6 +18,15 @@ function makeRowNode(isHeader: boolean, children: MarkdownTableRowNode['children
1818
} as MarkdownTableRowNode;
1919
}
2020

21+
function makeCellNode(id: number): MarkdownTableCellNode {
22+
return {
23+
id, type: 'table-cell', status: 'complete',
24+
parent: null, index: null,
25+
alignment: null,
26+
children: [],
27+
} as MarkdownTableCellNode;
28+
}
29+
2130
@Component({
2231
standalone: true,
2332
imports: [MarkdownTableRowComponent],
@@ -64,4 +73,17 @@ describe('MarkdownTableRowComponent', () => {
6473
const tr = fixture.nativeElement.querySelector('tr');
6574
expect(tr.classList.contains('chat-md-table-row--header')).toBe(true);
6675
});
76+
77+
it('renders table-cell components directly under the table row', () => {
78+
const fixture = TestBed.createComponent(HostComponent);
79+
fixture.componentInstance.node.set(makeRowNode(false, [
80+
makeCellNode(3),
81+
makeCellNode(4),
82+
] as MarkdownTableRowNode['children']));
83+
fixture.detectChanges();
84+
85+
const tr = fixture.nativeElement.querySelector('tr');
86+
expect(tr.querySelector(':scope > chat-md-children')).toBeNull();
87+
expect(tr.querySelectorAll(':scope > chat-md-table-cell').length).toBe(2);
88+
});
6789
});

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

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
// libs/chat/src/lib/markdown/views/markdown-table-row.component.ts
22
// SPDX-License-Identifier: MIT
3-
import { Component, ChangeDetectionStrategy, input, computed, inject } from '@angular/core';
4-
import type { MarkdownTableRowNode } from '@cacheplane/partial-markdown';
5-
import { MarkdownChildrenComponent } from '../markdown-children.component';
3+
import { NgComponentOutlet } from '@angular/common';
4+
import { Component, ChangeDetectionStrategy, input, computed, inject, type Type } from '@angular/core';
5+
import type { ViewRegistry } from '@threadplane/render';
6+
import type { MarkdownNode, MarkdownTableRowNode } from '@cacheplane/partial-markdown';
7+
import { MARKDOWN_VIEW_REGISTRY } from '../markdown-view-registry';
68
import { IS_HEADER_ROW } from '../markdown-table-row.token';
79

810
@Component({
911
selector: 'chat-md-table-row',
1012
standalone: true,
11-
imports: [MarkdownChildrenComponent],
13+
imports: [NgComponentOutlet],
1214
changeDetection: ChangeDetectionStrategy.OnPush,
1315
template: `
1416
<tr class="chat-md-table-row" [class.chat-md-table-row--header]="node().isHeader">
15-
<chat-md-children [parent]="node()" />
17+
@for (child of node().children; track $index) {
18+
@let comp = resolve(child);
19+
@if (comp) {
20+
<ng-container *ngComponentOutlet="comp; inputs: { node: child }" />
21+
}
22+
}
1623
</tr>
1724
`,
1825
providers: [
@@ -27,4 +34,11 @@ import { IS_HEADER_ROW } from '../markdown-table-row.token';
2734
})
2835
export class MarkdownTableRowComponent {
2936
readonly node = input.required<MarkdownTableRowNode>();
37+
private readonly registry = inject<ViewRegistry>(MARKDOWN_VIEW_REGISTRY);
38+
39+
protected resolve(child: MarkdownNode): Type<unknown> | null {
40+
const entry = this.registry[child.type];
41+
if (!entry) return null;
42+
return typeof entry === 'function' ? entry : entry.component;
43+
}
3044
}

0 commit comments

Comments
 (0)