diff --git a/libs/chat/package.json b/libs/chat/package.json index 0e0889bd9..1c573f90f 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -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", 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 6490bdec6..335e9e6ad 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 @@ -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'; @@ -20,6 +20,28 @@ function makeTableNode(overrides: Partial = {}): 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], @@ -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 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. + it('renders native table rows and cells directly under table sections', () => { + // Keep the browser's table layout tree native. Custom element hosts between + // / and , or between and /, 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 ; 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); + 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); }); }); 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 4da1911ad..b872865a0 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,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: ` @if (headerRow(); as row) { - + + @for (cell of row.children; track $index) { + + } + } @for (row of bodyRows(); track $index) { - + + @for (cell of row.children; track $index) { + + } + }
+ +
+ +
diff --git a/libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts index cb1282a5b..aa458bbbb 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.table-stream.spec.ts @@ -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(); + } + }); }); diff --git a/package-lock.json b/package-lock.json index dc93f494a..dc8c09e4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@angular/platform-browser": "~21.1.0", "@angular/router": "~21.1.0", "@cacheplane/partial-json": "^0.1.1", - "@cacheplane/partial-markdown": "^0.5.3", + "@cacheplane/partial-markdown": "^0.5.4", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", "@neondatabase/serverless": "^0.10.0", @@ -207,7 +207,7 @@ "license": "PolyForm-Noncommercial-1.0.0 OR LicenseRef-Threadplane-Commercial", "dependencies": { "@cacheplane/partial-json": ">=0.1.1 <0.3.0", - "@cacheplane/partial-markdown": "^0.5.3" + "@cacheplane/partial-markdown": "^0.5.4" }, "peerDependencies": { "@angular/common": "^20.0.0 || ^21.0.0", @@ -7272,9 +7272,9 @@ "license": "MIT" }, "node_modules/@cacheplane/partial-markdown": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@cacheplane/partial-markdown/-/partial-markdown-0.5.3.tgz", - "integrity": "sha512-XlvjGVsEY7tA3Q4eSY0EiP3FmKkzv5VwFWCmmqvnvf1/dYy+YjQXmDg1mqtFWk4iNyjMMd7aBtGiY+lEuZPmLQ==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@cacheplane/partial-markdown/-/partial-markdown-0.5.4.tgz", + "integrity": "sha512-BUDabdQibR4EsNELxtqPh/fiPVC5igUZiFMv6y+DZYp9u7KvbB9ucal+z4F7y7nOWh6FKmC+BPwMbeHpgLzlbQ==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 99ef732be..ac703fc18 100644 --- a/package.json +++ b/package.json @@ -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",