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.
This commit is contained in:
imccyu committed 2026-10-18 15:14:16 +08:00
1 parent ed9a92d1a8
commit 8a3b0be658
7 files changed
+97 -22

No files matched your search

@@ -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);
@@ -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 => {
@@ -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
@@ -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”.
@@ -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 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
顶部用时/状态控件与唤醒标题沿用二级汇总标题字号,并跟随字号设置。已完成用时的数字使用代码等宽字体;运行和完成状态的分钟和秒数均不补前导零。含用时的运行文案末尾附加一个空格及“···”,完成后显示“已完成,用时”。
@@ -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)
})
@@ -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', () => {