diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml new file mode 100644 index 0000000000..f0a89cb78d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.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/bug-fix/2026-09-17-thinking-markdown.md +2026-09-17-thinking-markdown.md: de49cb42ba537700f6584c9f4cef9a57fb2e3605 +2026-09-17-thinking-markdown.zh.md: e403630138f2f39aec0834e550d4d4a8860a67fe diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md new file mode 100644 index 0000000000..de49cb42ba --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md @@ -0,0 +1,31 @@ +# Agent Note: Compact Markdown for Thinking + +Status: implemented + +English | [中文](2026-09-17-thinking-markdown.zh.md) + +## Problem + +Chat Thinking contains model-authored Markdown, but literal rendering exposes headings, emphasis markers, and code fences. The shared answer typography makes headings larger and more prominent than the secondary reasoning text. Trajectory applies that answer typography to the same reasoning. + +## Decision + +[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. 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 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 + +**Use answer typography unchanged.** Large headings and generous block spacing give reasoning more visual weight than the answer. + +**Style Markdown separately in each consumer.** Chat and Trajectory would need to track the same renderer elements independently; the primitive owns those elements and their compact presentation. + +**Disable tables and formulas.** The existing renderer already supports them. Bounded overflow and inherited font sizing preserve useful content without another parsing mode. + +## 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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md new file mode 100644 index 0000000000..e403630138 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Thinking 的紧凑 Markdown + +Status: implemented + +[English](2026-09-17-thinking-markdown.md) | 中文 + +## 问题 + +Chat Thinking 包含模型编写的 Markdown,但纯文本渲染直接显示标题、强调标记和代码围栏。共享回答排版会让标题比次级推理文本更大、更醒目。Trajectory 对同一推理内容使用了回答排版。 + +## 决策 + +[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)的次级内容例外;代码保留等宽字体和底色。 + +表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../feature/2026-08-03-web-sticky-collapsible-headers.zh.md) 位于滚动内容上方,无需第二条 sticky 栏。 + +Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是独立的单行文本投影。解析器、冻结块缓存、存储的推理内容和 Session 格式均保持不变。Trajectory 将 Thinking 固定在检查器的 13px/20px 层级,与 Chat 的内容字号设置无关。其回答输出保留既有排版及其 [Thinking 折叠行为](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 + +## 曾考虑的替代方案 + +**直接使用回答排版。** 大标题和宽松块间距让推理比回答更醒目。 + +**由每个使用方单独设置 Markdown 样式。** Chat 和 Trajectory 将需要分别跟踪相同的渲染元素;基元拥有这些元素及其紧凑展示。 + +**禁用表格和公式。** 既有渲染器已支持二者。限制溢出和继承字号可以保留有用内容,无需增加另一种解析模式。 + +## 后果 + +Thinking 支持结构化阅读,同时保持次级强调。虽然视觉层级被统一,辅助技术仍可使用语义标题。共享变体同时用于两个视图;后续 Markdown 元素变更必须同时保留紧凑排版与默认回答排版。 + +Markdown 软换行与回答正文一样折叠;硬换行和独立段落需要相应 Markdown 语法。流式冻结以完整块为单位,因此尚未结束的长段落仍处于可变尾部,并随增长重新解析。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml index b8c09374e3..9ae953b44a 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.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 .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md -2026-08-03-web-sticky-collapsible-headers.md: 6e8b4b94c9c6aa40bc7190e6705b84caa8189c52 -2026-08-03-web-sticky-collapsible-headers.zh.md: d4e1f69539db6870ae60d5c4b05cd52c7d129360 +2026-08-03-web-sticky-collapsible-headers.md: 5bbe91681e656a8278b9d65f12e20a4d9dc779d0 +2026-08-03-web-sticky-collapsible-headers.zh.md: c75846fa7f5ca1088dab530b63243553e50f9579 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md index 6e8b4b94c9..5bbe91681e 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md +++ b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md @@ -14,7 +14,7 @@ The disclosure header of each uncapped block sticks to the conversation scroll c Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header. -The pinned header's stacking rank differs by block, because their bodies differ. The Think body is plain text with no sticky descendant, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`). +The pinned header's stacking rank differs by block, because their bodies differ. The Think body uses [compact Markdown](../bug-fix/2026-09-17-thinking-markdown.md) with code banners in normal flow, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`). The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state. diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md index d4e1f69539..c75846fa7f 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md +++ b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md @@ -14,7 +14,7 @@ Status: implemented 这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。 -钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文是纯文本、无 sticky 后代,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。 +钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文使用[紧凑 Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md),代码栏处于正常文档流中,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。 规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml index a088f36d49..6009b3c1ad 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.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 .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: fc383387cede1517981358fd31a08f47c2d76c02 -2026-09-04-web-clickable-link-styles.zh.md: 7336a740e2ba93fe67a504745dcf770cab3c803d +2026-09-04-web-clickable-link-styles.md: c4e506294c87c6b959cb449403edce09daa11062 +2026-09-04-web-clickable-link-styles.zh.md: 01258674b4b8194ad5e0597cde61cc2186406d49 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md index fc383387ce..c4e506294c 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md @@ -15,6 +15,7 @@ One link language across the transcript's clickable-link surfaces — markdown a - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. - A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. +- [Compact Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.md) retains tertiary text color and a resting dotted underline to preserve secondary emphasis. - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md index 7336a740e2..01258674b4 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md @@ -15,6 +15,7 @@ Status: implemented - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 - 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 +- [紧凑 Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md) 保留 tertiary 文字色和默认点状下划线,以保持次级强调。 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 diff --git a/apps/web/tests/expected/thinking-markdown/ui.expected.md b/apps/web/tests/expected/thinking-markdown/ui.expected.md new file mode 100644 index 0000000000..a94d4bbf3c --- /dev/null +++ b/apps/web/tests/expected/thinking-markdown/ui.expected.md @@ -0,0 +1,221 @@ +- button "Think" [expanded]: + - img + - text: Think +- heading "Compact reasoning with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary" [level=2] +- paragraph: + - text: A paragraph with + - strong: strong text + - text: "," + - emphasis: emphasis + - text: "," + - link "reference": + - /url: https://example.com/ + - text: ", and" + - code: inline_code + - text: . +- heading "Level 1" [level=1] +- heading "Level 2" [level=2] +- heading "Level 3" [level=3] +- heading "Level 4" [level=4] +- heading "Level 5" [level=5] +- heading "Level 6" [level=6] +- 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 +- list: + - listitem: + - paragraph: Loose first paragraph. + - paragraph: Loose middle paragraph. + - paragraph: Loose last paragraph. +- blockquote: + - paragraph: Quoted reasoning. +- separator +- table: + - rowgroup: + - row "First Second Third Fourth Fifth Sixth": + - columnheader "First" + - columnheader "Second" + - columnheader "Third" + - columnheader "Fourth" + - columnheader "Fifth" + - columnheader "Sixth" + - rowgroup: + - row "long_table_cell_0_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_3_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_5_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": + - cell "long_table_cell_0_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + - cell "long_table_cell_1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + - cell "long_table_cell_2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + - cell "long_table_cell_3_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + - cell "long_table_cell_4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + - cell "long_table_cell_5_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + - row "Row 1 column 1 Row 1 column 2 Row 1 column 3 Row 1 column 4 Row 1 column 5 Row 1 column 6": + - cell "Row 1 column 1" + - cell "Row 1 column 2" + - cell "Row 1 column 3" + - cell "Row 1 column 4" + - cell "Row 1 column 5" + - cell "Row 1 column 6" + - row "Row 2 column 1 Row 2 column 2 Row 2 column 3 Row 2 column 4 Row 2 column 5 Row 2 column 6": + - cell "Row 2 column 1" + - cell "Row 2 column 2" + - cell "Row 2 column 3" + - cell "Row 2 column 4" + - cell "Row 2 column 5" + - cell "Row 2 column 6" + - row "Row 3 column 1 Row 3 column 2 Row 3 column 3 Row 3 column 4 Row 3 column 5 Row 3 column 6": + - cell "Row 3 column 1" + - cell "Row 3 column 2" + - cell "Row 3 column 3" + - cell "Row 3 column 4" + - cell "Row 3 column 5" + - cell "Row 3 column 6" + - row "Row 4 column 1 Row 4 column 2 Row 4 column 3 Row 4 column 4 Row 4 column 5 Row 4 column 6": + - cell "Row 4 column 1" + - cell "Row 4 column 2" + - cell "Row 4 column 3" + - cell "Row 4 column 4" + - cell "Row 4 column 5" + - cell "Row 4 column 6" + - row "Row 5 column 1 Row 5 column 2 Row 5 column 3 Row 5 column 4 Row 5 column 5 Row 5 column 6": + - cell "Row 5 column 1" + - cell "Row 5 column 2" + - cell "Row 5 column 3" + - cell "Row 5 column 4" + - cell "Row 5 column 5" + - cell "Row 5 column 6" + - row "Row 6 column 1 Row 6 column 2 Row 6 column 3 Row 6 column 4 Row 6 column 5 Row 6 column 6": + - cell "Row 6 column 1" + - cell "Row 6 column 2" + - cell "Row 6 column 3" + - cell "Row 6 column 4" + - cell "Row 6 column 5" + - cell "Row 6 column 6" + - row "Row 7 column 1 Row 7 column 2 Row 7 column 3 Row 7 column 4 Row 7 column 5 Row 7 column 6": + - cell "Row 7 column 1" + - cell "Row 7 column 2" + - cell "Row 7 column 3" + - cell "Row 7 column 4" + - cell "Row 7 column 5" + - cell "Row 7 column 6" + - row "Row 8 column 1 Row 8 column 2 Row 8 column 3 Row 8 column 4 Row 8 column 5 Row 8 column 6": + - cell "Row 8 column 1" + - cell "Row 8 column 2" + - cell "Row 8 column 3" + - cell "Row 8 column 4" + - cell "Row 8 column 5" + - cell "Row 8 column 6" + - row "Row 9 column 1 Row 9 column 2 Row 9 column 3 Row 9 column 4 Row 9 column 5 Row 9 column 6": + - cell "Row 9 column 1" + - cell "Row 9 column 2" + - cell "Row 9 column 3" + - cell "Row 9 column 4" + - cell "Row 9 column 5" + - cell "Row 9 column 6" + - row "Row 10 column 1 Row 10 column 2 Row 10 column 3 Row 10 column 4 Row 10 column 5 Row 10 column 6": + - cell "Row 10 column 1" + - cell "Row 10 column 2" + - cell "Row 10 column 3" + - cell "Row 10 column 4" + - cell "Row 10 column 5" + - cell "Row 10 column 6" + - row "Row 11 column 1 Row 11 column 2 Row 11 column 3 Row 11 column 4 Row 11 column 5 Row 11 column 6": + - cell "Row 11 column 1" + - cell "Row 11 column 2" + - cell "Row 11 column 3" + - cell "Row 11 column 4" + - cell "Row 11 column 5" + - cell "Row 11 column 6" + - row "Row 12 column 1 Row 12 column 2 Row 12 column 3 Row 12 column 4 Row 12 column 5 Row 12 column 6": + - cell "Row 12 column 1" + - cell "Row 12 column 2" + - cell "Row 12 column 3" + - cell "Row 12 column 4" + - cell "Row 12 column 5" + - cell "Row 12 column 6" + - row "Row 13 column 1 Row 13 column 2 Row 13 column 3 Row 13 column 4 Row 13 column 5 Row 13 column 6": + - cell "Row 13 column 1" + - cell "Row 13 column 2" + - cell "Row 13 column 3" + - cell "Row 13 column 4" + - cell "Row 13 column 5" + - cell "Row 13 column 6" + - row "Row 14 column 1 Row 14 column 2 Row 14 column 3 Row 14 column 4 Row 14 column 5 Row 14 column 6": + - cell "Row 14 column 1" + - cell "Row 14 column 2" + - cell "Row 14 column 3" + - cell "Row 14 column 4" + - cell "Row 14 column 5" + - cell "Row 14 column 6" + - row "Row 15 column 1 Row 15 column 2 Row 15 column 3 Row 15 column 4 Row 15 column 5 Row 15 column 6": + - cell "Row 15 column 1" + - cell "Row 15 column 2" + - cell "Row 15 column 3" + - cell "Row 15 column 4" + - cell "Row 15 column 5" + - cell "Row 15 column 6" + - row "Row 16 column 1 Row 16 column 2 Row 16 column 3 Row 16 column 4 Row 16 column 5 Row 16 column 6": + - cell "Row 16 column 1" + - cell "Row 16 column 2" + - cell "Row 16 column 3" + - cell "Row 16 column 4" + - cell "Row 16 column 5" + - cell "Row 16 column 6" + - row "Row 17 column 1 Row 17 column 2 Row 17 column 3 Row 17 column 4 Row 17 column 5 Row 17 column 6": + - cell "Row 17 column 1" + - cell "Row 17 column 2" + - cell "Row 17 column 3" + - cell "Row 17 column 4" + - cell "Row 17 column 5" + - cell "Row 17 column 6" + - row "Row 18 column 1 Row 18 column 2 Row 18 column 3 Row 18 column 4 Row 18 column 5 Row 18 column 6": + - cell "Row 18 column 1" + - cell "Row 18 column 2" + - cell "Row 18 column 3" + - cell "Row 18 column 4" + - cell "Row 18 column 5" + - cell "Row 18 column 6" + - row "Row 19 column 1 Row 19 column 2 Row 19 column 3 Row 19 column 4 Row 19 column 5 Row 19 column 6": + - cell "Row 19 column 1" + - cell "Row 19 column 2" + - cell "Row 19 column 3" + - cell "Row 19 column 4" + - cell "Row 19 column 5" + - cell "Row 19 column 6" + - row "Row 20 column 1 Row 20 column 2 Row 20 column 3 Row 20 column 4 Row 20 column 5 Row 20 column 6": + - cell "Row 20 column 1" + - cell "Row 20 column 2" + - cell "Row 20 column 3" + - 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 + - text: . +- 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 + 1 1 + 1 x +- text: typescript +- button "Copy" +- code: const value = "reasoning code" +- paragraph: {{longToken}} diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 9d58ad53b2..838f514348 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -36,6 +36,7 @@ const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history const UI_EXPANDED_EXPECTED = fileURLToPath( new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url), ) +const THINKING_EXPECTED = join(SNAPSHOT_DIR, 'thinking-expanded.expected.md') // Command-row goldens over the same conversation after direct host commands. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url)) const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url)) @@ -367,6 +368,40 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) }) + it.skipIf(MODE === 'record')('renders recorded reasoning as secondary Markdown when expanded', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-thinking')) + const turnProcess = page.locator('[data-turn-process]').first() + const wasExpanded = await turnProcess.getAttribute('aria-expanded') === 'true' + if (!wasExpanded) await turnProcess.click() + const thinking = page.locator('[data-variant="think"]').first() + const toggle = thinking.getByRole('button').first() + const secondarySize = await thinking.locator('[class*="summaryText"]').evaluate(element => getComputedStyle(element).fontSize) + await toggle.click() + try { + await thinking.locator('p').waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd) + const typography = await thinking.locator('p').evaluate((paragraph, expectedSize) => { + const style = getComputedStyle(paragraph) + return { + secondarySize: style.fontSize === expectedSize, + wraps: style.whiteSpace === 'normal', + contained: paragraph.scrollWidth <= paragraph.clientWidth + 1, + } + }, secondarySize) + expect(typography).toEqual({ secondarySize: true, wraps: true, contained: true }) + await compareOrRefreshGolden(THINKING_EXPECTED, [ + snapshot, + '', + `- Secondary font size: ${String(typography.secondarySize)}`, + `- Markdown paragraph wrapping: ${String(typography.wraps)}`, + `- Content stays within the reasoning column: ${String(typography.contained)}`, + ].join('\n'), MODE) + } finally { + await toggle.click() + if (!wasExpanded) await turnProcess.click() + } + }) + it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) @@ -780,7 +815,8 @@ describe('web e2e: seeded history renders through cold resume', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md', - 'session.v3.jsonl', 'sticky-geometry.expected.md', 'ui.expected.md', 'ui-expanded.expected.md', + 'session.v3.jsonl', 'sticky-geometry.expected.md', 'thinking-expanded.expected.md', + 'ui.expected.md', 'ui-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/thinking-markdown.e2e.ts b/apps/web/tests/thinking-markdown.e2e.ts new file mode 100644 index 0000000000..131f3dce24 --- /dev/null +++ b/apps/web/tests/thinking-markdown.e2e.ts @@ -0,0 +1,296 @@ +/** Expanded reasoning keeps Markdown semantics at the secondary typography tier. */ +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, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts' + +const EXPECTED_DIR = fileURLToPath(new URL('./expected/thinking-markdown', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./expected/thinking-markdown/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'thinking-markdown-web-e2e' +const DONE = 'THINKING_MARKDOWN_DONE' +const LONG_TOKEN = 'workspace/packages/client/secondary-markdown/'.repeat(12) + +/** Closed Session using semantic Markdown and content wider than the message column. */ +function thinkingFixture(): string { + const session = Session.create(SessionId('thinking-markdown-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show reasoning and a main answer.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Thinking Markdown', messageSeqs: [user.seq], source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + stream: [], turn: 1, step: 1, + message: createMessage({ + role: 'assistant', + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + content: [{ + type: 'reasoning', + text: [ + `## Compact reasoning ${'with a deliberately long summary '.repeat(8)}`, + '', + 'A paragraph with **strong text**, *emphasis*, [reference](https://example.com/), and `inline_code`.', + '', + ...[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', + '', + '- Loose first paragraph.', + '', + ' Loose middle paragraph.', + '', + ' Loose last paragraph.', + '', + '> Quoted reasoning.', + '', + '---', + '', + '| First | Second | Third | Fourth | Fifth | Sixth |', + '| --- | --- | --- | --- | --- | --- |', + `| ${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$.`, + '', + '$$', + `${'a+'.repeat(80)}\\frac{1}{1+\\frac{1}{x}}`, + '$$', + '', + '```typescript', + 'const value = "reasoning code"', + '```', + '', + LONG_TOKEN, + ].join('\n'), + }, { type: 'text', text: `# Main answer\n\n${DONE}` }], + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [JSON.stringify({ + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', + createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, + }), ...session.snapshotEvents().map(({ seq, time: _time, ...event }) => JSON.stringify({ + ...event, seq, time: new Date().setHours(12, 0, 0, 0) + seq * 1_000, + })), ''].join('\n') +} + +describe('web e2e: secondary Thinking Markdown', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, thinkingFixture(), SEED_ID) + browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByText(DONE, { exact: true }).waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + await page.waitForFunction(() => { + const frame = document.querySelector('[data-sidebar-collapsed]') + return frame !== null && getComputedStyle(frame).gridTemplateColumns.split(' ')[0] === '56px' + && frame.getAnimations().every(animation => animation.playState === 'finished' || animation.playState === 'idle') + }, undefined, { timeout: 10_000 }) + await expandTurnProcesses(page) + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('renders semantic blocks without promoting headings or overflowing the column', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-thinking-markdown')) + const thinking = page.locator('[data-variant="think"]') + const toggle = thinking.getByRole('button').first() + const summary = thinking.locator('[class*="summaryText"]') + const summaryStyle = await summary.evaluate((element) => { + const style = getComputedStyle(element) + return { fontSize: style.fontSize, lineHeight: style.lineHeight, color: style.color } + }) + expect(await summary.evaluate(element => element.getBoundingClientRect().height)) + .toBeLessThanOrEqual(Number.parseFloat(summaryStyle.lineHeight) + 1) + await toggle.click() + const markdown = thinking.locator('[data-markdown-variant="compact"]') + await markdown.locator('h1').waitFor({ timeout: 10_000 }) + expect(await markdown.locator('h1,h2,h3,h4,h5,h6').count()).toBe(7) + expect(await markdown.locator('ul').count()).toBe(2) + expect(await markdown.locator('ol').count()).toBe(1) + expect(await markdown.locator('strong').textContent()).toBe('strong text') + expect(await markdown.locator('em').textContent()).toBe('emphasis') + expect(await markdown.getByRole('link', { name: 'reference' }).getAttribute('href')).toBe('https://example.com/') + 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(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)) + .split(LONG_TOKEN).join('{{longToken}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + for (const width of [1680, 640]) { + await page.setViewportSize({ width, height: 1000 }) + await page.evaluate(async () => { await document.fonts.ready }) + const styles = await markdown.evaluate((root, secondary) => { + const elements = [...root.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,a,strong,em,pre,pre code,pre span,th,td,.katex')] + const scrollers = [...root.querySelectorAll('[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('.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.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 === 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('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('.katex-display')] + .every(element => element.scrollWidth > element.clientWidth), + } + }, summaryStyle) + expect(styles, `Thinking typography at ${String(width)}px`).toEqual({ + 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 }) + .evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)) + expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize)) + await page.setViewportSize({ width: 1680, height: 1000 }) + await page.locator('[data-conversation-scroll]').evaluate((host) => { + const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)') + if (row === null) throw new Error('tall Thinking table row missing') + host.scrollTop += row.getBoundingClientRect().top - host.getBoundingClientRect().top + }) + await expect.poll(() => toggle.evaluate((button) => { + const host = button.closest('[data-conversation-scroll]') + const table = button.closest('[data-variant="think"]')?.querySelector('table') + if (host === null || table === null || table === undefined) throw new Error('Thinking scroll context missing') + const buttonRect = button.getBoundingClientRect() + const tableRect = table.getBoundingClientRect() + const x = buttonRect.left + buttonRect.width / 2 + const y = buttonRect.top + buttonRect.height / 2 + return { + pinned: Math.abs(buttonRect.top - host.getBoundingClientRect().top) <= 1, + tableUnderHeader: tableRect.top < y && tableRect.bottom > y, + headerReceivesPointer: button.contains(document.elementFromPoint(x, y)), + } + }), { timeout: 5_000 }).toEqual({ pinned: true, tableUnderHeader: true, headerReceivesPointer: true }) + await toggle.click() + expect(await thinking.getAttribute('data-expanded')).toBeNull() + expect(await summary.evaluate(element => element.getBoundingClientRect().height)) + .toBeLessThanOrEqual(Number.parseFloat(summaryStyle.lineHeight) + 1) + expect(await summary.evaluate(element => getComputedStyle(element).textOverflow)).toBe('ellipsis') + + await page.setViewportSize({ width: 1680, height: 1000 }) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.locator('tr[data-trajectory-row-key]', { hasText: DONE }).click() + const details = page.getByRole('tabpanel') + const trajectoryToggle = details.getByRole('button', { name: 'Thinking', exact: true }) + if (await trajectoryToggle.getAttribute('aria-expanded') !== 'true') await trajectoryToggle.click() + 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']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 83b574f9bf..32ffccf9f0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -88,6 +88,7 @@ "tests/markdown-images.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", + "tests/thinking-markdown.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 1235210985..cc7799325f 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.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 docs/web-styling.md -web-styling.md: c158562f0c862e883514c9d4057c87d4df65119e -web-styling.zh.md: 7086bab6ff65c5a4415795aebd04da1e86e68c4f +web-styling.md: 1be0b629d2ccf54c5a00cfc8091a01e3d4d09bfd +web-styling.zh.md: 106880e964a399cba825e889b5e93048589d00e4 diff --git a/docs/web-styling.md b/docs/web-styling.md index c158562f0c..1be0b629d2 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -23,7 +23,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing. - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. -- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)). +- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md)). Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)). ## Changing the system diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 7086bab6ff..106880e964 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -23,7 +23,7 @@ - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。 - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 -- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。 +- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md))。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。 ## 变更系统 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 00cb0adb37..781dfd899c 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: faf5e8b955b46a84483ebfe0ec16c2a397522992 -README.zh.md: 3330ce4e37ad9b30dae3355b92430a55cdd736a6 +README.md: 0a3f5cad6e0bcd2da6f8ff993dea2e32a3e7e4a6 +README.zh.md: 028467df8ec75154f585fd7d928c51436e8b82c6 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index faf5e8b955..0a3f5cad6e 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -53,7 +53,7 @@ The completed-turn action footer starts 20px below the preceding prose or extens ## Turn Process Folding -Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete text; incoming answer text, Tool calls, and stream completion preserve that choice. The collapsed summary follows the latest reasoning line while streaming and shows the first line after settlement. +Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. The collapsed summary stays on one line, follows the latest reasoning line while streaming, and shows the first line after settlement. Settings → General exposes a persisted, localized `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 3330ce4e37..028467df8e 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -55,7 +55,7 @@ Assistant 回复结算后,已完成轮次的计时对话框不显示 TTFT 和 ## 轮次过程折叠 -每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整文本;后续回答文本、工具调用和流结束都保留用户的选择。折叠摘要在流式输出期间跟随最新思考行,结算后显示第一行。 +每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。折叠摘要保持单行,在流式输出期间跟随最新思考行,结算后显示第一行。 「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「标准」/「紧凑」对话显示偏好,默认使用「紧凑」。标准模式保持所有过程行可见且不渲染轮次过程控件。紧凑模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。 diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 20a786b271..3ca51f8d85 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -13,15 +13,8 @@ overflow: hidden; } -/* Think's body flows uncapped with the page (unlike the capped tool cards, - which scroll inside their own card), so a long reasoning chain carries the - disclosure header off the top of the viewport and buries the collapse toggle. - Pin the header to the conversation scroll container's top while the row is - open so the toggle stays reachable; the base background masks the prose - scrolling under it. Gated on DisclosureRow's `data-open` so a collapsed row - scrolls away normally. The body is plain text with no sticky descendant, so - any positive z-index clears the flow; the compaction marker, whose markdown - body can pin a code-block banner, needs a higher rank. */ +/* The uncapped body scrolls with the conversation; keep its collapse toggle + reachable. Compact Markdown code banners stay in flow below this header. */ .root[data-expanded] [data-open] [data-disclosure-row] { position: sticky; top: 0; @@ -107,11 +100,7 @@ .thinkBody { /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size-secondary, 13px); - line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); - white-space: pre-wrap; - word-break: break-word; + min-width: 0; } @media (prefers-reduced-motion: reduce) { diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 59705b2e4c..73289e8e3f 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -1,7 +1,8 @@ /** Assistant reasoning disclosure, independent of Tool-call presentation. */ -import { useState } from 'react' -import { DisclosureRow, IconThinkOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { useMemo, useState } from 'react' +import { DisclosureRow, IconThinkOutline14, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { markdownLabels } from '../markdown-labels.ts' import a11yCss from './accessibility.module.css' import css from './ReasoningRow.module.css' @@ -18,15 +19,16 @@ function latestLine(text: string): string { /** * Render one assistant reasoning block collapsed until the reader opens it. The - * collapsed summary omits double-asterisk markers; expanded content preserves - * the complete text. + * collapsed summary omits double-asterisk markers; expanded content renders + * the complete Markdown with secondary typography. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. - * @param props.t - conversation locale seat for the running status. + * @param props.t - conversation locale seat for status and Markdown actions. * @returns the reasoning disclosure. */ export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) { const [expanded, setExpanded] = useState(false) + const labels = useMemo(() => markdownLabels(t), [t]) const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') return ( @@ -57,7 +59,9 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool )} > -
{text}
+
+ +
) diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index cf84408caf..00e87bdcb6 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -21,12 +21,15 @@ function declarationsFrom(source: string, selector: string): string[] { describe('chat flow font-size axis', () => { it('think text reads the secondary tier (one step under the body size)', () => { const css = read('ReasoningRow.module.css') - for (const selector of ['.summary', '.thinkBody']) { - expect(declarationsFrom(css, selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size-secondary, 13px)', - 'line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))', - ])) - } + expect(declarationsFrom(css, '.summary')).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))', + 'white-space: nowrap', + ])) + expect(declarationsFrom(css, '.summaryText')).toEqual(expect.arrayContaining([ + 'overflow: hidden', + 'text-overflow: ellipsis', + ])) }) it('command and context summaries read the secondary tier on the shared row line', () => { diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 79c2ced780..dedfa9198f 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -106,7 +106,7 @@ describe('ReasoningRow', () => { text: 'Inspect the session\n**Comparing checkout and merge bases**', streaming: true, }, - ])('strips double-asterisk markers from the $label summary without changing the reasoning body', ({ text, streaming }) => { + ])('strips double-asterisk markers from the $label summary and renders body emphasis', ({ text, streaming }) => { const view = render( { expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull() fireEvent.click(view.getByText('思考')) - expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text) + expect(view.getByText('Comparing checkout and merge bases').tagName).toBe('STRONG') + expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).not.toContain('**') }) - it('expanded Think drops the inline summary and renders plain prose, no IN card', () => { + it('keeps heading syntax in the collapsed summary and renders compact headings when expanded', () => { + const text = Array.from({ length: 6 }, (_, index) => `${'#'.repeat(index + 1)} Section ${index + 1}`) + .join('\n\n') + '\n\nReasoning body.' + const view = render( + , + ) + const summary = view.getByText('# Section 1') + expect(summary.tagName).toBe('SPAN') + expect(view.queryByRole('heading')).toBeNull() + + fireEvent.click(summary) + const compact = view.container.querySelector('[data-markdown-variant="compact"]') + expect(compact).not.toBeNull() + expect(compact?.querySelectorAll('h1, h2, h3, h4, h5, h6')).toHaveLength(6) + expect(compact?.querySelector('p')?.textContent).toBe('Reasoning body.') + + fireEvent.click(view.getByText('思考')) + expect(view.getByText('# Section 1').tagName).toBe('SPAN') + expect(view.queryByRole('heading')).toBeNull() + }) + + it('keeps completed reasoning blocks mounted while the open streaming tail grows', () => { + const first = '## Investigation\n\n**Check persistence**\n\n' + const view = render( + , + ) + fireEvent.click(view.getByText('思考')) + const heading = view.getByRole('heading', { name: 'Investigation' }) + const emphasis = view.getByText('Check persistence') + const text = first + Array.from({ length: 8 }, (_, index) => `Paragraph ${index}.`).join('\n\n') + view.rerender( + , + ) + expect(view.getByRole('heading', { name: 'Investigation' })).toBe(heading) + expect(view.getByText('Check persistence')).toBe(emphasis) + expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).not.toContain('##') + }) + + it('expanded Think drops the inline summary and renders prose without an IN card', () => { const view = render( p { + margin: 4px 0; +} + +.compact li::marker { + line-height: inherit; + color: inherit; +} + +.compact blockquote { + padding-left: 8px; +} + +.compact hr { + margin: 8px 0; +} + +.compact a, +.compact .fileMention { + font-weight: inherit; + color: inherit; + text-decoration: underline dotted; +} + +.compact a:hover, +.compact a:focus, +.compact .fileMention:hover, +.compact .fileMention:focus { + text-decoration-color: currentColor; +} + +.compact :not(pre) > code { + display: inline; + font: inherit; + font-family: var(--ds-font-family-code); + font-size: 1em !important; + border-radius: 4px; + padding: 0 3px; + box-decoration-break: clone; +} + +.markdown.compact :global(.md-code-block) { + margin: 4px 0; + color: inherit; +} + +.compact :global(.md-code-block) > :has(> [data-code-block-banner]) { + position: static; + z-index: auto; +} + +.compact [data-code-block-banner] { + padding: 4px 8px; + font: inherit; +} + +.compact [data-code-block-banner] > :first-child { + font-size: inherit; + line-height: inherit; + color: inherit; +} + +.compact pre { + margin: 4px 0; + padding: 8px; + font-size: inherit; + line-height: inherit; +} + +/* Shiki writes token colors inline; compact code uses the surrounding text color. */ +.compact pre, +.compact pre :is(code, span) { + color: inherit !important; +} + +.markdown.compact .tableScroll { + width: 100%; + max-width: 100%; + margin: 4px 0; + padding-left: 0; +} + +.markdown.compact .tableScroll > table { + z-index: auto; +} + +.compact :is(th, td) { + padding: 4px 8px; + font: inherit; +} + +.compact th { + font-weight: 600; +} + +.compact :global(.katex) { + 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; + /* KaTeX descenders can paint one pixel below a fractional line-box edge. */ + padding-bottom: 1px; +} + +.compact :global(.katex-display) { + margin: 4px 0; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 121c6ff49b..8eace9bbe2 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -13,6 +13,7 @@ import { memo, useMemo, useRef } from 'react' import type { ReactNode } from 'react' +import clsx from 'clsx' import { IncrementalMarkdownParser } from './incremental.ts' import { parseGfm, parseGfmWithMath } from './parse.ts' import { @@ -159,17 +160,22 @@ class StreamingRenderer { * displayable URLs its resolver vouches for; both vocabularies are the * single streaming gate — they apply to settled renders only, because a * streaming message's vocabulary is not final and frozen cached elements - * must not bake in handlers that could go stale. + * must not bake in handlers that could go stale. `variant="compact"` uses + * secondary text sizing, uniform bold headings, and tight block spacing; + * the default `body` variant uses the full document typography. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, * relative links, and unsafe protocols are disabled, while absolute HTTP(S) * images render directly. */ -export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions, pathImages }: { +export const MarkdownText = memo(function MarkdownText({ + text, streaming = false, labels, fileMentions, pathImages, variant = 'body', +}: { text: string streaming?: boolean labels: MarkdownLabels fileMentions?: MarkdownFileMentions | undefined pathImages?: MarkdownPathImages | undefined + variant?: 'body' | 'compact' }) { const streamRef = useRef(null) const streamLabelsRef = useRef(labels) @@ -184,5 +190,6 @@ export const MarkdownText = memo(function MarkdownText({ text, streaming = false } return streamRef.current.render(text) }, [text, streaming, labels, fileMentions, pathImages]) - return
{children}
+ return
{children}
}) diff --git a/packages/client/ui-primitives/tests/markdown-compact.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-compact.client.spec.tsx new file mode 100644 index 0000000000..d36214aca0 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-compact.client.spec.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MarkdownText } from './markdown-test-components.tsx' + +afterEach(cleanup) + +describe('compact MarkdownText', () => { + it('retains the body variant semantics for headings, lists, quotes, links, code, tables, and math', () => { + const text = [ + '# First', '## Second', '### Third', '#### Fourth', '##### Fifth', '###### Sixth', + 'Paragraph with **strong** and `inline`.', + '- one\n- two\n - nested', + '1. ordered\n2. next', + '> quoted', + '[reference](https://example.com)', + '```ts\nconst answer = 42\n```', + '---', + '| Name | Value |\n| --- | --- |\n| answer | 42 |', + '$x^2$', + '$$\nx + y = z\n$$', + ].join('\n\n') + const body = render() + const compact = render() + const bodyRoot = body.container.firstElementChild! + const compactRoot = compact.container.firstElementChild! + + expect(bodyRoot.hasAttribute('data-markdown-variant')).toBe(false) + expect(compactRoot.getAttribute('data-markdown-variant')).toBe('compact') + expect(compactRoot.innerHTML).toBe(bodyRoot.innerHTML) + expect(compactRoot.querySelectorAll('h1, h2, h3, h4, h5, h6')).toHaveLength(6) + expect(compactRoot.querySelectorAll('ul')).toHaveLength(2) + expect(compactRoot.querySelector('ol')).not.toBeNull() + expect(compactRoot.querySelector('strong')?.textContent).toBe('strong') + expect(compactRoot.querySelector('p > code')?.textContent).toBe('inline') + expect(compactRoot.querySelector('blockquote')?.textContent?.trim()).toBe('quoted') + expect(compactRoot.querySelector('a')?.getAttribute('href')).toBe('https://example.com') + expect(compactRoot.querySelector('pre code')?.textContent).toBe('const answer = 42') + expect(compactRoot.querySelector('hr')).not.toBeNull() + expect(compactRoot.querySelector('table')).not.toBeNull() + expect(compactRoot.querySelectorAll('.katex')).toHaveLength(2) + }) + + it('keeps frozen blocks and highlighted lines mounted across growth and variant changes', () => { + const prefix = '# Heading\n\n**Completed** paragraph.\n\n- First\n\n' + const initial = `${prefix}\`\`\`ts\nconst first = 1\nlet tail` + const grown = `${initial} = 2\n\`\`\`` + const live = render() + const heading = live.container.querySelector('h1') + const paragraph = live.container.querySelector('p') + const firstLine = live.container.querySelector('pre .line') + + expect(heading?.textContent).toBe('Heading') + expect(paragraph?.querySelector('strong')?.textContent).toBe('Completed') + expect(firstLine?.textContent).toBe('const first = 1') + + live.rerender() + expect(live.container.querySelector('h1')).toBe(heading) + expect(live.container.querySelector('p')).toBe(paragraph) + expect(live.container.querySelector('pre .line')).toBe(firstLine) + expect(live.container.querySelector('pre code')?.textContent).toBe('const first = 1\nlet tail = 2') + + live.rerender() + expect(live.container.firstElementChild?.hasAttribute('data-markdown-variant')).toBe(false) + expect(live.container.querySelector('h1')).toBe(heading) + expect(live.container.querySelector('pre .line')).toBe(firstLine) + + live.rerender() + expect(live.container.querySelector('h1')).toBe(heading) + expect(live.container.querySelector('pre .line')).toBe(firstLine) + expect(live.container.textContent).not.toContain('**Completed**') + }) +}) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 1dae344665..e930caa64a 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: 63bd664b902d8d40f8a05153770306d3b03dbe4e -README.zh.md: 89db03a99ac68065211f92ff7e47307813c1ab76 +README.md: 02cbf402a702a4a51e9e3a122e3df13c4eb4e772 +README.zh.md: 00971c0710e1620b97fc51ae03486a9114629a49 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 63bd664b90..02cbf402a7 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -33,6 +33,8 @@ 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 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 Historical replies retain TTFT, generation duration, and throughput when their recorded streams contain token timestamps. TTFT measures from the Step start to its first token, including output from an earlier retry attempt; an unloaded Step start or a stream without tokens leaves the corresponding metric unavailable. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 89db03a99a..00971c0710 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -33,6 +33,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +检查器中的思考内容使用紧凑 Markdown,沿用检查器固定的 13px 字号和 20px 行高,不受内容字号设置影响。标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。 + ### 时间概览 当记录的流包含 token 时间戳时,历史回复会保留 TTFT、生成耗时与吞吐量。TTFT 从步骤开始计量到首个 token,包括更早重试尝试中的输出;步骤起点尚未加载或流中没有 token 时,对应指标保持不可用。 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index c743e0e503..a679c82569 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1423,70 +1423,70 @@ color: var(--dsw-alias-label-primary); } -.markdownPreview > div, -.markdownPayload > div { +.markdownPreview > div:not([data-markdown-variant='compact']), +.markdownPayload > div:not([data-markdown-variant='compact']) { gap: 8px; font: var(--dsw-font-xs-13); } -.markdownPreview > div h1, -.markdownPayload > div h1 { +.markdownPreview > div:not([data-markdown-variant='compact']) h1, +.markdownPayload > div:not([data-markdown-variant='compact']) h1 { font: 600 16px/22px var(--dsw-font-family); } -.markdownPreview > div h2, -.markdownPayload > div h2 { +.markdownPreview > div:not([data-markdown-variant='compact']) h2, +.markdownPayload > div:not([data-markdown-variant='compact']) h2 { font: 600 15px/22px var(--dsw-font-family); } -.markdownPreview > div :where(h3, h4, h5, h6), -.markdownPayload > div :where(h3, h4, h5, h6) { +.markdownPreview > div:not([data-markdown-variant='compact']) :where(h3, h4, h5, h6), +.markdownPayload > div:not([data-markdown-variant='compact']) :where(h3, h4, h5, h6) { font: 600 14px/20px var(--dsw-font-family); } -.markdownPreview > div :not(pre) > code, -.markdownPayload > div :not(pre) > code { +.markdownPreview > div:not([data-markdown-variant='compact']) :not(pre) > code, +.markdownPayload > div:not([data-markdown-variant='compact']) :not(pre) > code { font: 12px/18px var(--ds-font-family-code); } -.markdownPreview > div table, -.markdownPayload > div table { +.markdownPreview > div:not([data-markdown-variant='compact']) table, +.markdownPayload > div:not([data-markdown-variant='compact']) table { font: var(--dsw-font-xs-13); } -.markdownPreview > div > :first-child { +.markdownPreview > div:not([data-markdown-variant='compact']) > :first-child { margin-top: 0; } -.markdownPreview > div > :last-child { +.markdownPreview > div:not([data-markdown-variant='compact']) > :last-child { margin-bottom: 0; } -.markdownPreview > div :where(h1, h2, h3) { +.markdownPreview > div:not([data-markdown-variant='compact']) :where(h1, h2, h3) { margin: 12px 0 6px; } -.markdownPreview > div :where(h4, h5, h6) { +.markdownPreview > div:not([data-markdown-variant='compact']) :where(h4, h5, h6) { margin: 10px 0 5px; } -.markdownPreview > div :where(p, ul, ol) { +.markdownPreview > div:not([data-markdown-variant='compact']) :where(p, ul, ol) { margin: 8px 0; } -.markdownPreview > div li:not(:first-child) { +.markdownPreview > div:not([data-markdown-variant='compact']) li:not(:first-child) { margin-top: 3px; } -.markdownPreview > div blockquote { +.markdownPreview > div:not([data-markdown-variant='compact']) blockquote { margin-top: 8px; } -.markdownPreview > div hr { +.markdownPreview > div:not([data-markdown-variant='compact']) hr { margin: 14px 0; } -.markdownPreview > div > :global(.md-code-block) { +.markdownPreview > div:not([data-markdown-variant='compact']) > :global(.md-code-block) { margin: 10px 0; } @@ -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); diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 6babf88c4e..c5ae3ad812 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1130,18 +1130,20 @@ function MarkdownFragment({ text, rendered, preview, + variant = 'body', t, }: { text: string rendered: boolean preview: boolean + variant?: 'body' | 'compact' t: TrajectoryTranslate }) { const labels = useMemo(() => markdownLabels(t), [t]) if (rendered) { return (
- +
) } @@ -1521,6 +1523,7 @@ function MarkdownRecordContent({ text={record.cell.thinkingDetail} rendered={rendered} preview={preview} + variant="compact" t={t} /> )} diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 5a882141bb..503e19eab7 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -273,6 +273,36 @@ describe('TrajectoryTable', () => { expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') }) + it('renders thinking as compact Markdown while keeping answer typography separate', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: 'Answer', + outputDetail: '# Answer heading\n\nAnswer body.', + thinkingDetail: '# Thinking heading\n\nReasoning **emphasis**.', + timeSeconds: 1, + }], + }], + }] + render() + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Preview' })) + + expect(screen.getByRole('heading', { name: 'Thinking heading' }) + .closest('[data-markdown-variant="compact"]')).not.toBeNull() + expect(screen.getByText('emphasis').tagName).toBe('STRONG') + expect(screen.getByRole('heading', { name: 'Answer heading' }) + .closest('[data-markdown-variant="compact"]')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.queryByRole('heading', { name: 'Thinking heading' })).toBeNull() + expect(screen.getByRole('heading', { name: 'Answer heading' })).toBeTruthy() + }) + it('opens thinking on another record after collapsing the selected record', () => { const turns: readonly TrajectoryTurnModel[] = [{ turn: 1, diff --git a/snapshots/web/seeded-history/thinking-expanded.expected.md b/snapshots/web/seeded-history/thinking-expanded.expected.md new file mode 100644 index 0000000000..cc2dd82a38 --- /dev/null +++ b/snapshots/web/seeded-history/thinking-expanded.expected.md @@ -0,0 +1,8 @@ +- button "Think" [expanded]: + - img + - text: Think +- paragraph: The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. + +- Secondary font size: true +- Markdown paragraph wrapping: true +- Content stays within the reasoning column: true diff --git a/tsconfig.host.json b/tsconfig.host.json index 915d2d4667..51bf1b7689 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -79,6 +79,7 @@ "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/thinking-markdown.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",