fix(chat): keep the transcript steady at Turn end and when steering lands

Measured against 0.2.1-rc.4 and master with the frame-sampled motion matrix,
the transcript moved back and forth at Turn end and around input landing.
This keeps rc.4's landing behavior, master's steering layout, and fixes the
reversals that remain.

- Completion-time folding keeps the first visible surviving row still when
  work rows close at once, filling the gap with bottom space capped at half
  the scrollport, instead of letting the browser clamp the position.
- After the Session stops running, the running status keeps its tail slot
  hidden and silent until the Turn's actions row replaces it, so the tail
  does not shrink and grow back.
- A Turn's duration control takes its gap in one frame instead of sliding.
- A fold batch that takes over following for a content change absorbs
  growth outside the batch before its first frame.
- Steering lands at once under either fold timing: the running Turn keeps
  streaming above its echo, which a native animation would push down.
- The running status no longer slides below a Step's first output, and the
  column no longer carries `data-chat-tail-pinned`; the steering entrance
  keeps its animation.
- A queued prompt is accepted by its Inbox row as well as by its receipt,
  so the composer clears the draft in the frame the queue dock shows it.
- A queued echo waits for the Inbox projection instead of retiring on the
  durable splice, so the queue dock does not empty for a frame.
This commit is contained in:
imccyu committed 2026-10-27 20:44:52 +08:00
1 parent 6300195071
commit ae07093132
21 files changed
+539 -366

No files matched your search

