diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 2ed33272ba..62ac3b40da 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -187,13 +187,23 @@ animation: running-follows-output 110ms cubic-bezier(0.4, 0, 0.2, 1); } - /* An input fold, or a pinned tail whose following scrolls hold the status - still, runs both status moves at zero duration. The structure alone names - the animation, so a switch only changes the duration of a move its - structural change already created: turning a switch off never replays a - finished move, and turning it on completes a running one at once. The - structural rules above are wrapped in :where() so this rule outranks them. */ - .column:is([data-input-fold], [data-chat-tail-pinned]) .running { + /* Switches only change the duration of a move its structural change already + created, so turning one off never replays a finished move and turning it on + completes a running one 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; } } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index f387c46f93..f82af1468e 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -782,9 +782,9 @@ export class ChatViewport { /** * 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, so following scrolls - * absorb content growth and the status keeps its on-screen position. Status moves run at zero duration - * there. A resize calls this after its own follow scroll, before paint, so a move that starts in that - * frame finishes before it is drawn; scroll and scrollend call it after delivering the sample, and + * absorb output growth and the status keeps its on-screen position. The first-output slide runs at zero + * duration there. 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. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index f59304584b..befd791eb1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 367940c7208b3486 - zh: 7de635c8cb8ab99a + en: c609b981a27dcdff + zh: 891f1c4d8f5f7efb /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 6fc4f54291..cb51b641e1 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -102,7 +102,7 @@ Clicking Load older anchors the first visible content item below that button in Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor. -While the Session runs, Chat shows a Session-level indicator at the running Turn's tail: a whale tail beside shimmering elapsed time. It follows the transcript rows and the echoes of submissions that open a Turn; steering still pending when a submission opens the next Turn is claimed first, so its echoes precede that submission's echo. Echoes of steering that waits for the running Turn's next Step follow the indicator. While the running Step has no output, the indicator sits directly below the input. When the first output row appears, the indicator slides from that position into its slot below the row; the travel equals the row's gap plus one row height, which follows the font size setting, so it starts exactly where it stood. Later output moves it without animation. 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. +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. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion. The top duration/status control and trigger title use the group-title font size and follow the font-size setting. Completed duration digits use the code font with tabular numerals; running and completed minutes and seconds have no leading zero. The timed running label ends with a space and “···”; completion shows “Completed in”. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 4ac1b12e0e..33355e6c9b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -102,7 +102,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。 -会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行中的 Step 尚无输出时,运行指示紧贴在输入下方。第一条输出行出现时,运行指示从原位置滑入该行下方的位置;滑动距离等于该行间距加一行高度(随字号设置变化),因此起点正是它原来的位置。之后的输出不再带动画。transcript 超出视口并跟随底部时,跟随滚动会让运行指示保持原位,因此 steering 进场和这次下滑都不播放。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。 +会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行中的 Step 尚无输出时,运行指示紧贴在输入下方。第一条输出行出现时,运行指示从原位置滑入该行下方的位置;滑动距离等于该行间距加一行高度(随字号设置变化),因此起点正是它原来的位置。之后的输出不再带动画。transcript 超出视口并跟随底部时,输出增长由跟随滚动吸收,运行指示保持原位,因此这次下滑不播放;被领取的 steering 仍会让运行指示越过其等待中的回显,因此 steering 进场照常播放,同时页面随新增的槽位滚动。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。 顶部用时/状态控件与唤醒标题沿用二级汇总标题字号,并跟随字号设置。已完成用时的数字使用代码等宽字体;运行和完成状态的分钟和秒数均不补前导零。含用时的运行文案末尾附加一个空格及“···”,完成后显示“已完成,用时”。 diff --git a/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts index 1968fe9c00..55ef6c8b1b 100644 --- a/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts +++ b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts @@ -48,15 +48,25 @@ describe('Running status move below the first output', () => { it('lets input folds and the pinned tail change only the duration of a status move', () => { 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 rule outranks it. - const moves = [...view.matchAll(/([^{}]+)\{\s*animation: running-(?:enters-slot|follows-output) /g)].map(match => match[1]!.trim()) + // 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/) } - expect(view).toMatch(/\.column:is\(\[data-input-fold\], \[data-chat-tail-pinned\]\) \.running \{\s*animation-duration: 0s;\s*\}/) + // 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) + 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) })