diff --git a/apps/web/tests/chat-flow-layout.e2e.ts b/apps/web/tests/chat-flow-layout.e2e.ts new file mode 100644 index 0000000000..d3d5e999ac --- /dev/null +++ b/apps/web/tests/chat-flow-layout.e2e.ts @@ -0,0 +1,73 @@ +/** Cold history retains Chat spacing through the flow slot's display:contents anchor. */ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold, seedSession, selectedSessionFixture, watchConsole } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +it.each(['turn-tail-actions', 'goal-multi-turn-actions', 'present'] as const)( + 'preserves process and answer spacing in cold %s history', async (scenario) => { + const scaffold = await launchWebScaffold({}) + onTestFinished(() => scaffold.close()) + if (scenario === 'present') { + await writeFile(join(scaffold.workspaceCwd, 'report.txt'), 'DELIVERED_REPORT\n') + await writeFile(join(scaffold.workspaceCwd, '说明.txt'), 'DELIVERED_NOTE\n') + } + const fixture = await selectedSessionFixture(fileURLToPath( + new URL(`../../../snapshots/web/${scenario}/session.v3.jsonl`, import.meta.url), + )) + await seedSession(scaffold, await readFile(fixture, 'utf8'), `flow-layout-${scenario}`) + 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() + const controls = page.locator('[data-turn-process]') + await expect.poll(() => controls.count()).toBe(scenario === 'goal-multi-turn-actions' ? 2 : 1) + const process = controls.first() + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await process.evaluate(element => element.closest('[data-slot="conversation.chat.flow"]') !== null)).toBe(true) + + if (scenario === 'goal-multi-turn-actions') { + const trigger = page.locator('[data-turn-trigger]').first() + await process.click() + for (const fontSize of [10, 14, 22]) { + 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 group = page.locator('[data-process-activity]:visible').first() + expect(await process.evaluate(element => element.getBoundingClientRect().top)) + .toBe(await trigger.evaluate(element => element.getBoundingClientRect().bottom) + 16) + expect(await group.evaluate(element => element.getBoundingClientRect().top)) + .toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16) + } + } else { + const answer = page.getByText(scenario === 'present' ? 'PRESENT_DONE' : 'DONE', { exact: true }) + await answer.waitFor() + const answerBounds = await answer.evaluate((element) => { + const seat = element.closest('[data-chat-flow-kind="assistant-step"]') + if (seat === null) throw new Error('final answer has no Chat seat') + const { top, bottom } = seat.getBoundingClientRect() + return { top, bottom } + }) + expect(answerBounds.top).toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16) + if (scenario === 'present') { + const geometry = await page.locator('[data-presented-files-row]').evaluate((grid) => { + const root = grid.parentElement + const tail = root?.closest('[data-turn-tail]') + const actions = tail?.querySelector('button[aria-label="Copy"]')?.parentElement + if (root === null || actions == null) throw new Error('presented files have no Turn footer') + return { top: root.getBoundingClientRect().top, bottom: root.getBoundingClientRect().bottom, + actionsTop: actions.getBoundingClientRect().top } + }) + expect(geometry.top - answerBounds.bottom).toBeCloseTo(16, 1) + expect(geometry.actionsTop - geometry.bottom).toBeCloseTo(20, 1) + } + } + expect(console.pageErrors).toEqual([]) + }, +) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4b226670b4..9c0350a2be 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,7 @@ // cannot see both sides of the cordis Context merges). "exclude": [ "tests/collapse-timing.e2e.ts", + "tests/chat-flow-layout.e2e.ts", "tests/layout-bottom.e2e.ts", "tests/shortcuts-desktop.e2e.ts", "tests/shortcuts-workspace.e2e.ts", diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 5733ff0370..696c60b4a7 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -33,11 +33,11 @@ en: 0c63ffa8f245050e zh: f421d1a569b16c3d /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: dce41efbcf28da6c - zh: 574dd206e82d8457 + en: 3c5ba298bdfa4aee + zh: 34f2c4efd11c8b16 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: b2e13046e81fab44 - zh: db7e02ddaca5e11e + en: 79f0abd1c42d229f + zh: ce695d9fd9626fc8 /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 3d7322eddf..db9f42e0e9 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -99,7 +99,7 @@ The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belon Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration. -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. CSS variables do not belong in the Group Definition. +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 cross that anchor, which contributes no spacing of its own. 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. Tool and process rows sit 6px 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 around them. 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. @@ -154,7 +154,7 @@ Within a process group, wheel, touchstart, and any pointerdown interrupt an acti `useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reading gestures release the paging anchor, but composer clicks, typing, and non-scrolling keys retain it; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test. -Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the content column's direct Node/Group boxes and retains the preceding candidate in gaps. It neither hit-tests the document nor searches Group members or all Turn markers. Empty Seats retain zero-height in-flow boxes so outer positions remain ordered without extra spacing. This lookup does not change semantic position capture or paging compensation. +Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the flow slot's direct Node/Group boxes (or the column's in standalone rendering) and retains the preceding candidate in gaps. It neither hit-tests the document nor searches Group members or all Turn markers. Empty Seats retain zero-height in-flow boxes so outer positions remain ordered without extra spacing. This lookup does not change semantic position capture or paging compensation. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index dd0da1e0fe..aad611f756 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -99,7 +99,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载时 实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。 -过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。 +过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。`conversation.chat.flow` 插槽添加一个 `display: contents` DOM 锚点;行选择器跨过该锚点,锚点自身不产生间距。CSS 变量不属于 Group Definition。 运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件周围保留 16px。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。 @@ -154,7 +154,7 @@ Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` `useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。阅读手势释放分页锚点,composer 内的点击、打字和非滚动按键则保留锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。 -活跃轮次高亮采用近似定位:`readVisibleTurn` 对正文容器的直属 Node/Group 布局盒二分,落在间隙时保留前方候选。它不做文档命中测试,也不查询 Group 成员或全部 Turn 标记。空 Seat 保留零高度的流内布局盒,使外层位置有序且不增加间距。这种定位不改变语义位置捕获或分页补偿。 +活跃轮次高亮采用近似定位:`readVisibleTurn` 对 flow 插槽的直属 Node/Group 布局盒二分(独立渲染时使用正文容器的直属布局盒),落在间隙时保留前方候选。它不做文档命中测试,也不查询 Group 成员或全部 Turn 标记。空 Seat 保留零高度的流内布局盒,使外层位置有序且不增加间距。这种定位不改变语义位置捕获或分页补偿。 diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 364337b268..86de49fc76 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -62,11 +62,13 @@ flex-direction: column; } -/* `hidden="until-found"` retains a zero-height box so browser find can reveal - its subtree. Direct Chat Node Seats and auxiliary rows share one flow; - hidden and empty Seats do not contribute spacing. */ -.column > :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) - ~ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) { +/* The flow slot's display:contents anchor preserves layout but not selector ancestry. + `hidden="until-found"` retains a searchable zero-height box; hidden and empty + Seats do not contribute spacing. */ +:is(.column, .column > :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) + ~ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))), +.column > .older ~ :global([data-slot="conversation.chat.flow"]) + > :nth-child(1 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))) { margin-top: var(--dsh-chat-flow-gap, 6px); } @@ -76,7 +78,7 @@ --dsh-chat-flow-gap: 12px; } -.column > [data-chat-group-part="response"]:not([hidden]):not(:empty) +:is(.column, .column > :global([data-slot="conversation.chat.flow"])) > [data-chat-group-part="response"]:not([hidden]):not(:empty) + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running) { --dsh-chat-flow-gap: 12px; } @@ -94,12 +96,14 @@ } /* While a Turn folds, spacing changes share the closing rows' timing. */ -.column[data-chat-motion] > :not([data-chat-motion]) { +.column[data-chat-motion] > :not([data-chat-motion]), +.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) { transition: margin-top 160ms cubic-bezier(0.4, 0, 0.2, 1); } @media (prefers-reduced-motion: reduce) { - .column[data-chat-motion] > :not([data-chat-motion]) { + .column[data-chat-motion] > :not([data-chat-motion]), + .column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) { transition: none; } } @@ -107,7 +111,7 @@ /* The Turn header has 16px clearance from its input and following content. */ .flowItem[data-chat-flow-kind="turn-process"], .flowItem[data-turn-process-answer], -.column > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running) { +:is(.column, .column > :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running) { --dsh-chat-flow-gap: 16px; } @@ -146,7 +150,12 @@ .column > :nth-last-child(2 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is( .flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]), [data-chat-group-key] -) ~ .running > .runningDivider { +) ~ .running > .runningDivider, +.column:has(> :global([data-slot="conversation.chat.flow"]) + > :nth-last-child(1 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is( + .flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]), + [data-chat-group-key] + )) > .running > .runningDivider { display: block; } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index 5653a3024c..b5ce2d4a62 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -286,7 +286,8 @@ export class ChatViewport { const first = this.turns[0] if (elements === null || metrics === null || first === undefined) return null const line = elements.scroller.getBoundingClientRect().top + Math.min(96, metrics.height * 0.2) - const rows = elements.column.children + // Slot anchors use display:contents: measure their rows, not the anchor's empty box. + const rows = (elements.column.querySelector(':scope > [data-slot="conversation.chat.flow"]') ?? elements.column).children let low = 0 let high = rows.length let reading = first.turn 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 cf192daa49..0f8b56ced4 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 @@ -161,7 +161,7 @@ describe('chat flow font-size axis', () => { const empty = declarationsFrom(css, emptySelector) expect(empty).toContain('height: 0') expect(empty).not.toContain('display: none') - expect(css).toContain(`.column > :not([hidden]):not(${emptySelector})`) + expect(css).toContain(`:is(.column, .column > :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(${emptySelector})`) expect(css).toContain(`~ :not([hidden]):not(${emptySelector})`) }) diff --git a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts index 7dd39a8698..def772efb8 100644 --- a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts @@ -48,10 +48,19 @@ function fixture() { return { column, viewport, group, row, prepend: (height: number) => { prependedHeight += height } } } -it.each([64, 4096])('locates the reading Turn with logarithmic outer-row measurements (%s rows)', (count) => { +it.each([ + { count: 64, slot: false }, { count: 4096, slot: false }, + { count: 64, slot: true }, { count: 4096, slot: true }, +])('locates the reading Turn with logarithmic outer-row measurements ($count rows, slot=$slot)', ({ count, slot }) => { const h = fixture() + const parent = slot ? document.createElement('div') : h.column + if (slot) { + parent.dataset.slot = 'conversation.chat.flow' + parent.style.display = 'contents' + h.column.append(parent) + } const rows = Array.from({ length: count }, (_, index) => { - const row = h.row(h.column, `turn-${index + 1}`, index * 100) + const row = h.row(parent, `turn-${index + 1}`, index * 100) row.dataset.chatTurn = String(index + 1) return row }) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 6efda51bd0..a4d48030dc 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -459,9 +459,6 @@ "packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx": { "ab22ebe23f59b5aa76d42502a0421f3953d1d170e8a4f900a0c442daf6f1397d": 1 }, - "packages/client/ui-chat/src/client/chat/ChatView.tsx": { - "50ce0a6085bdfd7032e5a391ed34cd078768ad8f46cc885eb54571cf8edd4bee": 1 - }, "packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts": { "a7d8cc8d42e37ad5cc4882241492ac572ed8163c7fb8663180e893c7178e906f": 1 }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 7374f887ae..616ee97fa8 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -9,6 +9,7 @@ }, "include": [ "apps/web/tests/collapse-timing.e2e.ts", + "apps/web/tests/chat-flow-layout.e2e.ts", "apps/web/product-isolation.ts", "apps/web/tests/connection-recovery.e2e.ts", "apps/web/tests/server-restart.e2e.ts",