diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml new file mode 100644 index 0000000000..19561bb6f0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md +2026-08-19-web-markdown-wide-table-view.md: 847b3f055edd29eeb0b6f3f8bec5b97252aca4f4 +2026-08-19-web-markdown-wide-table-view.zh.md: d2fcc18f883f71a3fdbfbab6476c87b1c9840b21 diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md new file mode 100644 index 0000000000..847b3f055e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md @@ -0,0 +1,37 @@ +# Agent Note: Web markdown tables fill the column by count, wide ones break out + +Status: implemented + +English | [中文](2026-08-19-web-markdown-wide-table-view.zh.md) + +## Problem + +`MarkdownText` rendered every GFM table at its natural width (`.tableScroll table { width: max-content; max-width: max-content }`, `packages/client/ui-primitives/src/markdown/MarkdownText.module.css`), so any table wider than the 748px message column could only be read through horizontal scrolling. A three-column table whose cells could comfortably wrap still forced a scroll, and a genuinely wide table could never use more than the message column even when the transcript around it had hundreds of spare pixels. Issue #1761 (with external feedback dsh-external/issues#520) asks for wrap-first adaptation and a wider view for tables that need it. The deepsuite chat product solved the same problem CSS-first; per review direction this change mirrors that solution instead of the interactive expand-dialog approach first drafted here. + +## Decision + +**Column count picks the sizing arm, statically, in the renderer.** `renderTable` reads the parsed column count (the `align` array, header-row fallback): tables under four columns — and any table inside a blockquote (`inBlockquote` render-context flag) — get the module's `tableFill` class, `table { width: 100%; max-width: none }`, filling the column with cells wrapping down to their existing `min-width: 100px` floor. Four-or-more-column tables keep the natural-width rules and instead carry the stable global hook class `md-table-wide` (the `md-code-block` precedent), so a hosting layout can widen them. This is deepsuite chat's discriminator (`.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))` plus its blockquote exemption) computed in the renderer, which already knows the column count. No measurement, observers, or interaction state anywhere. + +**The chat transcript widens hooked tables with container-query CSS.** ChatView's `.scroll` declares `container-type: inline-size`, and `AssistantMarkdown.module.css` gives `.body :global(.md-table-wide)` the breakout: `--dsh-table-spare` is the per-side spare width `max(0px, (100cqw - --dsh-chat-content-width) / 2)`, `--dsh-table-lead` adds the wrapper's own indent (`min(--dsh-chat-content-width, 100cqw) - 100%`), and width/negative-margin/lead-padding combine so the wrapper's scroll area spans the full transcript while the table content keeps starting at the message column's left edge. `100cqw` is the CSS stand-in for deepsuite chat's JS-measured `--dsl-virtual-list-width`; the `max(0px, …)` clamp replaces its below-SM JS gate, degrading continuously to the plain in-column scroll when the transcript is narrower than the message column. Scoping the rule under `AssistantMarkdown .body` keeps tool cards, compaction rows, and every other `MarkdownText` surface at plain in-column behavior. + +**The parity fixtures change deliberately.** The table-containing markdown-dom fixtures pin the discriminator: `tableScroll tableFill` for narrow and blockquote tables, `tableScroll md-table-wide` for wide ones, and a new `table-wide-and-blockquote` corpus document pins both arms of the blockquote exemption. + +## Alternatives considered + +**Overflow-measured chrome: a ResizeObserver-gated expand entry opening the table in a `Modal` wide view.** Implemented first, then rejected on review direction in favor of deepsuite chat parity: the CSS solution needs no per-table observers, no dialog state that the streaming finalize swap would drop, no label plumbing through the cordis-free package, and gives the wide view permanently instead of behind an interaction. + +**`:has()`-based column counting in CSS, as deepsuite chat does.** Rejected: their wrapper is generic while this renderer already walks the table node, so the count is available statically; a class is cheaper than a `:has()` selector re-evaluated on DOM changes and pins the decision in the DOM for fixtures. + +**Breaking out to the viewport rather than the transcript.** Rejected: the conversation column pins `overflow-x: hidden` (the one-axis contract in `apps/web/tests/conversation-column-overflow.e2e.ts`), and anything wider than the transcript box would clip; the transcript width is exactly the space the layout actually has. + +## Consequences + +An ordinary wide table reads in place with wrapped cells; a many-column table keeps its readable natural width, spans the whole transcript where the layout has spare width, and scrolls for the remainder — with no interaction required and nothing to restore. Chromium keyboard-focuses scrollable containers by default, so the wrapper stays keyboard-scrollable without added attributes (a `:focus-visible` ring marks it). Two knowledge edges: `container-type: inline-size` on ChatView's `.scroll` makes it the nearest query container for anything inside the transcript that later uses container units, and sub-four-column tables now always stretch to the full column width (deepsuite chat behavior) rather than shrink-wrapping short content. + +## Testing + +The markdown-dom parity fixtures pin the wrapper classes per arm, including the new `table-wide-and-blockquote` document; `markdown-render-units.client.spec.tsx` covers the hand-built rowless/align-less fallback. `apps/web/tests/markdown-wide-table.e2e.ts` seeds a closed turn with three tables (three-column fill, twelve-column wide, long-token/CJK long-cell) and, in real Chromium, pins the relations golden across viewport stops — fill and long-cell tables fill the column with no residual scroll at every stop and grow taller as the column narrows, the wide table always scrolls, breaks out past the message column exactly at the stops where the transcript is wider than it, keeps its content left-aligned with the fill table's under the breakout, and clamps to neutral at the narrow stop — plus arrow-key scrolling of the focused wrapper, a zoom arm, and a deviceScaleFactor-2 arm that must report the same relations. + +## Related + +- [Web markdown incremental AST renderer](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) — the renderer and DOM-parity contract this change extends. diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md new file mode 100644 index 0000000000..d2fcc18f88 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md @@ -0,0 +1,37 @@ +# Agent Note:Web markdown 表格按列数填充消息列,宽表突破列宽 + +Status: implemented + +[English](2026-08-19-web-markdown-wide-table-view.md) | 中文 + +## 问题 + +`MarkdownText` 把每个 GFM 表格都按自然宽度渲染(`.tableScroll table { width: max-content; max-width: max-content }`,`packages/client/ui-primitives/src/markdown/MarkdownText.module.css`),于是任何比 748px 消息列更宽的表格都只能靠横向滚动阅读。单元格本可以舒适换行的三列表格也被迫滚动;而真正宽的表格即便转录区周围有几百像素的空余,也永远只能用消息列那么宽。Issue #1761(含外部反馈 dsh-external/issues#520)要求先换行适应,并为需要的表格提供更宽的视图。deepsuite chat 产品已用 CSS 优先的方式解决了同一问题;按评审方向,本变更对齐该方案,替换此前起草的交互式展开对话框方案。 + +## 决定 + +**列数在渲染器里静态决定尺寸分支。**`renderTable` 读取解析出的列数(`align` 数组,缺省回退表头行):不足四列的表格——以及 blockquote 内的任何表格(渲染上下文的 `inBlockquote` 标志)——获得模块的 `tableFill` 类,`table { width: 100%; max-width: none }`,填满消息列,单元格按既有 `min-width: 100px` 下限换行。四列及以上的表格保持自然宽度规则,改挂稳定的全局钩子类 `md-table-wide`(沿用 `md-code-block` 先例),供宿主布局加宽。这正是 deepsuite chat 的判别式(`.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))` 及其 blockquote 豁免),只是移到已经掌握列数的渲染器里计算。全程没有测量、observer 或交互状态。 + +**聊天转录区用容器查询 CSS 加宽挂钩表格。**ChatView 的 `.scroll` 声明 `container-type: inline-size`,`AssistantMarkdown.module.css` 给 `.body :global(.md-table-wide)` 定义突破:`--dsh-table-spare` 是单侧空余宽度 `max(0px, (100cqw - --dsh-chat-content-width) / 2)`,`--dsh-table-lead` 再加上包裹层自身的缩进(`min(--dsh-chat-content-width, 100cqw) - 100%`),宽度/负 margin/前导 padding 组合起来,让包裹层的滚动区横跨整个转录区,而表格内容仍从消息列左缘起排。`100cqw` 是 deepsuite chat 用 JS 测量的 `--dsl-virtual-list-width` 的 CSS 等价物;`max(0px, …)` 钳制取代其 below-SM 的 JS 开关,转录区窄于消息列时连续退化为普通列内滚动。规则限定在 `AssistantMarkdown .body` 之下,工具卡片、压缩行等其他 `MarkdownText` 表面保持普通列内行为。 + +**一致性 fixture 的变化是有意的。**含表格的 markdown-dom fixture pin 住判别结果:窄表与 blockquote 表为 `tableScroll tableFill`,宽表为 `tableScroll md-table-wide`;新增的 `table-wide-and-blockquote` 语料文档同时 pin 住 blockquote 豁免的两个分支。 + +## 曾考虑的替代方案 + +**实测溢出的交互件:ResizeObserver 门控的展开入口,用 `Modal` 打开宽视图。**先行实现,后按评审方向否决、改为对齐 deepsuite chat:CSS 方案不需要逐表 observer,没有会被流式定稿替换丢弃的对话框状态,不用穿过 cordis-free 包的文案管道,且宽视图是常驻的而非藏在交互后面。 + +**像 deepsuite chat 那样用 `:has()` 在 CSS 里数列。**否决:它们的包裹层是通用组件,而本渲染器本来就在遍历表格节点,列数是静态可得的;类名比随 DOM 变化反复求值的 `:has()` 选择器更便宜,还把决定固化进 DOM 供 fixture pin 住。 + +**突破到视口宽而不是转录区宽。**否决:会话列 pin 死了 `overflow-x: hidden`(`apps/web/tests/conversation-column-overflow.e2e.ts` 的单轴契约),超出转录区盒子的部分会被裁剪;转录区宽度正是布局实际拥有的空间。 + +## 后果 + +普通宽表原地换行阅读;多列表格保持可读的自然宽度,在布局有空余处横跨整个转录区,剩余部分滚动——无需任何交互,也没有状态要恢复。Chromium 默认让可滚动容器可键盘聚焦,因此包裹层无需附加属性即可键盘滚动(`:focus-visible` 有焦点圈)。两个需要知道的点:ChatView `.scroll` 上的 `container-type: inline-size` 使它成为转录区内后续使用容器单位的最近查询容器;不足四列的表格现在总是拉伸到整列宽(deepsuite chat 行为),而不是按内容收缩。 + +## 测试 + +markdown-dom 一致性 fixture 按分支 pin 住包裹层类名,含新增的 `table-wide-and-blockquote` 文档;`markdown-render-units.client.spec.tsx` 覆盖手工树的无行无 align 兜底。`apps/web/tests/markdown-wide-table.e2e.ts` seed 一个含三个表格的已关闭轮次(三列填充表、十二列宽表、长 token/中文长单元格表),在真实 Chromium 中跨视口档 pin 关系 golden——填充表与长单元格表在每一档都填满消息列、无残余滚动、随列变窄而变高;宽表始终滚动、恰好在转录区宽于消息列的档位突破列宽、突破时内容与填充表左对齐、窄档钳制为中性——外加聚焦包裹层的方向键滚动、缩放分支、以及必须报告相同关系的 deviceScaleFactor-2 分支。 + +## 相关 + +- [Web markdown 增量 AST 渲染器](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) —— 本变更所扩展的渲染器与 DOM 一致性契约。 diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts new file mode 100644 index 0000000000..da2c371085 --- /dev/null +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -0,0 +1,454 @@ +// Web e2e scenario: markdown tables in the message column, deepsuite-chat +// parity. Tables under four columns (and long-cell tables) fill the 748px +// message column and wrap; four-or-more-column tables keep their natural +// width, scroll horizontally inside their wrapper, and — through the +// renderer's `md-table-wide` hook plus AssistantMarkdown's container-query +// breakout — span the whole transcript width instead of clipping at the +// message column, with the table content still starting at the message +// column's left edge. When the transcript is narrower than the message +// column the breakout clamps to neutral and the plain in-column scroll +// remains. +// +// Only a real engine lays out CSS tables and resolves container-query +// units, so the fill/scroll/breakout relations, the lead-padding alignment, +// arrow-key scrolling, and the zoom/DPR arms are all measured in Chromium +// across viewport stops. The golden records relations and booleans, never +// pixels: absolute widths document the platform, not the behavior. +// +// Zero model calls: the transcript is a closed turn assembled through the +// Session API and seeded cold; a stray stream would fail loud with +// NO_ADAPTER. +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-wide-table', import.meta.url)) +const GEOMETRY_EXPECTED = fileURLToPath( + new URL('./snapshots/markdown-wide-table/geometry.expected.md', import.meta.url), +) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-wide-table-web-e2e' +/** Painted into the final paragraph; the open barrier waits for it. */ +const TAIL_MARKER = 'MWT_TABLES_DONE' + +/** + * First-header-cell markers identify each table without depending on CSS + * module hashes or DOM order. + */ +const FILL_MARKER = 'MWT_FILL_C1' +const WIDE_MARKER = 'MWT_WIDE_C01' +const LONG_CELL_MARKER = 'MWT_LONGCELL_F1' +const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER] +/** Golden-facing names, in {@link MARKERS} order. */ +const TABLE_NAMES = ['fill', 'wide', 'long-cell'] + +/** + * Viewport sweep. The wide stops leave the transcript far wider than the + * 748px message column, so the breakout relation holds with a fat margin on + * every platform; the narrow stop drops the transcript below the message + * column, which must clamp the breakout to neutral. The sidebar is collapsed + * for the whole sweep (see beforeAll), so the transcript width follows the + * viewport identically on overlay- and classic-scrollbar platforms. + */ +const WIDTHS = [1680, 1100, 640] + +/** A sentence long enough that three of them cannot sit unwrapped in the 748px column. */ +const SENTENCE = 'This cell carries one full sentence so the unwrapped table is far wider than the message column.' +/** Unbroken path-like token (no scheme, so GFM does not autolink it and no anchor joins the tab order). */ +const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3) +const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。' + +/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */ +function tablesMarkdown(): string { + const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)] + const wideRow = (row: number): string[] => + Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`) + return [ + 'Three markdown tables exercise the wide-table layout rules.', + '', + `| ${FILL_MARKER} | Current approach | Proposed approach |`, + '| --- | --- | --- |', + `| Rendering | ${SENTENCE} | ${SENTENCE} |`, + `| Memory | ${SENTENCE} | ${SENTENCE} |`, + '', + `| ${wideHeader.join(' | ')} |`, + `|${' --- |'.repeat(12)}`, + `| ${wideRow(1).join(' | ')} |`, + `| ${wideRow(2).join(' | ')} |`, + '', + `| ${LONG_CELL_MARKER} | Value |`, + '| --- | --- |', + `| path | ${LONG_TOKEN} |`, + `| 说明 | ${CJK_SENTENCE} |`, + '', + TAIL_MARKER, + ].join('\n') +} + +/** Build one closed, invariant-checked session fixture carrying the three tables. */ +function wideTableFixture(): string { + const session = Session.create(SessionId('markdown-wide-table-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the wide-table layout scenarios.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Markdown wide tables', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: tablesMarkdown() }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + // Spaced event times, as the sibling markdown fixtures pin them. + ...session.events.map(event => JSON.stringify({ + ...event, + time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +/** One table's layout relations at the current viewport. */ +interface TableReading { + /** Identifying first-header-cell marker. */ + marker: string + /** `scrollWidth - clientWidth` of the wrapper: the residual horizontal scroll. */ + overflow: number + /** Wrapper content width. */ + clientWidth: number + /** Rendered wrapper height; wrapping shows up as growth when the column narrows. */ + height: number + /** Renderer marked the table with the `md-table-wide` breakout hook. */ + wideHook: boolean + /** Resolved lead padding (the breakout's alignment compensation). */ + paddingLeft: number + /** The table's own left x, for the content-alignment relation. */ + tableLeft: number +} + +/** Read all three tables' relations in one pass. */ +function readTables(page: Page): Promise { + return page.evaluate((markers) => { + const wrappers = [...document.querySelectorAll('[class*="tableScroll"]')] + return markers.map((marker) => { + const wrapper = wrappers.find(candidate => candidate.textContent?.includes(marker) ?? false) + if (wrapper === undefined) throw new Error(`table wrapper ${marker} not rendered`) + const table = wrapper.querySelector('table') + if (table === null) throw new Error(`table ${marker} not rendered`) + return { + marker, + overflow: wrapper.scrollWidth - wrapper.clientWidth, + clientWidth: wrapper.clientWidth, + height: wrapper.getBoundingClientRect().height, + wideHook: wrapper.classList.contains('md-table-wide'), + paddingLeft: Number.parseFloat(getComputedStyle(wrapper).paddingLeft), + tableLeft: table.getBoundingClientRect().left, + } + }) + }, MARKERS) +} + +/** A sweep stop: the three tables' readings at one viewport width. */ +interface TableStop { + width: number + tables: TableReading[] +} + +/** + * Close the details pane so the transcript spans the viewport. Open, it pins + * the transcript to exactly the message column and every breakout relation + * would go vacuous. + * @param target - the page whose pane to close. + */ +async function closeDetailsPane(target: Page): Promise { + await target.getByRole('button', { name: 'Close details', exact: true }).waitFor({ timeout: 10_000 }) + await target.evaluate(() => { + document.querySelector('button[aria-label="Close details"]')?.click() + }) + // Closed details resolve to zero width but never unmount (ui-layout + // columns contract), so the settled signal is the frame's collapse marker, + // not the button's detachment. + await target.waitForSelector('[data-details-collapsed]', { timeout: 5_000 }) +} + +/** + * Render the golden body: relations only. `fills` is the wrap-first claim + * (the wrapper has no residual horizontal scroll), `scrolls` the many-column + * fallback, and `breaks out` whether the wide wrapper spans past the message + * column (compared against the fill table, which by construction is exactly + * the message column's width). + * @param stops - the measured stops, in sweep order. + * @param wrapTighter - per table name, whether the block grew taller at the + * narrowest stop than at the widest (the proof wrapping engaged). + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(stops: TableStop[], wrapTighter: Map): string { + return [ + '# Markdown wide-table relations', + '', + '| viewport | table | fills the column | scrolls | breaks out past the column |', + '| --- | --- | --- | --- | --- |', + ...stops.flatMap((stop) => { + const columnWidth = stop.tables[0]!.clientWidth + return stop.tables.map((table, index) => + `| ${String(stop.width)}px | ${TABLE_NAMES[index]} | ${String(table.overflow <= 1)} ` + + `| ${String(table.overflow > 1)} | ${String(table.clientWidth > columnWidth + 8)} |`, + ) + }), + '', + 'Wrap-first engagement (taller at the narrowest stop than at the widest):', + '', + ...[...wrapTighter.entries()].map(([name, tighter]) => `- ${name}: ${String(tighter)}`), + ].join('\n') +} + +describe('web e2e: markdown tables fill the column, wide ones break out and scroll', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, wideTableFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 }) + // Collapse the sidebar and close the details pane for the whole sweep: + // classic-scrollbar platforms (Linux CI) lose ~15px of layout width, + // which shifts how much of a narrow viewport the panes leave the + // transcript and lands the narrow stop's readings far from the macOS + // ones — and the details pane alone pins the transcript to exactly the + // message column, which would make every breakout relation vacuous. + // With both out of the equation the transcript follows the viewport + // identically on every platform, which is what keeps one committed + // golden true for all lanes. + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + // JS click: after the transcript scrolled to its tail, the pane's close + // button can sit under the sticky header where a pointer click is + // intercepted; the pane itself is scaffolding, not the behavior under + // test, so actionability adds nothing here. + await closeDetailsPane(page) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** + * Resize to a viewport and read the tables once layout settles (the frame + * eases its column tracks, so a read straight after a resize can catch a + * mid-transition width). + * @param width - viewport width to settle at. + * @returns the three tables' readings at that width. + */ + const settleAt = async (width: number): Promise => { + await page.setViewportSize({ width, height: 900 }) + // The wide wrapper follows the transcript width (the fill wrapper caps + // at the message column and would report "settled" mid-transition). + let previousWidth = -1 + await expect.poll(async () => { + const current = (await readTables(page))[1]!.clientWidth + const settled = current === previousWidth + previousWidth = current + return settled + }, { timeout: 10_000 }).toBe(true) + return readTables(page) + } + + /** Sweep once; every assertion reads the same measurement. */ + let swept: Promise | undefined + const sweep = (): Promise => { + swept ??= (async () => { + const stops: TableStop[] = [] + for (const width of WIDTHS) stops.push({ width, tables: await settleAt(width) }) + return stops + })() + return swept + } + + it('fills narrow tables, scrolls wide ones, and breaks them out where the transcript is wider', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table')) + const stops = await sweep() + for (const stop of stops) { + const [fill, wide, longCell] = stop.tables + const columnWidth = fill!.clientWidth + // #520: an ordinary wide table fills the message column and wraps… + expect(fill!.overflow, `fill viewport ${String(stop.width)}`).toBeLessThanOrEqual(1) + // …a long unbroken token and long CJK prose wrap instead of forcing a scroll… + expect(longCell!.overflow, `long-cell viewport ${String(stop.width)}`).toBeLessThanOrEqual(1) + // …and a many-column table keeps its natural width behind the scroll fallback. + expect(wide!.overflow, `wide viewport ${String(stop.width)}`).toBeGreaterThan(1) + // The hook is column-count static, present at every stop. + expect(wide!.wideHook).toBe(true) + expect(fill!.wideHook).toBe(false) + expect(longCell!.wideHook).toBe(false) + if (stop.width > 748) { + // Breakout: the wide wrapper spans past the message column, and its + // lead padding keeps the table content starting at the message + // column's left edge (compared to the fill table's content). + expect(wide!.clientWidth, `wide breakout at ${String(stop.width)}`).toBeGreaterThan(columnWidth + 8) + expect(wide!.paddingLeft, `lead at ${String(stop.width)}`).toBeGreaterThan(0) + expect(Math.abs(wide!.tableLeft - fill!.tableLeft), `alignment at ${String(stop.width)}`).toBeLessThan(1.5) + } else { + // Below the message column there is no spare width: the breakout + // clamps to neutral and the wrapper stays the column's width. + expect(Math.abs(wide!.clientWidth - columnWidth), `neutral at ${String(stop.width)}`).toBeLessThan(1.5) + expect(wide!.paddingLeft, `no lead at ${String(stop.width)}`).toBeLessThan(1.5) + } + } + // Wrap-first engaged for real: the filling tables grow taller as the + // column narrows (the wide table only scrolls, so it is exempt). + const widest = stops[0]! + const narrowest = stops[stops.length - 1]! + expect(narrowest.tables[0]!.height).toBeGreaterThan(widest.tables[0]!.height) + expect(narrowest.tables[2]!.height).toBeGreaterThan(widest.tables[2]!.height) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('keeps the wide table keyboard-scrollable', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-keyboard')) + await sweep() + await settleAt(1680) + const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER }) + // Chromium makes scrollable containers keyboard-focusable by default; + // arrow keys then scroll the focused wrapper. + await wide.focus() + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + await expect.poll(() => wide.evaluate(element => element.scrollLeft), { timeout: 5_000 }) + .toBeGreaterThan(0) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('keeps the fill/scroll relations under page zoom', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom')) + await sweep() + await settleAt(1100) + try { + await page.evaluate(() => { document.documentElement.style.zoom = '1.25' }) + await expect.poll(async () => { + const [fill, wide, longCell] = await readTables(page) + return fill!.overflow <= 1 && longCell!.overflow <= 1 && wide!.overflow > 1 + }, { timeout: 10_000 }).toBe(true) + } finally { + await page.evaluate(() => { document.documentElement.style.zoom = '' }) + } + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('reports the same relations on a high-DPI page', async () => { + const hidpiPage = await browser.newPage({ + viewport: { width: 1100, height: 900 }, + deviceScaleFactor: 2, + locale: 'en-US', + }) + const hidpiTripwire = watchConsole(hidpiPage) + try { + onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi')) + await hidpiPage.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const groupRow = hidpiPage.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = hidpiPage.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 }) + await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + await closeDetailsPane(hidpiPage) + // The pane collapses ease over the layout transition: compare only a + // settled reading (two consecutive equal wide-wrapper widths). + let readings: TableReading[] = [] + let previousWide = -1 + await expect.poll(async () => { + readings = await readTables(hidpiPage) + const settled = readings[1]!.clientWidth === previousWide + previousWide = readings[1]!.clientWidth + return settled + }, { timeout: 10_000 }).toBe(true) + const baseline = (await sweep()).find(stop => stop.width === 1100)! + const relations = (tables: TableReading[]) => tables.map(table => ({ + marker: table.marker, + fills: table.overflow <= 1, + breaksOut: table.clientWidth > tables[0]!.clientWidth + 8, + })) + expect(relations(readings)).toEqual(relations(baseline.tables)) + expect(hidpiTripwire.pageErrors).toEqual([]) + } finally { + await hidpiPage.close() + } + }, 120_000) + + it('matches the committed geometry golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-golden')) + const stops = await sweep() + const widest = stops[0]! + const narrowest = stops[stops.length - 1]! + const wrapTighter = new Map([ + ['fill', narrowest.tables[0]!.height > widest.tables[0]!.height], + ['long-cell', narrowest.tables[2]!.height > widest.tables[2]!.height], + ]) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(stops, wrapTighter), MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('commits exactly the fixtures it reads', async () => { + // No model calls, so no replay log: the golden is the whole inventory. + await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/markdown-wide-table/geometry.expected.md b/apps/web/tests/snapshots/markdown-wide-table/geometry.expected.md new file mode 100644 index 0000000000..0e195a6bba --- /dev/null +++ b/apps/web/tests/snapshots/markdown-wide-table/geometry.expected.md @@ -0,0 +1,18 @@ +# Markdown wide-table relations + +| viewport | table | fills the column | scrolls | breaks out past the column | +| --- | --- | --- | --- | --- | +| 1680px | fill | true | false | false | +| 1680px | wide | false | true | true | +| 1680px | long-cell | true | false | false | +| 1100px | fill | true | false | false | +| 1100px | wide | false | true | true | +| 1100px | long-cell | true | false | false | +| 640px | fill | true | false | false | +| 640px | wide | false | true | false | +| 640px | long-cell | true | false | false | + +Wrap-first engagement (taller at the narrowest stop than at the widest): + +- fill: true +- long-cell: true diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 111aefe63c..38a0438ef9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -64,6 +64,7 @@ "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", "tests/reference-composer.e2e.ts", + "tests/markdown-wide-table.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index 8fdba2baa0..c9e8d19d77 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -17,6 +17,28 @@ gap: 16px; } +/* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span + the whole transcript width instead of clipping at the 748px message + column, while the table content keeps starting at its normal x (the lead + padding compensates the negative margin) — deepsuite chat TableWrapper + parity, with 100cqw (the ChatView scroll container) standing in for its + JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero + when the transcript is narrower than the message column, so narrow + viewports keep the plain in-column scroll with no sideways shift. + Percentages resolve against the wrapper's containing block, so a table + indented inside a list still reaches the same transcript edges. */ +.body :global(.md-table-wide) { + --dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2)); + --dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%); + box-sizing: border-box; + width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare)); + /* The base .tableScroll caps at the column (max-width: 100%); the breakout + is exactly the case that must exceed it. */ + max-width: none; + margin-left: calc(-1 * var(--dsh-table-lead)); + padding-left: var(--dsh-table-lead); +} + /* Interrupted-turn terminal marker: quiet inline tag, no animation. */ .stopped { align-self: flex-start; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index ffee12984f..73fa688f95 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -18,6 +18,11 @@ /* Sides = composer clearance + 16px: on narrow viewports the transcript stays exactly 32px narrower than the input card (the shared width rule). */ padding: 16px calc(var(--dsh-composer-side-clearance) + 16px); + /* Inline-size query container: wide markdown tables size their breakout + against this box's content width (100cqw in AssistantMarkdown's + md-table-wide rule) — the CSS stand-in for deepsuite chat's JS-measured + --dsl-virtual-list-width. */ + container-type: inline-size; } :global([data-conversation-scroll]) .root { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 06ca10e568..6856283fcc 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: a675b3cd0aa9e09e243b69065110e1d2b67ff1d9 -README.zh.md: aa67993ec879635fc0677501665d2e23de996dcf +README.md: 0d7848112b650a322965ad52a38d23e0145e7921 +README.zh.md: 475af21d5a7bc1bc48115b171d92b9a0d44df309 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a675b3cd0a..0d7848112b 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); Chromium keyboard-focuses the scrollable wrapper by default ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index aa67993ec8..475af21d5a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -14,7 +14,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);Chromium 默认让可滚动包裹层可键盘聚焦([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index b62e66e86e..969cf41342 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -177,12 +177,31 @@ overscroll-behavior-x: contain; } +/* Chromium keyboard-focuses scrollable containers by default; the ring uses + the sheet's link focus color. */ +.tableScroll:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); +} + +/* Many-column tables (the renderer's `md-table-wide` hook) keep their + natural width and scroll inside the wrapper; a hosting layout may widen + the wrapper through the hook (deepsuite chat TableWrapper parity). */ .tableScroll table { border-collapse: collapse; width: max-content; max-width: max-content; } +/* Tables under four columns, and any table inside a blockquote, fill the + column instead: cells wrap down to their minimum readable width, and the + wrapper's scroll only remains for the below-floor case (deepsuite chat + parity: `.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))`). */ +.tableFill table { + width: 100%; + max-width: none; +} + .tableScroll th { text-align: start; padding: 10px 16px; diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 452e0fc475..12d73bb44b 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -18,6 +18,7 @@ import { Fragment, createElement } from 'react' import type { Key, ReactNode } from 'react' +import clsx from 'clsx' import type * as Md from 'mdast' import type {} from 'mdast-util-math' import { normalizeUri } from 'micromark-util-sanitize-uri' @@ -123,6 +124,8 @@ export interface MarkdownRenderContext { readonly streaming: boolean /** Localized fence copy-button labels. */ readonly codeLabels: MarkdownCodeLabels | undefined + /** Inside a blockquote's children: tables there always fill the quote's width. */ + readonly inBlockquote?: boolean /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ readonly fileMentions: MarkdownFileMentions | undefined /** Inside an anchor's children: interactive mentions must not nest there. */ @@ -213,7 +216,10 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'blockquote': return (
- {wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)} + {wrapBlockChildren( + renderChildren(node.children, { ...context, inBlockquote: true }).filter(child => child !== null), + true, + )}
) case 'thematicBreak': @@ -393,8 +399,15 @@ function renderListItem( function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode { const align = node.align ?? null const [headRow, ...bodyRows] = node.children + const columns = align === null ? headRow?.children.length ?? 0 : align.length + // Four or more columns read as a comparison matrix: the wrapper keeps the + // table at natural width and exposes the stable `md-table-wide` hook so a + // hosting layout (the chat transcript) can widen it past the message + // column. Narrower tables — and any table inside a blockquote — fill the + // column and wrap instead (deepsuite chat TableWrapper parity). + const wide = columns >= 4 && context.inBlockquote !== true return ( -
+
{headRow !== undefined && {renderTableRow(headRow, 'th', align, 0, context)}} {bodyRows.length > 0 && ( diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt index cf6b2ba4ad..83b008e2d5 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt @@ -22,7 +22,7 @@ #text "Unbalanced errors render the error arm: " #text "\\frac{" -
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt index 639a2fca84..a56b3c338d 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt @@ -3,7 +3,7 @@ #text "Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$."

#text "Unbalanced errors render the error arm: $\\frac{$" -

+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt index 2c2d9d7e0f..ba1552d480 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt index 2c2d9d7e0f..ba1552d480 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt new file mode 100644 index 0000000000..9b805440be --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt @@ -0,0 +1,46 @@ +
+
+
+ + + + +
+ #text "C1" + + #text "C2" + + #text "C3" + + #text "C4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" +
+
+ + + + + +
+ #text "Q1" + + #text "Q2" + + #text "Q3" + + #text "Q4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt new file mode 100644 index 0000000000..9b805440be --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt @@ -0,0 +1,46 @@ +
+
+ + + + + +
+ #text "C1" + + #text "C2" + + #text "C3" + + #text "C4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" +
+
+ + + + + +
+ #text "Q1" + + #text "Q2" + + #text "Q3" + + #text "Q4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt index 6a669ffe2f..2ce936ddcc 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt index 6a669ffe2f..2ce936ddcc 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx index 70197763da..093b84a133 100644 --- a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx @@ -163,6 +163,17 @@ const CORPUS: Record = { 'after', ].join('\n'), 'table-header-only': '| a | b |\n| --- | --- |\n\nafter', + // Four columns take the md-table-wide hook at top level; the same table + // inside a blockquote falls back to the fill arm. + 'table-wide-and-blockquote': [ + '| C1 | C2 | C3 | C4 |', + '| --- | --- | --- | --- |', + '| a | b | c | d |', + '', + '> | Q1 | Q2 | Q3 | Q4 |', + '> | --- | --- | --- | --- |', + '> | a | b | c | d |', + ].join('\n'), 'inline-code-with-newline': 'Spans `a\nb` across a line.', 'links-and-autolinks': [ '[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).', diff --git a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx index 48f59c7479..dbad9ef164 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx @@ -101,6 +101,14 @@ describe('renderBlocks over hand-built trees', () => { expect(container.querySelector('td')?.textContent).toBe('short') }) + it('renders a rowless align-less table as an empty fill wrapper', () => { + // Zero columns is below the wide threshold, so the fill arm applies. + const container = renderNodes([{ type: 'table', children: [] }]) + const wrapper = container.querySelector('table')?.parentElement + expect(wrapper?.className).not.toContain('md-table-wide') + expect(container.querySelector('table')?.childElementCount).toBe(0) + }) + it('pads rows against the alignment width with empty cells', () => { const container = renderNodes([ { diff --git a/tsconfig.host.json b/tsconfig.host.json index bb885e7ecc..d0b5808a48 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -51,6 +51,7 @@ "apps/web/tests/message-feedback-layout.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/reference-composer.e2e.ts", + "apps/web/tests/markdown-wide-table.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts",