diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 4d22e66e98..3b71335359 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -2,9 +2,10 @@ // deliberately virtualizer-neutral: they assert semantic-row position, // bottom ownership, interaction state, and the real outer scroll host rather // than DOM cardinality or implementation-specific spacer markup. -import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -524,6 +525,104 @@ describe('web e2e: long Chat scroll contract', () => { await browser?.close() }) + it.skipIf(MODE === 'record')('merges a large process fold with new-input following without returning downward', async () => { + const fixture = createChatScrollFixture({ markerPrefix: 'FOLD', title: 'CHAT_FOLD_INPUT', turns: 8 }) + const input = 'CHAT_FOLD_NEXT_INPUT' + const artifactRoot = fileURLToPath(new URL('../../../.artifacts/fold-input-e2e/', import.meta.url)) + await mkdir(artifactRoot, { recursive: true }) + const evidence = await mkdtemp(join(artifactRoot, 'run-')) + await withScrollWorld({ + failureShot: 'web-e2e-fold-input-motion', + replay: [replayEntry(textStream('CHAT_FOLD_REPLY', 'CHAT_FOLD_DONE', 2))], + seeds: [{ fixture, id: 'chat-fold-input-e2e' }], + }, async (world) => { + const { page } = world + await page.setViewportSize({ width: 1280, height: 720 }) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await openSeed(page, fixture, fixture.markers.assistant(8)) + await openSettings(page, 'en') + await page.getByRole('dialog', { name: 'Settings', exact: true }) + .getByText('When to Collapse Work Details', { exact: true }).locator('../..') + .getByRole('button', { name: 'On completion', exact: true }).click() + await page.getByRole('menuitem', { name: 'On next message', exact: true }).click() + await page.keyboard.press('Escape') + for (const index of [1, 2]) { + const row = page.locator(`[data-chat-call-id="chat-scroll-008-${index}"] [data-sample="bash"]`) + await row.click() + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + } + await wheelTranscript(page, -600) + await page.getByRole('button', { name: 'Back to bottom', exact: true }).click() + await expectBottom(page) + const beforeHeight = await page.locator('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])') + .evaluate(element => element.getBoundingClientRect().height) + const release = holdTextAfter(world, 0) + const trace = page.evaluate((marker) => new Promise<{ + readonly t: number; readonly top: number; readonly messageY: number; + readonly columnHeight: number; readonly folding: boolean; + }[]>(resolve => { + const host = document.querySelector('[data-conversation-scroll]')! + const column = host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')! + const samples: { t: number; top: number; messageY: number; columnHeight: number; folding: boolean }[] = [] + let frame = 0 + let timer = 0 + const sample = (): void => { + const message = Array.from(column.querySelectorAll('*')) + .find(element => element.children.length === 0 && element.textContent === marker) + if (message === undefined) return + samples.push({ t: performance.now(), top: host.scrollTop, messageY: message.getBoundingClientRect().top, + columnHeight: column.getBoundingClientRect().height, + folding: column.querySelector('[data-chat-motion="collapse"]') !== null }) + } + const tick = (): void => { + timer = window.setTimeout(() => { sample(); frame = requestAnimationFrame(tick) }, 0) + } + host.addEventListener('dsh-test-fold-stop', () => { + cancelAnimationFrame(frame) + window.clearTimeout(timer) + sample() + host.removeAttribute('data-fold-probe-ready') + resolve(samples) + }, { once: true }) + host.setAttribute('data-fold-probe-ready', '') + frame = requestAnimationFrame(tick) + }), input) + await page.locator('[data-fold-probe-ready]').waitFor() + let settled: ReturnType | undefined + try { + await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input) + await page.getByRole('button', { name: 'Send message', exact: true }).click() + settled = world.scaffold.whenTurnSettled(30_000) + await page.getByText(input, { exact: true }).waitFor() + await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0) + await expectBottom(page) + await nextPaint(page) + } finally { + await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop'))) + release() + await settled + } + const samples = await trace + expect(samples.length).toBeGreaterThan(4) + const first = samples[0]! + let minimum = first.messageY + let reversal = 0 + for (const sample of samples) { + minimum = Math.min(minimum, sample.messageY) + if (first.messageY - minimum > 4) reversal = Math.max(reversal, sample.messageY - minimum) + } + const afterHeight = samples.at(-1)!.columnHeight + await writeFile(join(evidence, 'motion.json'), JSON.stringify({ beforeHeight, afterHeight, reversal, samples }, null, 2)) + await page.screenshot({ path: join(evidence, 'settled.png') }) + console.log(`fold-input e2e: reversal=${reversal}px, samples=${samples.length}, evidence=${evidence}`) + expect(beforeHeight - afterHeight).toBeGreaterThan(250) + expect(first.messageY - minimum).toBeGreaterThan(40) + expect(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + await expectBottom(page) + assertClean(world) + }) + }, 180_000) + it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-floating-controls', diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts index b8012b1520..a33867b404 100644 --- a/apps/web/tests/collapse-timing.e2e.ts +++ b/apps/web/tests/collapse-timing.e2e.ts @@ -123,16 +123,16 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { await expectProcess(1, false) await capture('completion.expected.md') - await selectPreference('Collapse work details', 'On next message') + await selectPreference('When to Collapse Work Details', 'On next message') await expectProcess(1, true) await page.getByRole('button', { name: 'Read a.txt', exact: true }).waitFor() await capture('next-input.expected.md') - await selectPreference('Collapse work details', 'On completion') + await selectPreference('When to Collapse Work Details', 'On completion') await expectProcess(1, false) await capture('completion-restored.expected.md') - await selectPreference('Collapse work details', 'On next message') + await selectPreference('When to Collapse Work Details', 'On next message') await expectProcess(1, true) const input = page.locator('[data-composer-input]').last() await writeComposerDraft(page, input, prompt) @@ -178,7 +178,7 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { await capture('next-message.expected.md') await selectPreference('Work details', 'Verbose') - await selectPreference('Collapse work details', 'On completion') + await selectPreference('When to Collapse Work Details', 'On completion') await expectDetails(1, true) await expectDetails(2, true) expect(await page.locator('[data-turn-process]:enabled').count()).toBe(0) diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index 8b9181c829..b24ebca8d2 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -22,8 +22,8 @@ - button "更多字体设置" - text: 工作步骤展示 选择希望看到多少工具调用细节 - button "标准" - - text: 过程收起时机 任务完成后立即收起过程,或保留到下次发送消息时再收起 - - button "完成时收起" + - text: 工作步骤收起时机 选择何时自动收起工作步骤 + - button "回答结束后" - text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换 - switch "显示代码工作视图" [checked] - text: 快捷键 diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index d9ab49f0c4..801a3d9a5d 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -22,7 +22,7 @@ - button "More font settings" - text: Work details Choose how much detail to show for tool calls - button "Standard" - - text: Collapse work details Collapse work details when a task finishes, or keep them until you send the next message + - text: When to Collapse Work Details Choose when to automatically collapse work details - button "On completion" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" [checked] diff --git a/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md b/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md index d74b43df8e..e7b47ba72f 100644 --- a/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md +++ b/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md @@ -1,3 +1,3 @@ - menu: - - menuitem "完成时收起" - - menuitem "下次发送时收起" + - menuitem "回答结束后" + - menuitem "下次有新消息时" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 886fef79b0..13cd42f9d7 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -22,7 +22,7 @@ - button "More font settings" - text: Work details Choose how much detail to show for tool calls - button "Detailed" - - text: Collapse work details Collapse work details when a task finishes, or keep them until you send the next message + - text: When to Collapse Work Details Choose when to automatically collapse work details - button "On completion" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 886fef79b0..13cd42f9d7 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -22,7 +22,7 @@ - button "More font settings" - text: Work details Choose how much detail to show for tool calls - button "Detailed" - - text: Collapse work details Collapse work details when a task finishes, or keep them until you send the next message + - text: When to Collapse Work Details Choose when to automatically collapse work details - button "On completion" - text: Show coding view Shows trajectory, code diffs, and all Agent presets - switch "Show coding view" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index b00df5f987..df60e07cde 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -22,8 +22,8 @@ - button "更多字体设置" - text: 工作步骤展示 选择希望看到多少工具调用细节 - button "详细" - - text: 过程收起时机 任务完成后立即收起过程,或保留到下次发送消息时再收起 - - button "完成时收起" + - text: 工作步骤收起时机 选择何时自动收起工作步骤 + - button "回答结束后" - text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换 - switch "显示代码工作视图" - text: 快捷键 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 055b9acc3b..2b64c107fc 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -965,14 +965,14 @@ describe('web e2e: browser-local collapse timing preference', () => { { locale: 'zh', browserLocale: ZH_BROWSER_LOCALE, theme: 'dark', settings: '设置', close: '关闭', details: '工作步骤展示', compact: '简洁', detailed: '详细', - title: '过程收起时机', completion: '完成时收起', nextInput: '下次发送时收起', - description: '任务完成后立即收起过程,或保留到下次发送消息时再收起', + title: '工作步骤收起时机', completion: '回答结束后', nextInput: '下次有新消息时', + description: '选择何时自动收起工作步骤', }, { locale: 'en', browserLocale: 'en-US', theme: 'light', settings: 'Settings', close: 'Close', details: 'Work details', compact: 'Compact', detailed: 'Detailed', - title: 'Collapse work details', completion: 'On completion', nextInput: 'On next message', - description: 'Collapse work details when a task finishes, or keep them until you send the next message', + title: 'When to Collapse Work Details', completion: 'On completion', nextInput: 'On next message', + description: 'Choose when to automatically collapse work details', }, ] as const)('keeps $locale collapse timing on $theme only for the current Client', async (copy) => { const contextOptions = { diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 32f8f2d3b9..5389c7bf39 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -36,8 +36,8 @@ en: 8b2586e6b9f188a7 zh: 6687f9e67432d04f /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: bc765ae58800399f - zh: 1f004adf22c5a54e + en: 7a0dfe154ee330f3 + zh: 95a14dd8c56ee6f3 /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 4e41cefd3a..50cb29e4e1 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -128,7 +128,7 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. -The delayed-fold experiment pauses automatic tail following and browser scroll anchoring while process rows fade and close. Bottom space preserves the scroll range; final content and the new input move upward together. Once the rows close, native smooth scrolling targets the real content floor, not the reserved space, and releases that space on arrival without a fixed continuation speed. Reader gestures cancel waiting and active follow motion; resize cannot revive a cancelled wait in the same fold, but new submitted input can. Off-bottom readers retain their position and the space needed to prevent clamping. Unloading Chat cancels its waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. +Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. Reserved bottom space preserves the physical scroll range. Before paint, the task limits fold-driven movement to the remaining tail distance; after the rows close, native smooth scrolling covers only any remaining movement toward newer content. The content does not overshoot and then scroll back toward the composer. Reserved space is released on arrival without a fixed continuation speed. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position and the space needed to prevent clamping. Unloading Chat cancels animation frames, waiting callbacks, and row transitions. Reduced-motion users receive immediate visibility and positioning changes. Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node and image rendering, and shares the existing Chat store and Session node sources. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 8f182dd0b0..f45ac06be3 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -128,7 +128,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 -延后折叠实验在过程行淡出并收起期间暂停自动跟尾和浏览器滚动锚定。底部空间保留可滚动范围,最终正文与新输入一起向上移动。过程行收起后,原生平滑滚动以真实内容底部而非预留空间为目标,到达后释放该空间,不保证固定续滚速度。读者手势取消等待中和进行中的跟随动画;同一次折叠中的尺寸变化不能恢复已取消的等待,但新提交的输入可以。离底读者保留其位置及防止滚动钳制所需的空间。卸载 Chat 时取消其等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 +下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。底部预留空间保留物理滚动范围。任务在绘制前将折叠带来的位移限制在剩余跟尾距离内;过程行收起后,原生平滑滚动仅完成仍未覆盖的朝向新内容的位移,不让内容先越过目标再向 composer 回滚。到达后释放预留空间,不保证固定续滚速度。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留其位置及防止滚动钳制所需的空间。卸载 Chat 时取消动画帧、等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点和图片渲染,并共享既有 Chat store 与 Session 节点来源。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 diff --git a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts index 4c9953d7ed..25183e67d7 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts @@ -52,9 +52,8 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { const content = useRef<{ input: ChatScrollInput; applied: ChatScrollInput | null; opened: boolean }>({ input, applied: null, opened: false, }) - // While rows close, the scrollport holds still: the reserved room below keeps the floor from dropping, - // so the closing gap pulls the content below it (including a just-sent input) upward. One follow - // scroll lands whatever is still short of the floor after the last row closed. + // Fold movement consumes the same tail distance as scrolling. It cannot overshoot that distance; + // after the rows close, native following covers only the remaining forward movement. const cancelFollow = useRef<(() => void) | null>(null) const clearPendingFollow = useCallback(() => { cancelFollow.current?.() @@ -69,11 +68,23 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { }, [clearPendingFollow, viewport]) const followAfterFold = useCallback(() => { if (cancelFollow.current !== null) return - cancelFollow.current = viewport.motion.onFoldIdle(() => { + reading.interruptFollow() + const advance = (): void => { + viewport.limitFoldDisplacement() + frame = requestAnimationFrame(advance) + } + let frame = requestAnimationFrame(advance) + const disconnect = viewport.motion.onFoldIdle(() => { + cancelAnimationFrame(frame) cancelFollow.current = null + viewport.limitFoldDisplacement() viewport.reclaimBelow() if (content.current.input.deferCompletedTurns) reading.followTail('smooth') }) + cancelFollow.current = () => { + cancelAnimationFrame(frame) + disconnect() + } }, [reading, viewport]) const processContent = useCallback(() => { @@ -116,7 +127,10 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { useLayoutEffect(() => { const disconnectViewport = viewport.connect({ - scroll: reading.onScroll, + scroll: (event) => { + if (!event.movedByReader && viewport.motion.foldActive()) return + reading.onScroll(event) + }, scrollEnd: (outer) => { reading.onScrollEnd(outer) navigation.readerSettled() 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 6dfecdc496..41738a6393 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 @@ -160,6 +160,14 @@ export class ChatViewport { this.setSpacer(this.spacerHeight() + px) } + /** Limit an automatic fold's visible movement to the remaining tail distance before paint. */ + limitFoldDisplacement(): void { + const metrics = this.metrics() + if (metrics === null) return + const floor = Math.max(0, metrics.floor - this.spacerHeight()) + if (metrics.top > floor) this.write(floor, metrics, null) + } + /** * Give reserved room back after acknowledged scrolling or content growth without clamping the reader. * Holds still during folds and unsampled movement; tail following targets content without this room. diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 63e741e0f3..5d24261ad8 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -102,10 +102,10 @@ export const zh = { 'settings.links.newTab': '默认浏览器', 'settings.transcript.title': '工作步骤展示', 'settings.transcript.description': '选择希望看到多少工具调用细节', - 'settings.collapse.title': '过程收起时机', - 'settings.collapse.description': '任务完成后立即收起过程,或保留到下次发送消息时再收起', - 'settings.collapse.completion': '完成时收起', - 'settings.collapse.nextInput': '下次发送时收起', + 'settings.collapse.title': '工作步骤收起时机', + 'settings.collapse.description': '选择何时自动收起工作步骤', + 'settings.collapse.completion': '回答结束后', + 'settings.collapse.nextInput': '下次有新消息时', 'settings.transcript.compact': '简洁', 'settings.transcript.standard': '标准', 'settings.transcript.detailed': '详细', @@ -299,8 +299,8 @@ export const en = { 'settings.links.newTab': 'Default Browser', 'settings.transcript.title': 'Work details', 'settings.transcript.description': 'Choose how much detail to show for tool calls', - 'settings.collapse.title': 'Collapse work details', - 'settings.collapse.description': 'Collapse work details when a task finishes, or keep them until you send the next message', + 'settings.collapse.title': 'When to Collapse Work Details', + 'settings.collapse.description': 'Choose when to automatically collapse work details', 'settings.collapse.completion': 'On completion', 'settings.collapse.nextInput': 'On next message', 'settings.transcript.compact': 'Compact', diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index 691d04402f..5d728c4b43 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -135,6 +135,41 @@ describe('Chat scroll collapse timing', () => { expect(h.scroller.style.overflowAnchor).toBe('') }) + it('consumes excess collapse during the fold without a reverse native follow afterward', async () => { + const h = mountScroll() + h.scroller.scrollTop = 600 + h.startFold() + await act(async () => { await Promise.resolve() }) + h.grow(1_060) + h.update({ submissionId: 'new-input' }) + h.grow(1_020) + act(() => { vi.advanceTimersByTime(20) }) + expect(h.scroller.scrollTop).toBe(600) + h.grow(980) + act(() => { vi.advanceTimersByTime(20) }) + expect(h.scroller.scrollTop).toBe(580) + fireEvent.scroll(h.scroller) + expect(h.scroller.scrollTop).toBe(580) + h.finishFold() + expect(h.scroller.scrollTop).toBe(580) + expect(h.scrollTo).not.toHaveBeenCalled() + expect(h.scroller.querySelector('[data-chat-turn-spacer]')?.style.height).toBe('') + }) + + it('follows only the distance left after a smaller fold', async () => { + const h = mountScroll() + h.scroller.scrollTop = 600 + h.startFold() + await act(async () => { await Promise.resolve() }) + h.grow(1_200) + h.update({ submissionId: 'new-input' }) + h.grow(1_120) + act(() => { vi.advanceTimersByTime(20) }) + expect(h.scroller.scrollTop).toBe(600) + h.finishFold() + expect(h.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 720, behavior: 'smooth' }) + }) + it.each(['wheel', 'touchstart', 'pointerdown', 'keydown'] as const)( 'cancels queued follow when the reader sends %s input', async (intent) => { const h = mountScroll() diff --git a/packages/client/ui-chat/tests/reasoning-render-fixture.tsx b/packages/client/ui-chat/tests/reasoning-render-fixture.tsx index 12c7c3359d..4a9d32a3ea 100644 --- a/packages/client/ui-chat/tests/reasoning-render-fixture.tsx +++ b/packages/client/ui-chat/tests/reasoning-render-fixture.tsx @@ -10,7 +10,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderFactories, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { en, NS, zh } from '../src/client/locale.ts' -import { derivePresentationPolicy } from '../src/client/presentation-policy.ts' +import { derivePresentationPolicy, type CollapseTiming } from '../src/client/presentation-policy.ts' import { registerChatNodeRenderers } from '../src/client/chat/register-node-renderers.ts' import type { PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts' @@ -44,7 +44,7 @@ export async function createReasoningRenderFixture(language = 'zh') { apply: (ctx) => { registerChatNodeRenderers(ctx, createSnapshotStore('detailed'), - derivePresentationPolicy(createSnapshotStore('detailed'))) + derivePresentationPolicy(createSnapshotStore('detailed'), createSnapshotStore('completion'))) }, }) await runtime.sessions.add({ id: 'reasoning-fixture' }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index be753718ed..d195d284e2 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -66,9 +66,9 @@ describe('TranscriptViewRow', () => { }) it.each([ - [en, 'Work details', 'Collapse work details', - 'Collapse work details when a task finishes, or keep them until you send the next message', 'On completion', 'On next message'], - [zh, '工作步骤展示', '过程收起时机', '任务完成后立即收起过程,或保留到下次发送消息时再收起', '完成时收起', '下次发送时收起'], + [en, 'Work details', 'When to Collapse Work Details', + 'Choose when to automatically collapse work details', 'On completion', 'On next message'], + [zh, '工作步骤展示', '工作步骤收起时机', '选择何时自动收起工作步骤', '回答结束后', '下次有新消息时'], ] as const)('selects either collapse timing independently with localized labels (%s)', (dictionary, detailTitle, title, description, completion, nextInput) => { const b = mount('standard', dictionary) const titleElement = screen.getByText(title)