From 8a3b0be658c56e62564173ec42dec893d12e81e8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:00:50 +0800 Subject: [PATCH] fix(ui-chat): hold the running status still while the tail is pinned The first-output slide set its 6px default through a selector that outranked both variants, so a response (12px) and a new Turn's first output after the empty process control (16px) travelled 6px and started with a 6px or 10px jump. The default now sits on the single-class `.running` rule, which every variant outranks. When the transcript overflows the viewport and follows its bottom, the follow scroll of the same resize absorbs new rows and the status keeps its on-screen position; replaying a move there lifted the status 28-44px and slid it back. The viewport now marks the column `data-chat-tail-pinned` while the reader follows overflowing content, updated after each resize's follow scroll and on scroll, and neither the steering entrance nor the first-output slide plays under that mark. Update the README and add viewport and CSS checks. --- .../src/client/chat/ChatView.module.css | 26 +++++----- .../src/client/chat/use-chat-viewport.ts | 22 +++++++- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 2 +- .../client/conversation-nodes/README.zh.md | 2 +- .../tests/chat-viewport.client.spec.ts | 51 +++++++++++++++++++ .../tests/turn-tail-spacing.client.spec.ts | 12 +++-- 7 files changed, 97 insertions(+), 22 deletions(-) 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 9915e6d712..9f76aae4cf 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -145,9 +145,12 @@ /* Steering rows keep their full layout from insertion. Only the status moves: it enters its new slot below them from one slot higher, independent of how - many admitted rows it passes or how tall they are. */ + many admitted rows it passes or how tall they are. While the viewport pins + the tail (it follows the floor and the content overflows), following scrolls + absorb the growth and the status keeps its on-screen position, so neither + status move plays. */ @media (prefers-reduced-motion: no-preference) { - .column:not([data-input-fold]) > :global([data-slot="conversation.chat.flow"]) > .flowItem[data-chat-flow-kind="steering"] + .running { + .column:not([data-input-fold]):not([data-chat-tail-pinned]) > :global([data-slot="conversation.chat.flow"]) > .flowItem[data-chat-flow-kind="steering"] + .running { animation: running-enters-slot 140ms cubic-bezier(0.2, 0.8, 0.2, 1); } } @@ -166,17 +169,11 @@ input. When the first output row appears, the status enters its slot below that row from where it stood: it travels the row's gap plus one 24px row, which follows the content font size. The gap is 6px for a process row or - group header, 12px for a response, and 16px after the running Turn's process - control, which renders empty until the Turn closes, so it precedes a new - Turn's first output. Later output keeps the same animation and does not - restart it. */ -:is(.column, :global([data-slot="conversation.chat.flow"])) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):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 { - --dsh-running-follow: calc(6px + 24px + var(--dsh-content-font-delta, 0px)); -} - + group header (the default declared on `.running`), 12px for a response, and + 16px after the running Turn's process control, which renders empty until the + Turn closes, so it precedes a new Turn's first output. The default sits on + the single-class `.running` selector so both variants outrank it. Later + output keeps the same animation and does not restart it. */ :is(.column, :global([data-slot="conversation.chat.flow"])) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))))[data-chat-group-part="response"] ~ .running { --dsh-running-follow: calc(12px + 24px + var(--dsh-content-font-delta, 0px)); } @@ -186,7 +183,7 @@ } @media (prefers-reduced-motion: no-preference) { - .column:not([data-input-fold]) > :global([data-slot="conversation.chat.flow"]) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is( + .column:not([data-input-fold]):not([data-chat-tail-pinned]) > :global([data-slot="conversation.chat.flow"]) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):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 { @@ -229,6 +226,7 @@ flex-direction: column; align-items: flex-start; --dsh-chat-flow-gap: 12px; + --dsh-running-follow: calc(6px + 24px + var(--dsh-content-font-delta, 0px)); --dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer); color: var(--dsw-alias-label-deep-diving); font-size: calc(var(--dsh-content-font-size, 14px) - 2px); 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 40ea51de98..cba2046a30 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 @@ -99,6 +99,7 @@ export class ChatViewport { this.reclaimBelow() this.invalidate() this.events?.resize() + this.updateTailPin() }) this.observer.observe(column) this.observer.observe(scroller) @@ -110,7 +111,10 @@ export class ChatViewport { detach(): void { this.stopPreserving() this.motion.clear() - if (this.elements !== null) this.elements.scroller.style.overflowAnchor = '' + if (this.elements !== null) { + this.elements.scroller.style.overflowAnchor = '' + delete this.elements.column.dataset.chatTailPinned + } this.elements?.scroller.removeEventListener('scroll', this.onScroll) this.elements?.scroller.removeEventListener('scrollend', this.onScrollEnd, true) for (const type of READING_INTENTS) this.elements?.scroller.removeEventListener(type, this.onIntent, true) @@ -754,10 +758,26 @@ export class ChatViewport { this.invalidate() if (this.paging !== null) { this.events?.resize() + this.updateTailPin() return } const scroll = this.readScroll() if (scroll !== null) this.events?.scroll(scroll) + this.updateTailPin() + } + + /** + * Mark the column while the tail is pinned: the reader follows the floor and the content overflows, so + * following scrolls absorb content growth and rows at the tail keep their on-screen position. Moves that + * replay a status position change are skipped there. Called after the follow scroll of the same + * resize, before paint, so a move that starts in that frame is cancelled before it is drawn. + */ + private updateTailPin(): void { + const elements = this.elements + if (elements === null) return + const pinned = this.reader?.follow.active === true && elements.scroller.scrollHeight > elements.scroller.clientHeight + if (pinned) elements.column.dataset.chatTailPinned = '' + else delete elements.column.dataset.chatTailPinned } private readonly onScrollEnd = (event: Event): void => { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index e6d514845a..f59304584b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 4fccdea75e7163db - zh: 2f1771dca9c7c9d3 + en: 367940c7208b3486 + zh: 7de635c8cb8ab99a /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index b5161b7194..6fc4f54291 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -102,7 +102,7 @@ Clicking Load older anchors the first visible content item below that button in Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor. -While the Session runs, Chat shows a Session-level indicator at the running Turn's tail: a whale tail beside shimmering elapsed time. It follows the transcript rows and the echoes of submissions that open a Turn; steering still pending when a submission opens the next Turn is claimed first, so its echoes precede that submission's echo. Echoes of steering that waits for the running Turn's next Step follow the indicator. While the running Step has no output, the indicator sits directly below the input. When the first output row appears, the indicator slides from that position into its slot below the row; the travel equals the row's gap plus one row height, which follows the font size setting, so it starts exactly where it stood. Later output moves it without animation. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion. +While the Session runs, Chat shows a Session-level indicator at the running Turn's tail: a whale tail beside shimmering elapsed time. It follows the transcript rows and the echoes of submissions that open a Turn; steering still pending when a submission opens the next Turn is claimed first, so its echoes precede that submission's echo. Echoes of steering that waits for the running Turn's next Step follow the indicator. While the running Step has no output, the indicator sits directly below the input. When the first output row appears, the indicator slides from that position into its slot below the row; the travel equals the row's gap plus one row height, which follows the font size setting, so it starts exactly where it stood. Later output moves it without animation. When the transcript overflows the viewport and follows its bottom, following scrolls keep the indicator in place, so neither the steering entrance nor this slide plays. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion. The top duration/status control and trigger title use the group-title font size and follow the font-size setting. Completed duration digits use the code font with tabular numerals; running and completed minutes and seconds have no leading zero. The timed running label ends with a space and “···”; completion shows “Completed in”. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index dab45d5efe..4ac1b12e0e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -102,7 +102,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。 -会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行中的 Step 尚无输出时,运行指示紧贴在输入下方。第一条输出行出现时,运行指示从原位置滑入该行下方的位置;滑动距离等于该行间距加一行高度(随字号设置变化),因此起点正是它原来的位置。之后的输出不再带动画。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。 +会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行中的 Step 尚无输出时,运行指示紧贴在输入下方。第一条输出行出现时,运行指示从原位置滑入该行下方的位置;滑动距离等于该行间距加一行高度(随字号设置变化),因此起点正是它原来的位置。之后的输出不再带动画。transcript 超出视口并跟随底部时,跟随滚动会让运行指示保持原位,因此 steering 进场和这次下滑都不播放。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。 顶部用时/状态控件与唤醒标题沿用二级汇总标题字号,并跟随字号设置。已完成用时的数字使用代码等宽字体;运行和完成状态的分钟和秒数均不补前导零。含用时的运行文案末尾附加一个空格及“···”,完成后显示“已完成,用时”。 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 def772efb8..8278e2acf7 100644 --- a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts @@ -560,3 +560,54 @@ it('releases a removed paging row without navigating to unrelated content', () = expect(h.viewport.preserving).toBe(false) expect(h.scroller.scrollTop).toBe(80) }) + +it('pins the tail only while the reader follows overflowing content, after that resize follows', () => { + let notifyResize = () => {} + class Observer implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', Observer) + const column = document.createElement('div') + document.body.append(column) + let scrollHeight = 200 + Object.defineProperties(column, { + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + }) + const viewport = new ChatViewport() + onTestFinished(() => { + viewport.detach() + column.remove() + vi.unstubAllGlobals() + }) + viewport.attach(column, column) + const follow = new ScrollFollow(true, 4) + viewport.bindReader({ follow, landedAtTail: () => {}, pending: () => false }) + const pinned = () => column.hasAttribute('data-chat-tail-pinned') + let pinnedWhileFollowing: boolean | undefined + viewport.connect({ + scroll: () => {}, scrollEnd: () => {}, interact: () => {}, + resize: () => { pinnedWhileFollowing = pinned() }, + }) + + // Content that fits moves the tail on screen, so status moves keep playing. + notifyResize() + expect(pinned()).toBe(false) + // The growth that first overflows is followed before the mark changes, in the same resize. + scrollHeight = 900 + notifyResize() + expect(pinnedWhileFollowing).toBe(false) + expect(pinned()).toBe(true) + // Scrolling away releases follow intent and the pin; returning to the floor restores it. + follow.setFollowing(false) + column.dispatchEvent(new Event('scroll')) + expect(pinned()).toBe(false) + follow.setFollowing(true) + column.dispatchEvent(new Event('scroll')) + expect(pinned()).toBe(true) + viewport.detach() + expect(pinned()).toBe(false) +}) diff --git a/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts index 5da52f5208..a75611f32a 100644 --- a/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts +++ b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts @@ -28,8 +28,10 @@ describe('Running status move below the first output', () => { it('travels the first output row gap plus one font-scaled row', () => { const view = read('ChatView.module.css') - // A process row or group header after a mid-Turn input. - expect(view).toMatch(new RegExp(String.raw`\[data-chat-group-key\]\s*\) ~ \.running \{\s*${travel(6)}`)) + // A process row or group header after a mid-Turn input: the default sits on the single-class + // status rule, the lowest-specificity selector, so each variant below outranks it. + expect(view).toMatch(new RegExp(String.raw`\n\.running \{[^}]*${travel(6)}`)) + expect(view.match(/--dsh-running-follow: calc\(/g)).toHaveLength(3) // A response keeps its 12px clearance. expect(view).toMatch(new RegExp(String.raw`\)\)\)\)\[data-chat-group-part="response"\] ~ \.running \{\s*${travel(12)}`)) // A new Turn's first output follows the empty running process control at its 16px clearance. @@ -38,10 +40,14 @@ describe('Running status move below the first output', () => { it('slides the status from where it stood, like the steering entrance, outside input folds and reduced motion', () => { const view = read('ChatView.module.css') - expect(view).toMatch(new RegExp(String.raw`@media \(prefers-reduced-motion: no-preference\) \{\s*\.column:not\(\[data-input-fold\]\)[^{]*\[data-chat-group-key\]\s*\) ~ \.running \{\s*` + const unpinned = String.raw`\.column:not\(\[data-input-fold\]\):not\(\[data-chat-tail-pinned\]\)` + expect(view).toMatch(new RegExp(String.raw`@media \(prefers-reduced-motion: no-preference\) \{\s*${unpinned}[^{]*\[data-chat-group-key\]\s*\) ~ \.running \{\s*` + String.raw`animation: running-follows-output 110ms cubic-bezier\(0\.4, 0, 0\.2, 1\);`)) expect(view).toMatch(new RegExp(String.raw`@keyframes running-follows-output \{\s*from \{\s*opacity: 0;\s*` + String.raw`translate: 0 calc\(-1 \* var\(--dsh-running-follow\)\);\s*\}\s*40% \{\s*opacity: 1;`)) + // Following scrolls hold the status still while the tail is pinned, so neither status move plays there. + expect(view.match(/animation: running-(enters-slot|follows-output) /g)).toHaveLength(2) + expect(view.match(new RegExp(String.raw`${unpinned} > :global\(\[data-slot="conversation\.chat\.flow"\]\) > `, 'g'))).toHaveLength(2) }) it('sizes every first-output row to the travelled row height', () => {