From 975b57b54aec648baf77219fea8d03c1b231478e Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Sun, 2 Aug 2026 05:29:25 -0700
Subject: [PATCH] feat(web): follow live reasoning tail while collapsed
---
...6-08-02-web-thinking-tail-scroll.i18n.yaml | 6 ++++
.../2026-08-02-web-thinking-tail-scroll.md | 31 ++++++++++++++++
.../2026-08-02-web-thinking-tail-scroll.zh.md | 31 ++++++++++++++++
apps/web/tests/lifecycle-chrome.e2e.ts | 19 ++++++++--
.../client/ui-conversation/README.i18n.yaml | 4 +--
packages/client/ui-conversation/README.md | 2 ++
packages/client/ui-conversation/README.zh.md | 2 ++
.../src/client/chat/AssistantMarkdown.tsx | 9 ++++-
.../src/client/chat/ToolRow.module.css | 5 +++
.../src/client/chat/ToolRow.tsx | 23 +++++++++---
.../tests/chat-tool-row.spec.tsx | 36 +++++++++++++++++++
11 files changed, 158 insertions(+), 10 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md
create mode 100644 .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md
diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml
new file mode 100644
index 0000000000..f4f0ef3891
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md
+2026-08-02-web-thinking-tail-scroll.md: c45840731153627b4ce460ee140257ba33d2c007
+2026-08-02-web-thinking-tail-scroll.zh.md: b8d0444d62294e123bec1d26cb4c07538bbf966f
diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md
new file mode 100644
index 0000000000..c458407311
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md
@@ -0,0 +1,31 @@
+# Agent Note: Web thinking tail scroll — collapsed reasoning follows live output
+
+Status: implemented
+
+English | [中文](2026-08-02-web-thinking-tail-scroll.zh.md)
+
+## Problem
+
+The Web Think row rendered the first reasoning line as its collapsed summary for both settled and streaming blocks. Once that first line existed, every later reasoning delta changed hidden body text only. A fast model therefore looked stationary while it was thinking, and the user had to expand the full chain of thought to verify that output was still moving. The product backlog already called for “thinking: scrolling chain-of-thought updates, expandable”; the current row satisfied only the second half.
+
+## Decision
+
+Only a collapsed Think row whose reasoning block is the active streaming tail follows live output. Its summary is the latest non-blank line instead of the settled first line, and the existing single-line summary element becomes a programmatic horizontal scrollport pinned to `scrollWidth - clientWidth` after each text update. Direct `scrollLeft` assignment deliberately follows real deltas without inventing an independent marquee speed: fast tokens move fast, a paused model stops, and short text stays still because the scroll range is zero.
+
+The behavior is owned by the existing presentation components. `AssistantMarkdown` chooses the latest line only while the Think row is running; `ToolRow` already owns collapsed/open state and therefore owns whether its summary should follow the inline end. No session, wire, durable event, or model-visible contract changes. Expanding removes the collapsed summary and renders the complete reasoning body in ordinary page flow. When the row settles, it restores the stable first line and resets the summary to the left edge. Other tool summaries and settled Think rows retain their existing ellipsis behavior.
+
+## Alternatives considered
+
+**Animate a CSS marquee independent of streaming.** Rejected: it would keep moving through provider stalls and make a slow model look fast, which breaks the throughput signal the interaction exists to expose.
+
+**Always show a fixed suffix of the complete reasoning string.** Rejected: character slicing can cut a word or grapheme, discards the current line’s beginning before overflow actually requires it, and jumps rather than moving with each delta.
+
+**Auto-scroll the expanded reasoning body or the conversation page.** Rejected: expanded content is a reading surface. Forcing it to follow would fight a user who scrolls back; the follower belongs only to the collapsed one-line summary.
+
+## Consequences
+
+The collapsed row now communicates provider cadence through content motion as well as the existing sweep, while the settled transcript remains byte-for-byte stable. The scroll update runs only on React renders the streaming accumulator already causes; it adds no timer, animation loop, subscription, durable state, or transport traffic. A long current reasoning line retains its full DOM text and programmatically clips the already-overflowing prefix, so expansion still reveals the complete block and assistive technology reads the same current summary text.
+
+## Testing
+
+`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable.
diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md
new file mode 100644
index 0000000000..b8d0444d62
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md
@@ -0,0 +1,31 @@
+# Agent Note:Web 思考尾部滚动 —— 折叠态 reasoning 跟随实时输出
+
+Status: implemented
+
+[English](2026-08-02-web-thinking-tail-scroll.md) | 中文
+
+## 问题
+
+Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠摘要。首行一旦出现,之后每个 reasoning delta 只会改变隐藏的正文。于是快速模型在思考时看起来静止,用户必须展开完整思维链才能确认输出仍在推进。产品事项表已经要求“thinking:滚动展示思维链更新、可展开”;当前行只满足了后半项。
+
+## 决策
+
+只有 reasoning block 是当前流式尾部、且仍处于折叠态的 Think 行会跟随实时输出。其摘要使用最新的非空行,而不是结算后的首行;已有单行摘要元素成为程序化横向滚动区,每次文本更新后钉到 `scrollWidth - clientWidth`。这里刻意直接赋值 `scrollLeft`,通过真实 delta 推进而不虚构独立的跑马灯速度:token 快则移动快,模型停顿则停止,短文本因滚动范围为零而保持静止。
+
+该行为由已有呈现组件拥有。`AssistantMarkdown` 只在 Think 行运行时选择最新行;`ToolRow` 已经拥有折叠/展开状态,因此由它决定摘要是否追随行内末端。不改变 session、wire、持久事件或模型可见契约。展开会移除折叠摘要,并让完整 reasoning 正文进入普通页面流。该行结算后恢复稳定首行,同时把摘要重置到左端。其他工具摘要与已结算 Think 行保留已有省略号行为。
+
+## 曾考虑的替代方案
+
+**播放与流式输出无关的 CSS 跑马灯。** 否决:它会在 provider 停顿时继续移动,让慢模型显得很快,破坏该交互本应暴露的吞吐信号。
+
+**始终显示完整 reasoning 字符串的固定后缀。** 否决:按字符切片可能截断单词或字素,在内容真正溢出前就丢掉当前行的开头,而且只会跳变,无法随每个 delta 移动。
+
+**自动滚动展开的 reasoning 正文或会话页面。** 否决:展开内容是阅读界面,强制跟随会与向上回看的用户争夺滚动;跟随器只属于折叠的单行摘要。
+
+## 后果
+
+折叠行现在会同时通过内容移动和已有扫光传达 provider 节奏,而结算后的 transcript 保持逐字节稳定。滚动更新只发生在流式累加器本就会触发的 React 渲染中;不会增加计时器、动画循环、订阅、持久状态或传输流量。较长的当前 reasoning 行仍会把完整文本留在 DOM 中,只以编程方式裁掉已经溢出的前缀,因此展开仍能显示完整 block,辅助技术读到的也仍是同一份当前摘要文本。
+
+## 测试
+
+`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要契约仍然稳定。
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
index e285b0f0ac..65098063ad 100644
--- a/apps/web/tests/lifecycle-chrome.e2e.ts
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -158,8 +158,23 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
- await input.press('Enter')
- const sessionId = await settled
+ const observeTurn = async () => {
+ if (MODE !== 'record') await page.setViewportSize({ width: 640, height: 1000 })
+ try {
+ await input.press('Enter')
+ if (MODE !== 'record') {
+ const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
+ await expect.poll(async () => await liveTail.evaluate(element => (
+ element.scrollWidth > element.clientWidth
+ && element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
+ )), { timeout: 10_000 }).toBe(true)
+ }
+ return await settled
+ } finally {
+ if (MODE !== 'record') await page.setViewportSize({ width: 1680, height: 1000 })
+ }
+ }
+ const sessionId = await observeTurn()
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index a78b80035b..d131e31202 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: ea48725b02ad0c440984af4aadfec17d0e63791d
-README.zh.md: 654901caca68762e313dda456c1e0df23faf734a
+README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439
+README.zh.md: 268e05a806db1468ba689608c178646af132fe25
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index ea48725b02..e610b990dd 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -16,6 +16,8 @@ The session header declares and renders the session-scoped `'conversation.sessio
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
+A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
+
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 654901caca..268e05a806 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -14,6 +14,8 @@
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
+Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
+
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
index 387a7fd82a..f5cbadef8a 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
@@ -39,6 +39,13 @@ function firstLine(text: string): string {
return nl === -1 ? text : text.slice(0, nl)
}
+/** Latest non-blank reasoning line while the block is still streaming. */
+function latestLine(text: string): string {
+ const visible = text.trimEnd()
+ const nl = visible.lastIndexOf('\n')
+ return nl === -1 ? visible : visible.slice(nl + 1)
+}
+
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
function copyText(blocks: readonly AssistantBlock[]): string {
const parts: string[] = []
@@ -61,7 +68,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
variant="think"
icon={}
title="Think"
- summary={firstLine(text)}
+ summary={running ? latestLine(text) : firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
/>
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 81e41b066f..14bfe0e76c 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -84,6 +84,11 @@
color: var(--dsw-alias-label-tertiary);
}
+/* Live reasoning follows its one-line summary to the inline end. */
+.summary[data-follow-end] {
+ text-overflow: clip;
+}
+
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index 46cdbda191..4ec07aa6fc 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -19,7 +19,7 @@
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
-import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
+import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -152,6 +152,7 @@ export function ToolRow({
inspect,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
+ const summaryRef = useRef(null)
const terminalBody = terminal ?? null
const diffBody = diff ?? null
const readBody = read ?? null
@@ -173,6 +174,15 @@ export function ToolRow({
const summaryText = failureLine ?? summary
// The failure line is error prose, not the path: no open-file affordance.
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
+ const isThink = variant === 'think'
+ const followSummaryEnd = isThink && state === 'running' && !open
+ useLayoutEffect(() => {
+ const summaryElement = summaryRef.current
+ if (summaryElement === null) return
+ summaryElement.scrollLeft = followSummaryEnd
+ ? summaryElement.scrollWidth - summaryElement.clientWidth
+ : 0
+ }, [followSummaryEnd, summaryText])
const toggleExpand = () => {
setExpanded(v => !v)
}
@@ -188,9 +198,8 @@ export function ToolRow({
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
}
// Think reasoning is prose, not an input payload: expanded, it renders as
- // plain indented text (no IN/OUT card) and the inline summary — the body's
- // own first line — yields to avoid repeating itself.
- const isThink = variant === 'think'
+ // plain indented text (no IN/OUT card) and the inline summary yields to avoid
+ // repeating the body.
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : body
@@ -227,7 +236,11 @@ export function ToolRow({
{summaryText}
) : (
-
+
{summaryText}
)}
diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
index e0d01656ba..ff9a2a3f07 100644
--- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
@@ -320,6 +320,42 @@ describe('ToolRow', () => {
})
describe('ThinkRow', () => {
+ it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
+ const view = render(
+ ,
+ )
+ const summary = view.getByText('Newest reasoning tokens')
+ Object.defineProperties(summary, {
+ scrollWidth: { configurable: true, value: 300 },
+ clientWidth: { configurable: true, value: 100 },
+ })
+
+ view.rerender(
+ ,
+ )
+ expect(summary.scrollLeft).toBe(200)
+ expect(summary.getAttribute('data-follow-end')).toBe('true')
+
+ view.rerender(
+ ,
+ )
+ expect(view.getByText('Inspect the session')).toBeTruthy()
+ expect(summary.scrollLeft).toBe(0)
+ expect(summary.hasAttribute('data-follow-end')).toBe(false)
+ })
+
it('expands from either Think or the reasoning summary', () => {
const view = render(