@@ -800,7 +800,10 @@ export class Session implements SessionFace {
}
}
this.observeSubmissionInsertions(target, inserted, start, event.seq)
for (const message of inserted) this.observeSubmissionMessage(message, false)
// A queued echo waits for the inbox projection the queue dock renders, which can arrive after this
// event; retiring it here would empty the dock for a frame. A claim in the same projection notification
// still retires it through the durable user message.
for (const message of inserted) this.observeSubmissionMessage(message, false, false)
return
}
if (event.type === 'request/context' || event.type === 'turn/end') {
@@ -818,21 +821,29 @@ export class Session implements SessionFace {
const source = message.source
if (source.kind !== 'user' || !('rpcId' in source)) continue
const settlement = this.submissionSettlements.get(source.rpcId)
if (settlement === undefined || settlement.placement === 'queued'
|| settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue
if (settlement === undefined || settlement.retiring) continue
// Either proof accepts a prompt; an Inbox row can reach the Client before the prompt receipt. A queued
// prompt's row is the queue dock's own row, so its acceptance there lets the composer clear the draft in
// the frame that shows the row; its retirement follows the Inbox projection, not a receipt position.
if (settlement.placement === 'queued') {
this.acceptPrompt(source.rpcId)
continue
}
if ((settlement.receipt?.seq ?? -1) > seq) continue
settlement.receipt = { target, seq, index: start + index, attachments: attachmentRefsIn(message.content) }
// An Inbox row can reach the Client before the prompt receipt.
this.acceptPrompt(source.rpcId)
}
}
private observeSubmissionMessage(message: UserMessage, admitted: boolean): void {
private observeSubmissionMessage(message: UserMessage, admitted: boolean, retireQueued = true): void {
const source = message.source
if (source.kind !== 'user' || !('rpcId' in source)) return
const settlement = this.submissionSettlements.get(source.rpcId)
if (settlement === undefined || settlement.retiring) return
if (!admitted) {
if (settlement.placement === 'queued') this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
if (settlement.placement === 'queued' && retireQueued) {
this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
}
return
}
settlement.admitted = attachmentRefsIn(message.content)
@@ -119,6 +119,7 @@ describe('beginSubmission', () => {
for (const { mode, running, target, placement } of [
{ mode: 'queue', running: false, target: 'next-turn', placement: 'transcript' },
{ mode: 'steer', running: true, target: 'next-step', placement: 'steering' },
{ mode: 'queue', running: true, target: 'next-turn', placement: 'queued' },
] as const) {
for (const path of ['projection', 'durable event'] as const) {
it(`accepts a ${placement} prompt once the Inbox holds it, ahead of the Host receipt (${path})`, async ({ mock, start }) => {
@@ -384,8 +385,11 @@ describe('observed retirement', () => {
await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
data: { target: 'next-step', start: 0, inserted: [steerMessage] } })
flush()
expect(pending()).toEqual(['opening', 'steering'])
expect(queueRetired).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: [imageRef('queued-image')] })
// A queued echo waits for the inbox projection the queue dock renders, not the history event.
expect(pending()).toEqual(projectionFirst ? ['opening', 'steering'] : ['opening', 'queued', 'steering'])
if (projectionFirst) {
expect(queueRetired).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: [imageRef('queued-image')] })
} else expect(queueRetired).not.toHaveBeenCalled()
await pushEvent(mock, ev.turnStart(SessionSeq(2), 1))
await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 4,
data: { target: 'next-step', start: 0, removedCount: 1, inserted: [] } })
@@ -396,15 +400,17 @@ describe('observed retirement', () => {
await pushEvent(mock, ev.stepStart(SessionSeq(5), 1, 1))
await pushEvent(mock, { type: 'user/message', seq: SessionSeq(6), time: 7, surfaceOp: 'append', data: steerMessage })
flush()
expect(pending()).toEqual(projectionFirst ? ['opening'] : ['opening', 'steering'])
expect(pending()).toEqual(projectionFirst ? ['opening'] : ['opening', 'queued', 'steering'])
expect(normalRetired).not.toHaveBeenCalled()
if (!projectionFirst) expect(steerRetired).not.toHaveBeenCalled()
await pushEvent(mock, { type: 'user/message', seq: SessionSeq(7), time: 8, surfaceOp: 'append', data: normalMessage })
flush()
if (!projectionFirst) {
expect(pending()).toEqual(['opening', 'steering'])
expect(pending()).toEqual(['opening', 'queued', 'steering'])
session.projections.apply('inbox', accepted, SessionSeq(1))
await Promise.resolve()
flush()
expect(queueRetired).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: [imageRef('queued-image')] })
expect(normalRetired).not.toHaveBeenCalled()
session.projections.apply('inbox', claimed, SessionSeq(4))
await Promise.resolve()
+2 -2
View File
@@ -36,8 +36,8 @@
en: 3afad0b4c6bf9ce0
zh: d8179ba4ee9fcd61
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
en: 009ecb4fecd4ee9e
zh: ec2714337a1102c5
en: 91e1f5f12ca51211
zh: b92090966d58b82c
/deepseek-ai-dsh-client-ui-chat/model-experience:
en: c17b4dd43a61c569
zh: f7414309810c3a08
+4 -2
View File
@@ -134,7 +134,7 @@ Process rows share one text and icon color in every Work details mode: tertiary
Chat waits for initial history before automatic content, scroll, or size updates initialize reading state. Running Sessions restore their initial visibility and position before fold animations become eligible.
Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the floor within subpixel rounding, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Under completion-time folding, submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the floor within subpixel rounding, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Under completion-time folding, submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. Under next-message folding, transcript input that no fold batch carries lands on one native animation; steering lands at once under either timing, because the running Turn keeps streaming above its echo. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
The turn rail and back-to-bottom button sit outside the clipped transcript. They use the shared conversation scrollport for sticky positioning, or the Chat frame for absolute positioning when Chat owns its scrollport. The rail hides when the transcript's available width, excluding its horizontal padding, is at most 900px; the browser viewport width is not the criterion.
@@ -142,7 +142,9 @@ 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.
Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. A row that starts closing or revealing while a batch runs keeps its size until that batch settles and then changes in the next batch; a batch continues each row from the size it already has. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes.
Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. A row that starts closing or revealing while a batch runs keeps its size until that batch settles and then changes in the next batch; a batch continues each row from the size it already has. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. When a batch takes over following because content changed, such as a Turn ending while a next input waits, content that grew outside the batch since the last paint moves the scroll position before the first frame, as following does for streaming growth, so the batch animates only its own rows; growth that arrives with the reader's own input lands on the batch's animation. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes.
Completion-time folding closes a Turn's work rows at once. When the reader follows the tail, the first row visible before the close keeps its screen position, or, when that row closed, the first surviving row that followed it on screen, else the nearest surviving row above it; bottom space fills the gap, at most half the scrollport, and later growth takes that space before following advances.
Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow.
+4 -2
View File
@@ -134,7 +134,7 @@ Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure`
Chat 等待初始历史就绪后,才允许自动内容、滚动或尺寸更新初始化阅读状态。进行中的 Session 先恢复初始显隐和位置,再允许折叠动画。
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及到达底部(容许小于 1px 的取整误差)的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。采用完成时收起时,提交正文输入或 steering 会立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及到达底部(容许小于 1px 的取整误差)的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。采用完成时收起时,提交正文输入或 steering 会立即恢复跟随底部,并清除先前待处理的读者滚动采样。采用下次消息时收起时,没有折叠批次承载的正文输入用一次原生动画落到底部;steering 在两种时机下都立即落到底部,因为运行中的 Turn 仍在其回显上方持续输出。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
轮次轨道与回到底部按钮位于正文裁剪层外。它们相对共享会话滚动容器做 sticky 定位;Chat 自己持有滚动容器时,则相对 Chat 外框做 absolute 定位。正文扣除水平内边距后的可用宽度不超过 900px 时,隐藏轮次轨道;判断依据不是浏览器视口宽度。
@@ -142,7 +142,9 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。
下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。批次运行期间开始收起或展开的行先保持当前尺寸,等当前批次落定后在下一批中变化;批次从每一行已有的尺寸继续。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。
下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。批次运行期间开始收起或展开的行先保持当前尺寸,等当前批次落定后在下一批中变化;批次从每一行已有的尺寸继续。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。批次因内容变化接管跟随时(例如下一条输入在等待时一轮结束),上次绘制以来批次之外的内容增长在第一帧之前由滚动位置吸收,与跟随吸收流式增长的方式相同,批次只为自己的行做动画;随读者自己的输入到达的增长则随批次动画落定。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。
完成时收起会一次性收起一轮的工作行。读者跟随底部时,收起前第一个可见行保持屏幕位置;若该行本身被收起,则由收起前在屏幕上位于其后的第一个保留行保持不动,没有这样的行时才由其上方最近的保留行保持不动。底部空间填补空缺,最多半个滚动区域高度,后续增长先占用这部分空间,跟随才继续前进。
切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。
@@ -39,6 +39,11 @@ export const ChatFlow = memo(function ChatFlow({
? location.turn.status === 'open' ? location.turn.start?.time : undefined
: undefined
})
const latestTurnOpen = useChatNode(latestTurnAnchor ?? '', (node) => {
const location = node?.location
return (location?.kind === 'turn' || location?.kind === 'step') && location.turn.status === 'open'
})
const holding = !running && latestTurnOpen
const seatProps = {
nodeStore, useChatNode, useChatNodeBottom, useChatNodeProcess, usePresentation,
useStore, actions, renderSlot, t, useGroupAction, deferCollapse,
@@ -83,10 +88,13 @@ export const ChatFlow = memo(function ChatFlow({
&& node.location.turn.status === 'open' && node.location.turn.turn !== lastInputTurn) {
rows.splice(rows.length - 1, 0, ...leadingRows.splice(0))
}
// Rows enter before the keyed status and echoes after it, so React never moves its DOM node.
// Rows enter before the keyed status and echoes after it, so React never moves its DOM node. The
// running bit and the Turn's end arrive separately: after the bit clears, an open latest Turn keeps
// the status slot (hidden) until its actions row takes the slot in the same commit.
return [
...rows, ...leadingRows, ...openingRows,
...running ? [<RunningStatus key="running" startTime={runningStartTime} paused={disconnected} t={t} />] : [],
...running || holding ? [<RunningStatus key="running" startTime={runningStartTime} paused={disconnected}
holding={holding} t={t} />] : [],
...pendingRows,
]
})
@@ -115,6 +115,13 @@
transition: none;
}
/* A Turn's duration control renders empty until the Turn closes; its gap appears with it in the same
frame, so the layout below changes once instead of sliding after the control's own height. */
.column[data-chat-motion] > [data-chat-flow-kind="turn-process"],
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > [data-chat-flow-kind="turn-process"] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.column[data-chat-motion] > :not([data-chat-motion]),
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
@@ -163,6 +170,15 @@
:where(.column > :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);
}
/* An input fold runs the entrance at zero duration. The switch only changes
the duration of an entrance its structural change already created, as if
the entrance had it from its start: lifting the switch never replays an
entrance older than its duration, and setting it completes a running one.
The structural rule is wrapped in :where() so this rule outranks it. */
.column[data-input-fold] .running {
animation-duration: 0s;
}
}
@keyframes running-enters-slot {
@@ -175,66 +191,6 @@
}
}
/* While the running Step has no output, the status sits directly below the
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 4px for a process row or
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));
}
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))) ~ .running {
--dsh-running-follow: calc(16px + 24px + var(--dsh-content-font-delta, 0px));
}
@media (prefers-reduced-motion: no-preference) {
:where(.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 {
animation: running-follows-output 110ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Switches only change the duration of a move its structural change already
created. A duration change applies as if the move had it from its start, so
turning a switch off never replays a move whose structural change is older
than the move's duration, and plays the remainder of one that is younger;
turning a switch on completes a running move at once. The structural rules
above are wrapped in :where() so these rules outrank them. An input fold
runs both moves at zero duration. */
.column[data-input-fold] .running {
animation-duration: 0s;
}
/* While the tail is pinned, following scrolls absorb output growth and the
status keeps its on-screen position, so the first-output slide runs at zero
duration. Admitted steering still passes its pending echo below the status,
so the steering entrance keeps its duration. */
.column[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 {
animation-duration: 0s;
}
}
@keyframes running-follows-output {
from {
opacity: 0;
translate: 0 calc(-1 * var(--dsh-running-follow));
}
40% {
opacity: 1;
}
}
/* Empty Seats, including retained empty slot anchors, preserve Turn lookup
without contributing height or the sibling gap. */
.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) {
@@ -260,13 +216,19 @@
flex-direction: column;
align-items: flex-start;
--dsh-chat-flow-gap: 12px;
--dsh-running-follow: calc(4px + 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);
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
}
/* After the Session stops running and before the Turn's actions row arrives, the status keeps its slot
unseen so the tail does not shrink for that interval. */
.running[data-holding] {
visibility: hidden;
opacity: 0;
}
/* The last visible row before the status must contain output, not an input.
Hidden and empty seats do not separate a user message from the status, and
the steering/Inbox echoes after the status are outside the count. */
@@ -192,6 +192,7 @@ export function ChatView({
lastIsUser: lastKey !== null && nodeStore.get(lastKey)?.kind === 'user',
steeringId,
submissionId: visibleSubmissions.at(-1)?.requestId ?? null,
submissionSteers: visibleSubmissions.at(-1)?.placement === 'steering',
loadedTurns: turnNavigationItems,
transcriptView,
})
@@ -11,32 +11,37 @@ interface RunningStatusProps {
readonly startTime: number | undefined
/** Freeze the shown duration while the Client cannot observe the Session. */
readonly paused: boolean
/** Keep the slot without showing or announcing the status: the Session stopped before its Turn closed. */
readonly holding?: boolean
readonly t: ChatViewSlotProps['t']
}
/**
* Show live elapsed time after the current Turn's content without announcing ticks.
* @param props - Current Turn start time, pause state, and localized copy.
* @returns the blue running indicator; mount only while the Session is running.
* @returns the blue running indicator; mount while the Session is running, or holding its slot hidden until the
* Turn's actions row replaces it.
*/
export const RunningStatus = memo(function RunningStatus({ startTime, paused, t }: RunningStatusProps) {
export const RunningStatus = memo(function RunningStatus({ startTime, paused, holding = false, t }: RunningStatusProps) {
const [now, setNow] = useState(Date.now)
useEffect(() => {
if (startTime === undefined || paused) return
if (startTime === undefined || paused || holding) return
setNow(Date.now())
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
return () => { clearInterval(timer) }
}, [startTime, paused])
}, [startTime, paused, holding])
const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', {
duration: formatRunDuration(Math.max(1000, now - startTime), t).map(part => part.text).join(''),
})
return (
<div className={css.running} data-chat-running>
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{t('chat.deepDiving')}</span>
<div className={css.running} data-chat-running data-holding={holding ? '' : undefined} aria-hidden={holding || undefined}>
{holding ? null : (
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{t('chat.deepDiving')}</span>
)}
<span className={css.runningDivider} aria-hidden="true" />
<span className={css.runningContent}>
<RunningWhaleTail />
<TextShimmer active className={css.runningText}>{label}</TextShimmer>
<TextShimmer active={!holding} className={css.runningText}>{label}</TextShimmer>
</span>
</div>
)
@@ -23,6 +23,12 @@ export interface FlowMotionRows {
reveal(element: HTMLElement): void
/** @param element - row whose active transition should be cancelled without hiding it. */
cancelMotion(element: HTMLElement): void
/**
* Hide a row at once, outside a motion scope; the owning viewport measures the layout first.
* @param element - visible row.
* @param commit - applies the hidden state.
*/
hideNow?(element: HTMLElement, commit: () => void): void
}
/** Geometry captured before one fold batch changes its boxes. */
@@ -136,9 +142,13 @@ interface OwnedBox {
* Create animation coordination without cross-viewport broadcasts.
* @param driver - the viewport's driver for the next batch; null while detached.
* @param foldingChanged - disable or restore this viewport's native scroll anchoring.
* @param beforeInstantHide - measures the layout before a row hides at once outside a motion scope.
* @returns stable callbacks and cleanup for this viewport's animations.
*/
export function createFlowMotion(driver: () => InputFoldDriver | null, foldingChanged: (active: boolean) => void): FlowMotion {
export function createFlowMotion(
driver: () => InputFoldDriver | null, foldingChanged: (active: boolean) => void,
beforeInstantHide?: (element: HTMLElement) => void,
): FlowMotion {
const running = new Map<HTMLElement, () => void>()
let folding = 0
const foldIdleListeners = new Set<() => void>()
@@ -477,8 +487,13 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
for (const cancel of [...running.values()]) cancel()
}
function hideNow(element: HTMLElement, commit: () => void): void {
beforeInstantHide?.(element)
commit()
}
return {
collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, clear,
collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, clear, hideNow,
inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing && batchMember(box))),
}
}
@@ -496,6 +511,7 @@ function useFlowTransition(motion: FlowMotionRows | undefined, grow: boolean): H
if (hidden) {
if (hiddenOrCollapsing(element)) return
if (mounted && element.closest(MOTION_SCOPE) !== null && motion !== undefined) motion.collapse(element, commit)
else if (mounted && motion?.hideNow !== undefined) motion.hideNow(element, commit)
else commit()
return
}
@@ -89,11 +89,12 @@ export class ChatReading {
/**
* Follow the tail on the active fold's animation clock.
* @param input - whether the reader's own input requests the landing rather than a content change.
* @returns whether a fold batch owns the tail movement instead of a separate scroll animation.
*/
followInputFold(): boolean {
followInputFold(input: boolean): boolean {
this.cancelPending()
return this.viewport.followInputFold(this.follow)
return this.viewport.followInputFold(this.follow, input)
}
/**
@@ -217,8 +218,6 @@ export class ChatReading {
private publish(state: ChatReadingState): void {
this.follow.setFollowing(state.followingTail)
// Follow intent and pending sampling settle here, not only on the next scroll or resize.
this.viewport.refreshTailPin()
if (state.initialized === this.state.initialized && state.followingTail === this.state.followingTail
&& state.activeTurn === this.state.activeTurn) return
this.state = state
@@ -17,6 +17,8 @@ export interface ChatScrollInput extends ChatNavigationInput {
readonly lastIsUser: boolean
readonly steeringId: string | null
readonly submissionId: string | null
/** The latest visible submission steers the running Turn instead of opening a new one. */
readonly submissionSteers: boolean
readonly running: boolean
/** Enable the browser-local delayed-fold experiment and its input reveal animation. */
readonly deferCompletedTurns: boolean
@@ -44,8 +46,8 @@ interface ChatScrollState extends ChatReadingState {
*/
export function useChatScroll(input: ChatScrollInput): ChatScrollState {
const {
ready, order, firstSeq, lastKey, lastIsUser, steeringId, submissionId, running, deferCompletedTurns,
loadedTurns, chatScroll, hasMore, loadingOlder, loadOlder, loadThrough, transcriptView,
ready, order, firstSeq, lastKey, lastIsUser, steeringId, submissionId, submissionSteers, running,
deferCompletedTurns, loadedTurns, chatScroll, hasMore, loadingOlder, loadOlder, loadThrough, transcriptView,
} = input
const { viewport, listRef, columnRef } = useChatViewport()
const { reading, state } = useChatReading(viewport, chatScroll, loadedTurns.at(-1)?.turn ?? null)
@@ -64,11 +66,11 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
const current = content.current.input
if (!current.ready && !content.current.opened) return
const previous = content.current.applied
const ownInput = (current.lastIsUser && current.lastKey !== previous?.lastKey)
|| (current.steeringId !== null && current.steeringId !== previous?.steeringId
&& current.steeringId !== previous?.submissionId)
|| (current.submissionId !== null && current.submissionId !== previous?.submissionId
&& current.submissionId !== previous?.steeringId)
const steeringInput = current.steeringId !== null && current.steeringId !== previous?.steeringId
&& current.steeringId !== previous?.submissionId
const submissionInput = current.submissionId !== null && current.submissionId !== previous?.submissionId
&& current.submissionId !== previous?.steeringId
const ownInput = (current.lastIsUser && current.lastKey !== previous?.lastKey) || steeringInput || submissionInput
if (reading.pending && !ownInput) return
content.current.applied = current
if (current.ready && !content.current.opened) {
@@ -79,8 +81,11 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
}
if (ownInput) {
navigation.cancel()
if (current.deferCompletedTurns && reading.followInputFold()) return
reading.followTail(current.deferCompletedTurns ? 'smooth' : 'instant')
if (current.deferCompletedTurns && reading.followInputFold(true)) return
// Steering lands at once: the running Turn keeps streaming above its echo, and that growth would push
// the echo down under a native animation.
const steering = steeringInput || (submissionInput && current.submissionSteers)
reading.followTail(current.deferCompletedTurns && !steering ? 'smooth' : 'instant')
return
}
if (navigation.contentCommitted()) {
@@ -93,7 +98,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|| current.steeringId !== previous.steeringId || current.submissionId !== previous.submissionId
if (tipChanged && reading.followingTail) {
navigation.cancel()
if (current.deferCompletedTurns && reading.followInputFold()) return
if (current.deferCompletedTurns && reading.followInputFold(false)) return
reading.followTail()
} else navigation.reconcile()
}, [reading, navigation])
@@ -136,8 +141,8 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
useLayoutEffect(() => {
const previous = content.current.input
content.current.input = {
ready, order, lastKey, lastIsUser, steeringId, submissionId, running, loadedTurns, chatScroll,
deferCompletedTurns, transcriptView, ...navigationInput,
ready, order, lastKey, lastIsUser, steeringId, submissionId, submissionSteers, running, loadedTurns,
chatScroll, deferCompletedTurns, transcriptView, ...navigationInput,
}
if (!deferCompletedTurns) {
cancelPendingFollow()
@@ -151,7 +156,8 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
if (layoutChanged && content.current.opened) reading.refreshActiveTurn()
}, [
viewport, reading, processContent, navigationInput, ready, order, lastKey, lastIsUser,
steeringId, submissionId, running, loadedTurns, chatScroll, deferCompletedTurns, transcriptView, cancelPendingFollow,
steeringId, submissionId, submissionSteers, running, loadedTurns, chatScroll, deferCompletedTurns, transcriptView,
cancelPendingFollow,
])
const previousView = useRef(transcriptView)
@@ -47,6 +47,21 @@ export interface FoldReader {
readonly pending: () => boolean
}
/** A top-level row a closing shrink may keep still, measured before the rows close. */
interface ShrinkAnchor {
readonly element: HTMLElement
/** Distance from the row's top to the end of the content last painted. */
readonly fromEnd: number
}
/** Layout measured at the first instant hide of a commit; only the size observation of that frame uses it. */
interface ClosingLayout {
/** The first visible row, then the rows above it within one scrollport. */
readonly rows: readonly ShrinkAnchor[]
/** Content height without fold room when the rows were measured. */
readonly content: number
}
interface PagingPosition {
readonly row: HTMLElement
readonly position: ChatScrollPosition
@@ -65,12 +80,16 @@ export class ChatViewport {
private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null }
private paging: PagingPosition | null = null
private reader: FoldReader | null = null
/** Content overflow at the latest scroll or resize measurement; follow-intent commits reuse it. */
private overflowing = false
/** Whether the column carries `data-chat-tail-pinned`. */
private tailPinned = false
/** Rows measured before an instant close; see {@link ChatViewport.keepAboveClosing}. */
private closing: ClosingLayout | null = null
/** Discards a closing measurement that no size observation consumed by the end of its frame. */
private closingFrame: number | null = null
/** Content height without fold room at the latest size observation: the content that frame painted. */
private settledContent = 0
/** The pending or running fold batch lands at the tail. */
private tailRequested = false
/** The tail request came from the reader's own input rather than from a content change. */
private tailInput = false
/** Switches the running batch to a tail landing; null while no batch runs. */
private retarget: (() => void) | null = null
/** Transition state and row callbacks owned by this viewport's attachment. */
@@ -79,6 +98,7 @@ export class ChatViewport {
(active) => {
if (this.elements !== null) this.elements.scroller.style.overflowAnchor = active ? 'none' : ''
},
(element) => { this.measureClosing(element) },
)
/**
@@ -100,12 +120,13 @@ export class ChatViewport {
if (typeof ResizeObserver !== 'undefined') {
this.observer = new ResizeObserver(() => {
if (this.elements !== elements) return
// Read while layout is still clean, before this callback writes room or scroll positions.
const overflowing = this.contentOverflows(scrollMetrics(elements.scroller))
// Read while layout is still clean, before this callback writes room or scroll positions. Those writes
// leave the content height unchanged, so it is also the content this frame paints.
this.settledContent = elements.scroller.scrollHeight - this.spacerHeight()
this.keepAboveClosing()
this.reclaimBelow()
this.invalidate()
this.events?.resize()
this.refreshTailPin(overflowing)
})
this.observer.observe(column)
this.observer.observe(scroller)
@@ -117,12 +138,8 @@ export class ChatViewport {
detach(): void {
this.stopPreserving()
this.motion.clear()
if (this.elements !== null) {
this.elements.scroller.style.overflowAnchor = ''
delete this.elements.column.dataset.chatTailPinned
}
this.overflowing = false
this.tailPinned = false
if (this.elements !== null) this.elements.scroller.style.overflowAnchor = ''
this.dropClosing()
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)
@@ -131,6 +148,7 @@ export class ChatViewport {
this.elements = null
this.events = null
this.tailRequested = false
this.tailInput = false
this.retarget = null
this.turns = []
this.observation = { top: 0, landing: null }
@@ -186,24 +204,22 @@ export class ChatViewport {
*/
bindReader(reader: FoldReader): () => void {
this.reader = reader
this.refreshTailPin()
return () => {
if (this.reader !== reader) return
this.reader = null
this.refreshTailPin()
}
return () => { if (this.reader === reader) this.reader = null }
}
/**
* Land the pending or running fold batch at the tail instead of starting a separate scroll.
* @param follow - follow controller whose native animation yields to the batch.
* @param input - whether the reader's own input requests the landing; a content change, such as a Turn
* ending, requests it in place of the immediate follow.
* @returns whether a fold owns the tail movement.
*/
followInputFold(follow: ScrollFollow): boolean {
followInputFold(follow: ScrollFollow, input: boolean): boolean {
// Rows still closing on CSS transitions after a batch have no batch to consume the request.
if (this.elements === null || this.reader === null || !this.motion.inputActive()) return false
this.interruptFollow(follow)
this.tailRequested = true
this.tailInput ||= input
this.retarget?.()
return true
}
@@ -263,11 +279,27 @@ export class ChatViewport {
begin: (boxes) => {
// Own input lands at the tail; otherwise an unsampled reading movement outranks follow intent.
tail = this.tailRequested || (follow.active && !reader.pending())
const input = this.tailInput
this.tailRequested = false
this.tailInput = false
viewportTop = elements.scroller.getBoundingClientRect().top
start = elements.scroller.scrollTop
const originalRoom = this.spacerHeight()
initialHeight = Math.max(elements.column.offsetHeight, elements.scroller.scrollHeight - originalRoom)
if (tail && !input) {
// The batch replaces the immediate follow of a reader pinned to the tail, following content that
// overflows with no fold room: content that grew outside the batch since that paint, such as a Turn's
// duration control, is taken before the first frame as following takes streaming growth, so the
// batch only animates its own rows.
const pinned = originalRoom === 0 && this.settledContent > elements.scroller.clientHeight
&& follow.active && !reader.pending() && !follow.animating
const external = elements.scroller.scrollHeight - originalRoom - this.settledContent
if (external > 0.5 && pinned) {
elements.scroller.scrollTop = start + external
start = elements.scroller.scrollTop
this.observation = { top: start, landing: null }
}
}
let above = 0
const below: HTMLElement[] = []
for (const box of boxes) {
@@ -355,6 +387,7 @@ export class ChatViewport {
finish: (cancelled) => {
this.retarget = null
this.tailRequested = false
this.tailInput = false
if (this.elements !== elements) return
// Commit the final sibling margins while their fold transition override still applies.
void elements.column.offsetHeight
@@ -422,8 +455,129 @@ export class ChatViewport {
return null
}
/**
* Measure, before the first row of a commit closes at once, the rows a following reader's shrink keeps
* still: the first row visible in the last painted layout, the rows after it that were on screen, and the
* rows above it within one scrollport.
* Rows above the closing rows keep their painted screen positions, which the commit did not move; the same
* commit can add rows between them and the closing rows, as a Turn's duration control does, so rows from
* the closing rows on are placed against the current end. Reads happen only when rows close, never on the
* following path's size observations. A measurement the same frame's size observation does not use is
* discarded after that frame.
* @param element - the first row of this commit to close.
*/
private measureClosing(element: HTMLElement): void {
const elements = this.elements
const reader = this.reader
if (elements === null || reader === null || this.closing !== null || !element.isConnected) return
if (!reader.follow.active || reader.follow.animating || reader.pending()) return
if (this.paging !== null || this.motion.foldActive() || this.motion.inputActive()) return
const scroller = elements.scroller
const scrollHeight = scroller.scrollHeight
const height = scroller.clientHeight
// The commit has not moved the scroll position, so it is the painted one, within the follow threshold too.
const painted = scroller.scrollTop + height
const origin = scroller.getBoundingClientRect().top - scroller.scrollTop
const rows = (elements.column.querySelector(':scope > [data-slot="conversation.chat.flow"]') ?? elements.column).children
// End is the content end each row is placed against: painted for rows above the closing rows.
const end = (row: Element): number => row !== element && !row.contains(element)
&& (element.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_PRECEDING) !== 0 ? painted : scrollHeight
const screen = (row: Element, offset: number): number => offset - origin - (end(row) - height)
let low = 0
let high = rows.length
while (low < high) {
const middle = (low + high) >>> 1
const row = rows[middle] as Element
if (screen(row, row.getBoundingClientRect().bottom) > 0) high = middle
else low = middle + 1
}
let first = -1
for (let index = low; index < rows.length && first < 0; index++) {
const row = rows[index] as HTMLElement
const rect = row.getBoundingClientRect()
if (screen(row, rect.top) >= height) break
if (rect.height > 0 && !row.hasAttribute('hidden')) first = index
}
if (first < 0) return
const anchors: ShrinkAnchor[] = []
const add = (row: HTMLElement, rect: DOMRect): void => {
if (rect.height > 0 && !row.hasAttribute('hidden')) anchors.push({ element: row, fromEnd: end(row) - (rect.top - origin) })
}
add(rows[first] as HTMLElement, (rows[first] as HTMLElement).getBoundingClientRect())
// When the first visible row closes, the reader's eyes are on the rows that follow it on screen.
for (let index = first + 1; index < rows.length; index++) {
const row = rows[index] as HTMLElement
const rect = row.getBoundingClientRect()
if (screen(row, rect.top) >= height) break
add(row, rect)
}
for (let index = first - 1; index >= 0; index--) {
const row = rows[index] as HTMLElement
const rect = row.getBoundingClientRect()
if (screen(row, rect.bottom) <= -height) break
add(row, rect)
}
this.closing = { rows: anchors, content: scrollHeight - this.spacerHeight() }
if (typeof requestAnimationFrame === 'function') {
// This frame's size observation runs before the second callback; nothing later may use the measurement.
this.closingFrame = requestAnimationFrame(() => {
this.closingFrame = requestAnimationFrame(() => {
this.closingFrame = null
this.closing = null
})
})
}
}
private dropClosing(): void {
this.closing = null
if (this.closingFrame !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.closingFrame)
this.closingFrame = null
}
/**
* Keep a following reader's content still after rows closed at once, for example work details at Turn
* completion: the first visible row keeps its screen position, or, when it closed, the first surviving row
* that followed it on screen, else the nearest surviving row above it. Room below fills the gap, at most half
* the scrollport; beyond that the content end stops at the scrollport's middle. Called from the size
* observation, after layout and before paint; only the observation of the frame that closed the rows uses
* the measurement, and only while the reader still follows the tail with no fold batch, native animation,
* unsampled movement, or paging writing positions.
*/
private keepAboveClosing(): void {
const closing = this.closing
const elements = this.elements
const reader = this.reader
if (closing === null) return
this.dropClosing()
if (elements === null || reader === null || !reader.follow.active || reader.follow.animating || reader.pending()) return
if (this.paging !== null || this.motion.foldActive() || this.motion.inputActive()) return
const scroller = elements.scroller
const content = scroller.scrollHeight - this.spacerHeight()
// Growth in the same commit can outweigh the closed rows; nothing then shrank to compensate.
if (content >= closing.content - 0.5) return
const height = scroller.clientHeight
const origin = scroller.getBoundingClientRect().top - scroller.scrollTop
const cap = height / 2
let top = content + cap - height
for (const row of closing.rows) {
if (!row.element.isConnected || row.element.closest('[hidden]') !== null) continue
const rect = row.element.getBoundingClientRect()
if (rect.height === 0) continue
top = rect.top - origin - (height - row.fromEnd)
break
}
if (top + height - content > cap) top = content + cap - height
top = Math.max(0, top)
this.setSpacer(top === 0 ? 0 : Math.max(0, top + height - content))
if (scroller.scrollTop !== top) scroller.scrollTop = top
const metrics = scrollMetrics(scroller)
this.observation = { top: metrics.top, landing: { metrics, position: null, turn: this.latestTurn } }
}
/** Clear fold reservations after a display-mode change, allowing the scroll range to shrink. */
resetBelow(): void {
this.dropClosing()
this.setSpacer(0)
this.invalidate()
}
@@ -771,47 +925,16 @@ export class ChatViewport {
this.invalidate()
if (this.paging !== null) {
this.events?.resize()
this.refreshTailPin()
return
}
const scroll = this.readScroll()
if (scroll !== null) this.events?.scroll(scroll)
this.refreshTailPin(scroll === null ? undefined : this.contentOverflows(scroll.metrics))
}
/**
* Mark the column while the tail is pinned: the reader follows the floor with no unsampled reader
* movement, the test fold batches use for a tail landing, and the content overflows with no fold room
* below it, so following scrolls absorb output growth and the status keeps its on-screen position. Fold
* room, like the empty space below content that fits, takes growth before any scroll, so the status moves
* there. The first-output slide runs at zero duration while pinned. A resize calls this after its own
* follow scroll, before paint, so a slide that starts in that frame finishes before it is drawn; scroll and
* scrollend call it after delivering the sample, and follow-intent commits call it with the latest
* measurement. The attribute changes only with the pin.
* @param overflowing - a fresh overflow measurement; omitted, the latest one is reused.
*/
refreshTailPin(overflowing = this.overflowing): void {
this.overflowing = overflowing
const elements = this.elements
if (elements === null) return
const reader = this.reader
const pinned = overflowing && this.spacerHeight() === 0 && reader !== null && reader.follow.active && !reader.pending()
if (pinned === this.tailPinned) return
this.tailPinned = pinned
if (pinned) elements.column.dataset.chatTailPinned = ''
else delete elements.column.dataset.chatTailPinned
}
/** Room below closing rows is not content: only rows taller than the scrollport scroll on growth. */
private contentOverflows(metrics: ViewportMetrics): boolean {
return metrics.floor - this.spacerHeight() > 0
}
private readonly onScrollEnd = (event: Event): void => {
if (event.target === this.elements?.scroller) {
this.events?.scrollEnd(true)
this.reclaimBelow()
this.refreshTailPin()
} else if (event.target instanceof HTMLElement && event.target.hasAttribute('data-step-process-body')) this.events?.scrollEnd(false)
}
@@ -24,8 +24,8 @@
en: 75564701ecd3d1bd
zh: 9c042288a15d441d
/chat-conversation-node-rules/whole-turn-folding:
en: 09314600edba4979
zh: da8d599372c7899c
en: 5e008ac2ab8de86a
zh: d782e34c7cfcb0f2
/chat-conversation-node-rules/display-modes:
en: ada5a60d2c46f6a8
zh: f3eaeb5cb4d9b3de
@@ -103,7 +103,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. When the transcript overflows the viewport and follows its bottom, following scrolls keep the indicator in place as output grows, so this slide does not play; admitted steering still moves the indicator past its pending echo, so the steering entrance plays while the page scrolls by the added slot. The steering entrance starts one slot above the indicator's new position rather than where it stood, and its fade-in covers the difference. 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. While the Client connection is disconnected or reconnecting, the clock holds its last value; after reconnection it shows the elapsed time since the Turn start again. 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. Output appearing above the indicator moves it without animation. When steering is admitted, the indicator enters its new slot below the admitted rows from one slot higher rather than from where it stood, and its fade-in covers the difference. 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. While the Client connection is disconnected or reconnecting, the clock holds its last value; after reconnection it shows the elapsed time since the Turn start again. When the Session stops running, the indicator keeps its slot, hidden and silent, until the Turn's actions row replaces it, so the tail does not shrink in between; 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”.
@@ -103,7 +103,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行中的 Step 尚无输出时,运行指示紧贴在输入下方。第一条输出行出现时,运行指示从原位置滑入该行下方的位置;滑动距离等于该行间距加一行高度(随字号设置变化),因此起点正是它原来的位置。之后的输出不再带动画。transcript 超出视口并跟随底部时,输出增长由跟随滚动吸收,运行指示保持原位,因此这次下滑不播放;被领取的 steering 仍会让运行指示越过其等待中的回显,因此 steering 进场照常播放,同时页面随新增的槽位滚动。steering 进场从运行指示新位置上方一个槽位处开始,而不是从它原来的位置开始,淡入掩盖了这段差距。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。Client 连接断开或重新连接期间,时钟停在最后显示的值;恢复连接后重新显示自 Turn 开始以来的用时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行指示上方出现输出时,运行指示随之下移,不带动画。steering 被领取时,运行指示从新位置上方一个槽位处进入被领取行下方的新位置,而不是从它原来的位置开始,淡入掩盖了这段差距。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。Client 连接断开或重新连接期间,时钟停在最后显示的值;恢复连接后重新显示自 Turn 开始以来的用时。会话停止运行后,运行指示以隐藏、不播报的方式保留其位置,直到该 Turn 的操作行接替,因此尾部在此期间不会缩短;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
顶部用时/状态控件与唤醒标题沿用二级汇总标题字号,并跟随字号设置。已完成用时的数字使用代码等宽字体;运行和完成状态的分钟和秒数均不补前导零。含用时的运行文案末尾附加一个空格及“···”,完成后显示“已完成,用时”。
@@ -43,6 +43,7 @@ function mountScroll(deferCompletedTurns = true, initial: Partial<ChatScrollInpu
lastIsUser: false,
steeringId: null,
submissionId: null,
submissionSteers: false,
running: false,
deferCompletedTurns,
transcriptView: 'verbose',
@@ -104,6 +105,164 @@ function mountScroll(deferCompletedTurns = true, initial: Partial<ChatScrollInpu
}
}
/** A scrollport of stacked rows whose boxes follow scrollTop; closing a row clamps like a browser. */
function mountRows(heights: readonly number[]) {
const callbacks: { motion?: FlowMotionRows; resize?: () => void } = {}
class Observer implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { callbacks.resize = () => { callback([], this) } }
observe(): void {}
unobserve(): void {}
disconnect(): void { delete callbacks.resize }
}
vi.stubGlobal('ResizeObserver', Observer)
const input: ChatScrollInput = {
ready: true, order: [], firstSeq: null, lastKey: null, lastIsUser: false,
steeringId: null, submissionId: null, submissionSteers: false, running: false, deferCompletedTurns: false,
transcriptView: 'standard', loadedTurns: [], hasMore: false, loadingOlder: false,
loadOlder: vi.fn(), loadThrough: vi.fn(async () => {}), chatScroll: { read: () => null, save: vi.fn() },
}
const view = render(<ScrollHarness input={input} onMotion={(motion) => { callbacks.motion = motion }}
/>)
const scroller = within(view.container).getByTestId('scrollport')
const column = within(view.container).getByTestId('column')
const spacer = scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')!
const sizes = new Map<HTMLElement, number>()
const rows = heights.map((height) => {
const row = document.createElement('div')
sizes.set(row, height)
column.append(row)
return row
})
const sizeOf = (row: HTMLElement): number => row.hidden ? 0 : sizes.get(row) ?? 0
const docTop = (row: HTMLElement): number => {
let top = 0
for (const each of rows) {
if (each === row) return top
top += sizeOf(each)
}
return top
}
const content = (): number => rows.reduce((sum, row) => sum + sizeOf(row), 0)
const room = (): number => Number.parseFloat(spacer.style.height) || 0
const floor = (): number => Math.max(0, content() + room() - 400)
let top = 0
Object.defineProperties(scroller, {
clientHeight: { value: 400 },
scrollHeight: { get: () => content() + room() },
scrollTop: { get: () => top, set: (value: number) => { top = Math.max(0, Math.min(value, floor())) } },
scrollTo: { value: (options: ScrollToOptions) => { top = Math.max(0, Math.min(options.top ?? top, floor())) } },
getBoundingClientRect: { value: () => ({ top: 0, bottom: 400, height: 400 }) },
})
Object.defineProperty(column, 'offsetHeight', { get: content })
for (const row of rows) {
Object.defineProperty(row, 'getBoundingClientRect', { value: () => {
const rowTop = docTop(row) - top
return { top: rowTop, bottom: rowTop + sizeOf(row), height: sizeOf(row) }
} })
}
const resize = (): void => { act(() => { callbacks.resize?.() }) }
resize()
const row = (index: number): HTMLElement => {
const found = rows[index]
if (found === undefined) throw new Error(`no row ${String(index)}`)
return found
}
return {
scroller, row, room, resize,
top: () => top,
screenTop: (row: HTMLElement) => docTop(row) - top,
grow: (row: HTMLElement, by: number) => { sizes.set(row, (sizes.get(row) ?? 0) + by) },
/** Close rows at once, as completion-time folding does, then let layout clamp and observe. */
close: (...closing: HTMLElement[]) => {
act(() => {
for (const row of closing) callbacks.motion?.hideNow?.(row, () => { row.hidden = true })
top = Math.min(top, floor())
callbacks.resize?.()
})
},
/** Close one row while the same commit grows another, as a Turn's duration control does. */
closeGrowing: (closing: HTMLElement, grown: HTMLElement, by: number) => {
act(() => {
callbacks.motion?.hideNow?.(closing, () => { closing.hidden = true })
sizes.set(grown, (sizes.get(grown) ?? 0) + by)
top = Math.min(top, floor())
callbacks.resize?.()
})
},
}
}
describe('Chat scroll: a closing shrink keeps the content above still', () => {
it('keeps the first visible row still and fills the gap below with room', () => {
const h = mountRows([700, 100, 200])
expect(h.top()).toBe(600)
const [first, step, answer] = [h.row(0), h.row(1), h.row(2)]
h.close(step)
expect(h.screenTop(first)).toBe(-600)
expect(h.screenTop(answer)).toBe(100)
expect(h.room()).toBe(100)
})
it('moves nothing on screen when the closing rows are above the scrollport', () => {
const h = mountRows([200, 300, 500])
const [above, visible] = [h.row(0), h.row(2)]
h.close(above)
expect(h.screenTop(visible)).toBe(-100)
expect(h.room()).toBe(0)
})
it('keeps the row that followed a closed first row on screen still', () => {
const h = mountRows([500, 600, 100])
const [steps, answer] = [h.row(1), h.row(2)]
h.close(steps)
expect(h.screenTop(answer)).toBe(300)
expect(h.room()).toBe(0)
})
it('keeps the nearest surviving row above when no row followed a closed first row on screen', () => {
const h = mountRows([500, 900])
const [earlier, steps] = [h.row(0), h.row(1)]
h.close(steps)
// The earlier row would need more room than half the scrollport; the content end stops at the middle.
expect(h.screenTop(earlier)).toBe(-300)
expect(h.room()).toBe(200)
})
it('lets later growth take the room before following scrolls', () => {
const h = mountRows([700, 100, 200])
const [step, answer] = [h.row(1), h.row(2)]
h.close(step)
h.grow(answer, 60)
h.resize()
expect(h.room()).toBe(40)
expect(h.top()).toBe(600)
})
it('leaves a reader away from the tail to the browser', () => {
const h = mountRows([700, 100, 200])
const step = h.row(1)
fireEvent.wheel(h.scroller)
h.scroller.scrollTop = 100
fireEvent.scroll(h.scroller)
act(() => { vi.advanceTimersByTime(500) })
h.close(step)
expect(h.room()).toBe(0)
})
it('uses the measurement only for the observation of the commit that closed the rows', () => {
const h = mountRows([700, 100, 200])
const [step, answer] = [h.row(1), h.row(2)]
// Growth in the same commit outweighs the closed row, so nothing shrinks to compensate.
h.closeGrowing(step, answer, 100)
expect(h.room()).toBe(0)
// A later shrink follows the tail like any other; the stale measurement does not pull the reader.
h.grow(answer, -150)
h.resize()
expect(h.room()).toBe(0)
expect(h.top()).toBe(450)
})
})
const visibleFoldCases = [
{ name: 'E/E', earlier: 2400, steps: 1000, body: 400, viewport: 800, top: 800, input: 80, target: 2080, room: 0 },
{ name: 'E/T', earlier: 600, steps: 1000, body: 100, viewport: 800, top: 200, input: 80, target: 200, room: 220 },
@@ -129,7 +288,7 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea
const onMotion = (value: FlowMotionRows): void => { motion = value }
let input: ChatScrollInput = {
ready: false, order: [], firstSeq: null, lastKey: null, lastIsUser: false,
steeringId: null, submissionId: null, running: false, deferCompletedTurns: true,
steeringId: null, submissionId: null, submissionSteers: false, running: false, deferCompletedTurns: true,
transcriptView: 'detailed', loadedTurns: [], hasMore: false, loadingOlder: false,
loadOlder: vi.fn(), loadThrough: vi.fn(async () => {}), chatScroll: { read: () => null, save: vi.fn() },
}
@@ -381,6 +540,19 @@ describe('Chat scroll collapse timing', () => {
}
})
it.each([
['a local steering echo', { submissionId: 'steer', submissionSteers: true }],
['a pending steering message', { steeringId: 'inbox-steer' }],
] as const)('lands %s at once under either fold timing', (_, change) => {
for (const deferred of [false, true]) {
const h = mountScroll(deferred)
h.update(change)
expect(h.scrollTo).not.toHaveBeenCalled()
expect(h.scroller.scrollTop).toBe(600)
h.view.unmount()
}
})
it('coalesces submitted inputs into one frame-owned fold', async () => {
const h = mountScroll()
h.startFold()
@@ -4589,7 +4589,13 @@ describe('ChatView', () => {
expect(icon?.querySelectorAll('path')).toHaveLength(1)
expect(icon?.getAttribute('style')).toBeNull()
expect(status?.querySelector('animate')).toBeNull()
// The running bit clears before the Turn closes: the status keeps its slot unseen and unannounced.
act(() => { h.setSession({ running: false }) })
const held = column.querySelector<HTMLElement>('[data-chat-running]')
expect(held?.hasAttribute('data-holding')).toBe(true)
expect(held?.getAttribute('aria-hidden')).toBe('true')
expect(within(held!).queryByRole('status')).toBeNull()
act(() => { h.setChat({ turnEnds: new Map([[1, 2]]) }) })
expect(column.querySelector('[data-chat-running]')).toBeNull()
})
@@ -1,7 +1,6 @@
// @vitest-environment jsdom
import { expect, it, onTestFinished, vi } from 'vitest'
import { ChatReading } from '../src/client/chat/use-chat-reading.ts'
import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts'
import { ScrollFollow, scrollMetrics } from '../src/client/chat/use-scroll-follow.ts'
@@ -561,167 +560,3 @@ 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 sampled follow intent holds over overflowing content, writing the mark on change', async () => {
vi.useFakeTimers()
let notifyResize = () => {}
class Observer implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } }
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
vi.stubGlobal('ResizeObserver', Observer)
const scroller = document.createElement('div')
const column = document.createElement('div')
scroller.append(column)
document.body.append(scroller)
let content = 200
let top = 0
const floor = () => Math.max(0, content - 300)
Object.defineProperties(scroller, {
clientHeight: { configurable: true, get: () => 300 },
scrollHeight: { configurable: true, get: () => Math.max(300, content) },
scrollTop: {
configurable: true,
get: () => Math.min(top, floor()),
set: (value: number) => { top = Math.max(0, Math.min(value, floor())) },
},
})
const viewport = new ChatViewport()
viewport.attach(scroller, column)
const follow = new ScrollFollow(true, 4)
const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, {
initialized: true, followingTail: true, activeTurn: 1,
}, vi.fn(), follow)
const unbind = reading.bindFolds()
viewport.connect({
scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, interact: () => {},
resize: () => { reading.onResize() },
})
const writes: (string | null)[] = []
const mutations = new MutationObserver((records) => {
for (const record of records) writes.push(record.oldValue)
})
mutations.observe(column, { attributes: true, attributeFilter: ['data-chat-tail-pinned'], attributeOldValue: true })
onTestFinished(() => {
mutations.disconnect()
unbind()
reading.dispose()
viewport.detach()
scroller.remove()
vi.useRealTimers()
vi.unstubAllGlobals()
})
const pinned = () => column.hasAttribute('data-chat-tail-pinned')
const scrollTo = (value: number) => {
scroller.scrollTop = value
scroller.dispatchEvent(new Event('scroll'))
}
// Content that fits moves the tail on screen, so status moves keep their duration.
notifyResize()
expect(pinned()).toBe(false)
// Growth that overflows is followed to the floor within the same resize, then pinned.
content = 900
notifyResize()
expect(scroller.scrollTop).toBe(600)
expect(pinned()).toBe(true)
// Further growth and scroll deliveries at the floor keep the pin without rewriting it.
content = 950
notifyResize()
scroller.dispatchEvent(new Event('scroll'))
expect(pinned()).toBe(true)
// A reader scroll away leaves follow intent set until its sample settles; growth in that window is not
// followed, so the pin drops at once.
scrollTo(300)
expect(reading.pending).toBe(true)
expect(follow.active).toBe(true)
expect(pinned()).toBe(false)
content = 1_000
notifyResize()
expect(scroller.scrollTop).toBe(300)
expect(pinned()).toBe(false)
// The settled sample releases follow intent; returning to the floor restores both.
await vi.advanceTimersByTimeAsync(500)
expect(follow.active).toBe(false)
expect(pinned()).toBe(false)
scrollTo(700)
expect(follow.active).toBe(true)
expect(pinned()).toBe(true)
await Promise.resolve()
// Set, removed, set: one write per pin change.
expect(writes).toEqual([null, '', null])
viewport.detach()
expect(pinned()).toBe(false)
})
it('leaves the tail unpinned while fold room takes growth, then pins once the room is used', () => {
let notifyResize = () => {}
class Observer implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } }
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
vi.stubGlobal('ResizeObserver', Observer)
const scroller = document.createElement('div')
const column = document.createElement('div')
const spacer = document.createElement('div')
spacer.dataset.chatTurnSpacer = ''
scroller.append(column, spacer)
document.body.append(scroller)
let content = 900
let top = 0
const room = () => Number.parseFloat(spacer.style.height) || 0
const floor = () => Math.max(0, content + room() - 300)
Object.defineProperties(scroller, {
clientHeight: { configurable: true, get: () => 300 },
scrollHeight: { configurable: true, get: () => Math.max(300, content + room()) },
scrollTop: {
configurable: true,
get: () => Math.min(top, floor()),
set: (value: number) => { top = Math.max(0, Math.min(value, floor())) },
},
})
const viewport = new ChatViewport()
viewport.attach(scroller, column)
const follow = new ScrollFollow(true, 4)
const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, {
initialized: true, followingTail: true, activeTurn: 1,
}, vi.fn(), follow)
const unbind = reading.bindFolds()
viewport.connect({
scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, interact: () => {},
resize: () => { reading.onResize() },
})
onTestFinished(() => {
unbind()
reading.dispose()
viewport.detach()
scroller.remove()
vi.unstubAllGlobals()
})
const pinned = () => column.hasAttribute('data-chat-tail-pinned')
notifyResize()
expect(scroller.scrollTop).toBe(600)
expect(pinned()).toBe(true)
// A fold landing leaves room below the content and commits the tail landing.
spacer.style.height = '200px'
scroller.scrollTop = 800
reading.followTail()
expect(pinned()).toBe(false)
// Growth fills the room without scrolling, so the status moves on screen and keeps its slide.
content = 940
notifyResize()
expect(room()).toBe(160)
expect(scroller.scrollTop).toBe(800)
expect(pinned()).toBe(false)
// Once growth has used the room, following scrolls absorb it again.
content = 1_100
notifyResize()
expect(room()).toBe(0)
expect(scroller.scrollTop).toBe(800)
expect(pinned()).toBe(true)
})
@@ -174,6 +174,45 @@ it('keeps the reader landing while reader movement awaits sampling', async () =>
vi.advanceTimersByTime(500)
})
/** Stub the size observer so a test can mark the layout a following reader last saw painted. */
function observePaint(): () => void {
let observe = (): void => {}
vi.stubGlobal('ResizeObserver', class implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { observe = () => { callback([], this) } }
observe(): void {}
unobserve(): void {}
disconnect(): void {}
})
return () => { observe() }
}
it('takes content growth outside the batch since the last paint before animating a following reader', async () => {
const painted = observePaint()
const h = fixture({ earlier: 600, later: 0, top: 2_000, following: true })
painted()
// A row above the reader, such as a Turn's duration control, arrives with the commit that starts the batch.
h.grow(2_449)
h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(649) })
await Promise.resolve()
runFrameCount(1)
expect(h.list.scrollTop).toBe(2_049)
runFrames()
expect(h.reading.followingTail).toBe(true)
})
it('animates growth that arrives with the reader\'s own input', async () => {
const painted = observePaint()
const h = fixture({ earlier: 600, later: 0, top: 2_000, following: true })
painted()
h.grow(2_449)
h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(649) })
expect(h.reading.followInputFold(true)).toBe(true)
await Promise.resolve()
runFrameCount(1)
expect(h.list.scrollTop).toBe(2_000)
runFrames()
})
it('lands a tail batch on the committed content when it reaches past the estimate', async () => {
const h = fixture(VISIBLE_ROW)
// Content arriving with the hidden commit is not in the estimate; the landing reads the real floor.
@@ -1,4 +1,4 @@
/** CSS checks for the Turn tail slot and the running status's move below a Step's first output. */
/** CSS checks for the Turn tail slot and the running status's entrance after admitted steering. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -7,6 +7,11 @@ const read = (name: string): string =>
readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8')
describe('Turn tail slot spacing', () => {
it('gives a Turn duration control its gap without a transition while rows fold', () => {
const view = read('ChatView.module.css')
expect(view).toMatch(new RegExp(String.raw`\.column\[data-chat-motion\] > :global\(\[data-slot="conversation\.chat\.flow"\]\) > \[data-chat-flow-kind="turn-process"\] \{\s*transition: none;`))
})
it('combines the flow and footer offsets around turn-tail content', () => {
expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 4px\)/)
const tail = read('TurnTailNodeView.module.css')
@@ -42,55 +47,30 @@ describe('Turn tail slot spacing', () => {
})
})
describe('Running status move below the first output', () => {
describe('Running status entrance after admitted steering', () => {
const row = String.raw`calc\(24px \+ var\(--dsh-content-font-delta, 0px\)\)`
const travel = (gap: number) => String.raw`--dsh-running-follow: calc\(${gap}px \+ 24px \+ var\(--dsh-content-font-delta, 0px\)\);`
it('travels the first output row gap plus one font-scaled row', () => {
it('enters its new slot from one slot higher, outside reduced motion', () => {
const view = read('ChatView.module.css')
// 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(4)}`))
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.
expect(view).toMatch(new RegExp(String.raw`> \[data-chat-flow-kind="turn-process"\] \+ :nth-last-child\(2 of [^{]*\) ~ \.running \{\s*${travel(16)}`))
expect(view).toMatch(new RegExp(String.raw`@media \(prefers-reduced-motion: no-preference\) \{\s*:where\(\.column > [^{]*\[data-chat-flow-kind="steering"\]\) \+ \.running \{\s*`
+ String.raw`animation: running-enters-slot 140ms cubic-bezier\(0\.2, 0\.8, 0\.2, 1\);`))
expect(view).toMatch(new RegExp(String.raw`@keyframes running-enters-slot \{\s*from \{\s*opacity: 0;\s*`
+ String.raw`translate: 0 calc\(-44px - var\(--dsh-content-font-delta, 0px\)\);\s*\}\s*40% \{\s*opacity: 1;`))
})
it('slides the status from where it stood, like the steering entrance, outside reduced motion', () => {
const view = read('ChatView.module.css')
expect(view).toMatch(new RegExp(String.raw`@media \(prefers-reduced-motion: no-preference\) \{\s*:where\(\.column > [^{]*\[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;`))
})
it('lets input folds and the pinned tail change only the duration of a status move', () => {
it('is the only status move, and an input fold changes only its duration', () => {
const view = read('ChatView.module.css')
const normalize = (selector: string) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim()
// Structure alone names each move, so a switch turning off never adds an animation name and replays a
// finished move. The structure sits in :where() so the switch rules outrank it.
const moves = [...view.matchAll(/([^{}]+)\{\s*animation: running-(?:enters-slot|follows-output) /g)].map(match => normalize(match[1]!))
expect(moves).toHaveLength(2)
for (const selector of moves) {
expect(selector).toMatch(/^:where\(/)
expect(selector).not.toMatch(/data-input-fold|data-chat-tail-pinned/)
}
// An input fold zeroes both moves; the pinned tail zeroes only the first-output slide, whose structure it
// repeats, and leaves the entrance after admitted steering at its duration.
const slide = moves.find(selector => selector.includes('[data-chat-group-key]'))!
expect(slide.endsWith(') ~ .running')).toBe(true)
const slideStructure = slide.slice(':where('.length, -') ~ .running'.length)
// Structure alone names the move, so lifting the switch never adds an animation name and replays it.
const moves = [...view.matchAll(/([^{}]+)\{\s*animation: running-[\w-]+ /g)].map(match => normalize(match[1]!))
expect(moves).toHaveLength(1)
expect(moves[0]).toMatch(/^:where\(/)
expect(moves[0]).not.toMatch(/data-input-fold/)
const switches = [...view.matchAll(/([^{}]+)\{\s*animation-duration: 0s;\s*\}/g)].map(match => normalize(match[1]!))
expect(switches).toEqual([
'.column[data-input-fold] .running',
`${slideStructure.replace(/^\.column/, '.column[data-chat-tail-pinned]')} ~ .running`,
])
expect(view.match(/data-chat-tail-pinned/g)).toHaveLength(1)
expect(switches).toEqual(['.column[data-input-fold] .running'])
})
it('sizes every first-output row to the travelled row height', () => {
it('sizes the first output rows to one font-scaled row', () => {
expect(read('ReasoningRow.module.css')).toMatch(new RegExp(String.raw`\.root:not\(\[data-expanded\]\) \{[^}]*height: ${row};`))
expect(read('ChatGroupSeat.module.css')).toMatch(new RegExp(String.raw`\.title \{[^}]*line-height: ${row};`))
expect(read('AssistantMarkdown.module.css')).toMatch(new RegExp(String.raw`\.root \{[^}]*line-height: ${row};`))