diff --git a/apps/web/tests/reasoning-spacing.e2e.ts b/apps/web/tests/reasoning-spacing.e2e.ts new file mode 100644 index 0000000000..aa3b3935a4 --- /dev/null +++ b/apps/web/tests/reasoning-spacing.e2e.ts @@ -0,0 +1,119 @@ +/** Reasoning rows of one Assistant message keep the 4px process-row gap, and display-empty reasoning adds none. */ +import { chromium } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, seedSession, watchConsole } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +type Block = readonly ['reasoning' | 'signature' | 'text' | 'bash', string] + +/** Turn 1: reasoning around a signature-only block. Turn 2: signature-only blocks before and after reasoning. */ +const TURNS: readonly (readonly (readonly Block[])[])[] = [ + [[['reasoning', 'a'], ['signature', 's1'], ['reasoning', 'b'], ['bash', 'k1']], [['text', 'Answer one']]], + [ + [['reasoning', 'c'], ['bash', 'k2']], + [['signature', 's2'], ['signature', 's3'], ['reasoning', 'd'], ['bash', 'k3']], + [['reasoning', 'e'], ['signature', 's4'], ['signature', 's5'], ['bash', 'k4']], + [['text', 'Answer two']], + ], +] + +function bashArguments(label: string): string { + return JSON.stringify({ command: `echo ${label}`, description: `Run ${label}` }) +} + +function content([kind, label]: Block): Record { + switch (kind) { + case 'reasoning': return { type: 'reasoning', text: `Thinking ${label}\n\nDetails for ${label}.` } + case 'signature': return { type: 'reasoning', text: `${label}` } + case 'text': return { type: 'text', text: label } + case 'bash': return { + type: 'tool-call', id: `call_${label}`, name: 'bash', arguments: bashArguments(label), + } + } +} + +/** One closed Session whose Assistant messages carry the block layouts above. */ +function sessionLog(): string { + const header = { + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', + isSeeded: false, delegationDepth: 0, + } + const events: Record[] = [] + let message = 0 + const id = (): string => `{{message:${String(++message)}}}` + TURNS.forEach((steps, index) => { + const turn = index + 1 + events.push({ type: 'turn/start', data: { turn } }) + steps.forEach((blocks, stepIndex) => { + const step = stepIndex + 1 + events.push({ type: 'step/start', data: { turn, step } }) + if (step === 1) { + events.push({ + type: 'user/message', surfaceOp: 'append', + data: { content: [{ type: 'text', text: `Turn ${String(turn)}` }], source: { kind: 'user' }, role: 'user', id: id() }, + }) + } + events.push({ + type: 'assistant/message', surfaceOp: 'append', + data: { + turn, step, stream: [], + message: { + role: 'assistant', content: blocks.map(content), id: id(), + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }, + }, + }) + for (const [kind, label] of blocks) { + if (kind !== 'bash') continue + const callId = `call_${label}` + events.push({ type: 'tool/call', data: { turn, step, callId, name: 'bash', arguments: bashArguments(label) } }) + events.push({ + type: 'tool/result', surfaceOp: 'append', sourceEventSeqs: [events.length - 1], + data: { + turn, step, meta: { cwd: '{{cwd}}' }, + message: { + role: 'tool', source: { kind: 'tool', callId }, toolCallId: callId, + content: [{ type: 'text', text: `${label}\n` }], isError: false, id: id(), + }, + }, + }) + } + events.push({ type: 'step/end', data: { turn, step } }) + }) + events.push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + }) + return [header, ...events].map(line => JSON.stringify(line)).join('\n') +} + +it('keeps 4px between reasoning and tool rows around display-empty reasoning blocks', async () => { + const scaffold = await launchWebScaffold({}) + onTestFinished(() => scaffold.close()) + await scaffold.ctx.settings.update('ui-chat', { transcriptView: 'verbose' }) + await seedSession(scaffold, sessionLog(), 'reasoning-spacing') + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByText('Answer two', { exact: true }).waitFor() + + // Rows in reading order; signature-only reasoning renders no row of its own. + const rows = await page.evaluate(() => [...document.querySelectorAll('[data-variant="think"], [data-chat-call-id]')] + .filter(row => row.getBoundingClientRect().height > 0) + .map((row) => { + const { top, bottom } = row.getBoundingClientRect() + const name = row.matches('[data-variant="think"]') + ? /Thinking (\w+)/.exec(row.textContent ?? '')?.[1] + : row.getAttribute('data-chat-call-id')?.replace('call_', '') + return { name: name ?? '?', top, bottom } + }) + .sort((left, right) => left.top - right.top)) + expect(rows.map(row => row.name)).toEqual(['a', 'b', 'k1', 'c', 'k2', 'd', 'k3', 'e', 'k4']) + const gaps = Object.fromEntries(rows.slice(1).map((row, index) => + [`${rows[index]!.name}→${row.name}`, Math.round(row.top - rows[index]!.bottom)])) + expect(gaps).toMatchObject({ 'a→b': 4, 'b→k1': 4, 'c→k2': 4, 'k2→d': 4, 'd→k3': 4, 'k3→e': 4, 'e→k4': 4 }) + expect(console.pageErrors).toEqual([]) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9e6e59f142..a70870d5ec 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -89,6 +89,7 @@ "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", "tests/reasoning-preview.e2e.ts", + "tests/reasoning-spacing.e2e.ts", "tests/details-session-lifecycle.e2e.ts", "tests/sessionless-header.e2e.ts", "tests/document-preview.e2e.ts", diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 0ea68825c1..6ce0a71af9 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -33,8 +33,8 @@ en: 7cc6692b879aa500 zh: e924733c88b526c9 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: e2b02942a55fb669 - zh: a38bbee0cc69ae4e + en: f6d8bbae13a1a8f8 + zh: 96c8f400c215e33d /deepseek-ai-dsh-client-ui-chat/scroll-ownership: en: 1b12aa6c505dde3b zh: 88e1654dd63816d7 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 672da9097d..1a1869e4bd 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -127,7 +127,7 @@ Live tool deltas share reasoning's frame-batched publication; durable calls and The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. The `conversation.chat.flow` slot adds a `display: contents` DOM anchor; row selectors match that anchor directly, without repeating its column ancestry. The running status shares the rows' parent, so its divider uses the same visible-sibling rules. The anchor contributes no spacing of its own. The earlier-history control directly precedes the flow anchor; their sibling relationship supplies the leading gap. CSS variables do not belong in the Group Definition. -Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Ordinary flow rows sit 4px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. +Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Ordinary flow rows sit 4px apart, including adjacent reasoning rows of one Assistant message. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The APNG is a [package asset](../AGENTS.md#dependency-declaration) referenced by the plugin stylesheet; React does not pass image data through DOM styles. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 29a1951662..8f2648fd2d 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -127,7 +127,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器直接匹配该锚点,不重复匹配其正文容器祖先。运行状态与各行共享父级,使分隔线使用同一套可见兄弟节点规则。锚点自身不产生间距。更早历史控件紧邻流锚点之前,两者的兄弟关系决定首行间距。CSS 变量不属于 Group Definition。 -运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。普通流程行之间相隔 4px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 +运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。普通流程行之间相隔 4px,同一条 Assistant 消息里相邻的推理行也是如此;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。APNG 是插件样式表引用的[包资源](../AGENTS.md#dependency-declaration);React 不通过 DOM 样式传递图片数据。 diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css index 990aa985f6..6441ce20c6 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css @@ -15,9 +15,17 @@ } .body { + --dsh-assistant-block-gap: 16px; display: flex; flex-direction: column; - gap: 16px; + gap: var(--dsh-assistant-block-gap); +} + +/* A step-group reasoning part (its Chat seat carries data-chat-group-part) + holds only reasoning rows, which are process rows: they sit 4px apart, like + rows of different steps. */ +:global([data-chat-group-part="reasoning"]) .body { + --dsh-assistant-block-gap: 4px; } /* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span @@ -50,11 +58,13 @@ z-index: 1; } -/* hidden="until-found" keeps a zero-height reasoning box in flex layout. - Cancel the one gap that box would otherwise leave before the visible reply; - visible Assistant blocks retain the ordinary 16px rhythm. */ -.body > [data-turn-process-inline][hidden] { - margin-bottom: -16px; +/* hidden="until-found" keeps a zero-height reasoning box in flex layout, and + so does the class-less wrapper of a reasoning block whose row renders + nothing (signature-only reasoning has no display text). Cancel the one gap + each box would otherwise leave. */ +.body > [data-turn-process-inline][hidden], +.body > :empty:not([class]) { + margin-bottom: calc(-1 * var(--dsh-assistant-block-gap)); } /* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed diff --git a/tsconfig.host.json b/tsconfig.host.json index 9480842554..21ad8213c5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -79,6 +79,7 @@ "apps/web/tests/trajectory-virtualization.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", "apps/web/tests/reasoning-preview.e2e.ts", + "apps/web/tests/reasoning-spacing.e2e.ts", "apps/web/tests/details-session-lifecycle.e2e.ts", "apps/web/tests/sessionless-header.e2e.ts", "apps/web/tests/document-preview.e2e.ts",