fix(web): preserve compact math and inspector typography
This commit is contained in:
1 parent
7d9166ea40
commit
039415d5a4
10 files changed
+115
-21
No files matched your search
@@ -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 .agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md
|
||||
2026-09-17-thinking-markdown.md: 91ad8a3cbb4a0a2e0e4c931642cce7341f77a7df
|
||||
2026-09-17-thinking-markdown.zh.md: 0af53506795b8f597455a344043902708a5ec824
|
||||
2026-09-17-thinking-markdown.md: de49cb42ba537700f6584c9f4cef9a57fb2e3605
|
||||
2026-09-17-thinking-markdown.zh.md: e403630138f2f39aec0834e550d4d4a8860a67fe
|
||||
@@ -12,9 +12,9 @@ Chat Thinking contains model-authored Markdown, but literal rendering exposes he
|
||||
|
||||
[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../feature/2026-09-04-web-clickable-link-styles.md); code retains its monospace font and background.
|
||||
|
||||
Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../feature/2026-08-03-web-sticky-collapsible-headers.md) above the scrolling content without a second sticky band.
|
||||
Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Inline formulas retain native KaTeX baselines; enclosing text blocks own overflow so short formulas do not create scrollbars. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../feature/2026-08-03-web-sticky-collapsible-headers.md) above the scrolling content without a second sticky band.
|
||||
|
||||
Chat passes its running state to the existing incremental Markdown renderer. The collapsed summary remains a separate single-line text projection. The parser, frozen-block cache, stored reasoning, and Session format do not change. Trajectory's answer output retains its existing typography and its [Thinking disclosure behavior](../feature/2026-09-09-ptc-trajectory-code-inspection.md).
|
||||
Chat passes its running state to the existing incremental Markdown renderer. The collapsed summary remains a separate single-line text projection. The parser, frozen-block cache, stored reasoning, and Session format do not change. Trajectory pins Thinking to its inspector’s fixed 13px/20px tier, independent of Chat’s content-size setting. Its answer output retains its existing typography and its [Thinking disclosure behavior](../feature/2026-09-09-ptc-trajectory-code-inspection.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -27,3 +27,5 @@ Chat passes its running state to the existing incremental Markdown renderer. The
|
||||
## Consequences
|
||||
|
||||
Thinking supports structured reading while retaining its secondary emphasis. Semantic headings remain available to assistive technology even though their visible hierarchy is flattened. The shared variant applies to both views; future Markdown element changes must preserve its typography as well as the default answer typography.
|
||||
|
||||
Markdown soft line breaks collapse like answer prose; hard breaks and separate paragraphs require Markdown syntax. Streaming freezing operates on completed blocks, so an unfinished long paragraph remains in the mutable tail and is parsed again as it grows.
|
||||
@@ -12,9 +12,9 @@ Chat Thinking 包含模型编写的 Markdown,但纯文本渲染直接显示标
|
||||
|
||||
[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../feature/2026-09-04-web-clickable-link-styles.zh.md)的次级内容例外;代码保留等宽字体和底色。
|
||||
|
||||
表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../feature/2026-08-03-web-sticky-collapsible-headers.zh.md) 位于滚动内容上方,无需第二条 sticky 栏。
|
||||
表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../feature/2026-08-03-web-sticky-collapsible-headers.zh.md) 位于滚动内容上方,无需第二条 sticky 栏。
|
||||
|
||||
Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是独立的单行文本投影。解析器、冻结块缓存、存储的推理内容和 Session 格式均保持不变。Trajectory 的回答输出保留既有排版及其 [Thinking 折叠行为](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。
|
||||
Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是独立的单行文本投影。解析器、冻结块缓存、存储的推理内容和 Session 格式均保持不变。Trajectory 将 Thinking 固定在检查器的 13px/20px 层级,与 Chat 的内容字号设置无关。其回答输出保留既有排版及其 [Thinking 折叠行为](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -27,3 +27,5 @@ Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是
|
||||
## 后果
|
||||
|
||||
Thinking 支持结构化阅读,同时保持次级强调。虽然视觉层级被统一,辅助技术仍可使用语义标题。共享变体同时用于两个视图;后续 Markdown 元素变更必须同时保留紧凑排版与默认回答排版。
|
||||
|
||||
Markdown 软换行与回答正文一样折叠;硬换行和独立段落需要相应 Markdown 语法。流式冻结以完整块为单位,因此尚未结束的长段落仍处于可变尾部,并随增长重新解析。
|
||||
@@ -22,6 +22,14 @@
|
||||
- list:
|
||||
- listitem: Unordered item
|
||||
- listitem: Second item
|
||||
- listitem:
|
||||
- text: "Inline list formula:"
|
||||
- math: x j
|
||||
- text: .
|
||||
- listitem:
|
||||
- text: "Long list atom:"
|
||||
- math: a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a ⏟ l o n g
|
||||
- text: .
|
||||
- list:
|
||||
- listitem: Ordered item
|
||||
- listitem: Another item
|
||||
@@ -190,6 +198,18 @@
|
||||
- cell "Row 20 column 4"
|
||||
- cell "Row 20 column 5"
|
||||
- cell "Row 20 column 6"
|
||||
- paragraph:
|
||||
- text: "Short subscript:"
|
||||
- math: x j
|
||||
- text: .
|
||||
- paragraph:
|
||||
- text: "Short scripts:"
|
||||
- math: x i 2
|
||||
- text: .
|
||||
- paragraph:
|
||||
- text: "Long atom:"
|
||||
- math: a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a ⏟ l o n g
|
||||
- text: .
|
||||
- paragraph:
|
||||
- text: "Inline math:"
|
||||
- math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + z
|
||||
|
||||
@@ -52,6 +52,8 @@ function thinkingFixture(): string {
|
||||
...[1, 2, 3, 4, 5, 6].flatMap(level => [`${'#'.repeat(level)} Level ${String(level)}`, '']),
|
||||
'- Unordered item',
|
||||
'- Second item',
|
||||
'- Inline list formula: $x_j$.',
|
||||
`- Long list atom: $\\underbrace{${'a'.repeat(240)}}_{long}$.`,
|
||||
'',
|
||||
'1. Ordered item',
|
||||
'2. Another item',
|
||||
@@ -71,6 +73,12 @@ function thinkingFixture(): string {
|
||||
`| ${Array.from({ length: 6 }, (_, index) => `long_table_cell_${String(index)}_${'x'.repeat(40)}`).join(' | ')} |`,
|
||||
...Array.from({ length: 20 }, (_, row) => `| ${Array.from({ length: 6 }, (_, column) => `Row ${String(row + 1)} column ${String(column + 1)}`).join(' | ')} |`),
|
||||
'',
|
||||
'Short subscript: $x_j$.',
|
||||
'',
|
||||
'Short scripts: $x_i^2$.',
|
||||
'',
|
||||
`Long atom: $\\underbrace{${'a'.repeat(240)}}_{long}$.`,
|
||||
'',
|
||||
`Inline math: $${'a+'.repeat(80)}z$.`,
|
||||
'',
|
||||
'$$',
|
||||
@@ -105,7 +113,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, thinkingFixture(), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
@@ -150,7 +158,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
expect(await markdown.locator('pre code').textContent()).toContain('const value = "reasoning code"')
|
||||
expect(await markdown.locator('hr').count()).toBe(1)
|
||||
expect(await markdown.locator('table').count()).toBe(1)
|
||||
expect(await markdown.locator('.katex').count()).toBe(2)
|
||||
expect(await markdown.locator('.katex').count()).toBe(7)
|
||||
await markdown.locator('pre').scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => markdown.locator('pre.shiki').count(), { timeout: 15_000 }).toBe(1)
|
||||
const snapshot = (await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd))
|
||||
@@ -162,20 +170,48 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
await page.evaluate(async () => { await document.fonts.ready })
|
||||
const styles = await markdown.evaluate((root, secondary) => {
|
||||
const elements = [...root.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6,p,li,a,strong,em,pre,pre code,pre span,th,td,.katex')]
|
||||
const scrollers = [...root.querySelectorAll<HTMLElement>('[class*="tableScroll"],.katex')]
|
||||
const scrollers = [...root.querySelectorAll<HTMLElement>('[class*="tableScroll"],.katex-display,p:has(.katex),ul:has(.katex)')]
|
||||
const shortFormulas = [...root.querySelectorAll('p')].filter(element => element.textContent?.startsWith('Short '))
|
||||
// Native KaTeX baselines vary with host fonts; compact adds one pixel for descender ink.
|
||||
const shortMathNaturalHeight = shortFormulas.map((paragraph) => {
|
||||
const native = paragraph.cloneNode(true) as HTMLParagraphElement
|
||||
native.style.font = getComputedStyle(paragraph).font
|
||||
native.style.position = 'absolute'
|
||||
native.style.width = `${String(paragraph.clientWidth)}px`
|
||||
for (const formula of native.querySelectorAll<HTMLElement>('.katex')) formula.style.fontSize = secondary.fontSize
|
||||
document.body.appendChild(native)
|
||||
const height = native.getBoundingClientRect().height
|
||||
native.remove()
|
||||
return paragraph.getBoundingClientRect().height <= height + 1
|
||||
})
|
||||
const longAtom = [...root.querySelectorAll('p')].find(element => element.textContent?.startsWith('Long atom:'))
|
||||
const mathList = root.querySelector('ul:has(.katex)')
|
||||
const mathListItem = mathList?.querySelector('li')
|
||||
const banner = root.querySelector('[data-code-block-banner]')?.parentElement
|
||||
const loose = [...root.querySelectorAll('li p')].find(element => element.textContent === 'Loose middle paragraph.')
|
||||
return {
|
||||
secondarySize: elements.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
|
||||
secondaryLine: elements.every(element => getComputedStyle(element).lineHeight === secondary.lineHeight),
|
||||
secondaryLine: elements.filter(element => !element.classList.contains('katex'))
|
||||
.every(element => getComputedStyle(element).lineHeight === secondary.lineHeight),
|
||||
tertiaryColor: elements.every(element => getComputedStyle(element).color === secondary.color),
|
||||
tertiaryMarkers: [...root.querySelectorAll('li')]
|
||||
.every(element => getComputedStyle(element, '::marker').color === secondary.color),
|
||||
contained: root.scrollWidth <= root.clientWidth + 1,
|
||||
bannerStatic: banner !== undefined && banner !== null && getComputedStyle(banner).position === 'static',
|
||||
looseSpacing: loose !== undefined && getComputedStyle(loose).marginTop === '4px' && getComputedStyle(loose).marginBottom === '4px',
|
||||
wideContentBounded: scrollers.length === 3 && scrollers.every(element => element.clientWidth <= root.clientWidth + 1),
|
||||
displayMathScrolls: [...root.querySelectorAll<HTMLElement>('.katex-display .katex')]
|
||||
wideContentBounded: scrollers.length === 7 && scrollers.every(element => element.clientWidth <= root.clientWidth + 1),
|
||||
mathListMarkerSpace: mathList !== null && mathListItem !== null && mathListItem !== undefined
|
||||
&& mathListItem.getBoundingClientRect().left - mathList.getBoundingClientRect().left >= Number.parseFloat(secondary.fontSize),
|
||||
shortMathNaturalHeight: shortMathNaturalHeight.length === 2 && shortMathNaturalHeight.every(Boolean),
|
||||
shortMathNoScrollbar: shortFormulas.every(paragraph => [paragraph, ...paragraph.querySelectorAll<HTMLElement>('span')].every((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const scrollsX = style.overflowX === 'auto' || style.overflowX === 'scroll'
|
||||
const scrollsY = style.overflowY === 'auto' || style.overflowY === 'scroll'
|
||||
return (!scrollsX || element.scrollWidth <= element.clientWidth)
|
||||
&& (!scrollsY || element.scrollHeight <= element.clientHeight)
|
||||
})),
|
||||
longAtomScrolls: longAtom !== undefined && longAtom.scrollWidth > longAtom.clientWidth,
|
||||
displayMathScrolls: [...root.querySelectorAll<HTMLElement>('.katex-display')]
|
||||
.every(element => element.scrollWidth > element.clientWidth),
|
||||
}
|
||||
}, summaryStyle)
|
||||
@@ -183,6 +219,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
secondarySize: true, secondaryLine: true, tertiaryColor: true, tertiaryMarkers: true,
|
||||
contained: true, bannerStatic: true, looseSpacing: true,
|
||||
wideContentBounded: true, displayMathScrolls: true,
|
||||
shortMathNaturalHeight: true, shortMathNoScrollbar: true, longAtomScrolls: true, mathListMarkerSpace: true,
|
||||
})
|
||||
}
|
||||
const answerSize = await page.getByRole('heading', { name: 'Main answer', exact: true })
|
||||
@@ -223,6 +260,35 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
const trajectoryHeading = details.locator('[data-markdown-variant="compact"] h1')
|
||||
await trajectoryHeading.waitFor({ timeout: 10_000 })
|
||||
expect(await trajectoryHeading.evaluate(element => getComputedStyle(element).fontSize)).toBe(summaryStyle.fontSize)
|
||||
const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))
|
||||
try {
|
||||
for (const fontSize of [16, 17]) {
|
||||
await scaffold.ctx.settings.update('ui-theme', { fontSize })
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
|
||||
.toBe(`${String(fontSize)}px`)
|
||||
const typography = await details.evaluate((panel) => {
|
||||
const heading = panel.querySelector('[data-markdown-variant="compact"] h1')
|
||||
const paragraph = panel.querySelector('[data-markdown-variant="compact"] p')
|
||||
const answer = panel.querySelector('[class*="assistantOutput"] p')
|
||||
if (heading === null || paragraph === null || answer === null) throw new Error('Trajectory prose missing')
|
||||
const headingStyle = getComputedStyle(heading)
|
||||
const paragraphStyle = getComputedStyle(paragraph)
|
||||
return {
|
||||
headingSize: headingStyle.fontSize,
|
||||
paragraphSize: paragraphStyle.fontSize,
|
||||
lineHeight: paragraphStyle.lineHeight,
|
||||
noLargerThanAnswer: Number.parseFloat(paragraphStyle.fontSize) <= Number.parseFloat(getComputedStyle(answer).fontSize),
|
||||
}
|
||||
})
|
||||
expect(typography, `Trajectory with ${String(fontSize)}px content setting`).toEqual({
|
||||
headingSize: '13px', paragraphSize: '13px', lineHeight: '20px', noLargerThanAnswer: true,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
await scaffold.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) })
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
|
||||
.toBe(originalFontSize)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(EXPECTED_DIR, ['ui.expected.md'])
|
||||
|
||||
@@ -439,13 +439,15 @@
|
||||
}
|
||||
|
||||
.compact :global(.katex) {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Keep KaTeX's inline baseline and scroll the containing block only when needed. */
|
||||
.compact :is(p, h1, h2, h3, h4, h5, h6, ul, ol):has(:global(.katex)) {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
font-size: 1em;
|
||||
line-height: inherit;
|
||||
vertical-align: middle;
|
||||
/* KaTeX descenders can paint one pixel below a fractional line-box edge. */
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.compact :global(.katex-display) {
|
||||
|
||||
@@ -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-trajectory/README.md
|
||||
README.md: e55816c94298bdb8a6dbdcbb54fd350cdc60ea68
|
||||
README.zh.md: 875f67ed8c584e713ff692dcc1d304a0100b23f4
|
||||
README.md: 02cbf402a702a4a51e9e3a122e3df13c4eb4e772
|
||||
README.zh.md: 00971c0710e1620b97fc51ae03486a9114629a49
|
||||
@@ -33,7 +33,7 @@ Calls are identified by their recorded tool name. For `run_code`, the row shows
|
||||
|
||||
Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
|
||||
|
||||
Thinking in the inspector uses compact Markdown with secondary typography; headings add bold weight without increasing size or line height. Assistant output keeps its regular Markdown typography.
|
||||
Thinking in the inspector uses compact Markdown at the inspector's fixed 13px size and 20px line height, independent of the content-size setting. Headings add bold weight without increasing size or line height. Assistant output keeps its regular Markdown typography.
|
||||
|
||||
### The timing overview
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
|
||||
|
||||
选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
|
||||
|
||||
检查器中的思考内容使用次级排版的紧凑 Markdown;标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。
|
||||
检查器中的思考内容使用紧凑 Markdown,沿用检查器固定的 13px 字号和 20px 行高,不受内容字号设置影响。标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。
|
||||
|
||||
### 时间概览
|
||||
|
||||
|
||||
@@ -1496,6 +1496,8 @@
|
||||
}
|
||||
|
||||
.thinkingQuote {
|
||||
--dsh-content-font-size-secondary: 13px;
|
||||
--dsh-content-font-delta-secondary: 0px;
|
||||
margin: 6px 14px 0 12px;
|
||||
padding-left: 6px;
|
||||
border-left: 2px solid var(--dsw-alias-markdown-citation);
|
||||
|
||||
Reference in new issue
Block a user